From c633b1d2afd4a77a09889ce796ef610fe146ca46 Mon Sep 17 00:00:00 2001 From: menghao Date: Wed, 5 Aug 2026 16:56:06 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E6=88=90=E7=B4=A0=E6=9D=90=E6=97=A0?= =?UTF-8?q?=E9=99=90=E7=94=BB=E5=B8=83=E9=98=B6=E6=AE=B5=E5=9B=9B=E8=B5=84?= =?UTF-8?q?=E6=BA=90=E6=80=BB=E8=A7=88=E5=AE=9E=E6=97=B6=E9=97=AD=E7=8E=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 同步资源总览稳定基线并接入新增与精修入口 实现 revision 单调合并、事件去重和旧 scope 隔离 补齐依赖图、双布局协调和三阶段一次性聚焦 覆盖保存、项目切换、搜索隐藏及异步竞态测试 同步阶段四技术方案、决策记录和排障经验 --- .../src-tauri/src/agent/generation.rs | 2 + .../src/agent/generation/canvas_generation.rs | 409 ++++++++++- .../generation/external_generation_state.rs | 58 +- .../provider_request_builders.rs | 5 +- .../src-tauri/src/agent/runtime_driver.rs | 15 +- .../src/agent/runtime_driver/entrypoints.rs | 141 +++- .../main_loop_deadline_tests.rs | 34 +- .../agent/runtime_driver/pending_execution.rs | 48 +- .../agent/runtime_driver/pending_recovery.rs | 383 +++++++++- .../autonomous_completion_contract_tests.rs | 4 +- .../src/agent/runtime_tools/media.rs | 2 +- .../src-tauri/src/assets.rs | 65 +- .../src-tauri/src/main.rs | 27 +- .../src-tauri/src/project/agent_db.rs | 85 ++- .../src/project/agent_db/security_tests.rs | 136 ++++ .../src-tauri/src/runner/client.rs | 23 +- .../src-tauri/src/runner/dispatch.rs | 20 +- .../src-tauri/src/runner/protocol.rs | 6 +- .../src-tauri/src/runner/tests.rs | 9 +- .../src-tauri/src/tests/mod.rs | 43 ++ apps/ai-game-creator-shell/src/App.tsx | 189 ++++- apps/ai-game-creator-shell/src/app/types.ts | 7 + .../src/features/agent-runtime/model.ts | 7 +- .../features/app-shell/WorkspaceLauncher.tsx | 136 +++- .../src/features/app-shell/model.ts | 2 + .../app-shell/useHomeProjectCreation.ts | 30 + .../asset-canvas/AssetCanvasSurface.tsx | 403 +++++++---- .../tauriImageCanvasHostAdapter.ts | 20 +- .../SupervisorChatOnlyView.tsx | 444 ++++++++---- apps/ai-game-creator-shell/src/styles.css | 327 +++++++-- .../src/view/project-development/index.tsx | 658 +++++++++++++++--- .../projectResourceLiveUpdateModel.ts | 199 ++++++ .../useProjectResourceCanvasLayout.ts | 69 +- .../tests/agentRuntimeModel.test.ts | 29 +- .../tests/appSurface/harness.ts | 26 + .../tests/appSurface/home.suite.ts | 325 +++++++++ .../appSurface/project-development.suite.ts | 319 ++++++--- .../tests/assetCanvasSurface.test.tsx | 133 ++-- .../projectResourceLiveIntegration.test.tsx | 353 ++++++++++ .../projectResourceLiveUpdateModel.test.ts | 200 ++++++ .../resourceDependencyGraphModel.test.ts | 46 +- .../useProjectResourceCanvasLayout.test.ts | 88 ++- apps/desktop-shell/scripts/check-config.mjs | 16 + .../scripts/stage-release-binary.mjs | 8 +- ...AI游戏创作】项目开发工作台PRD-2026-07-20.md | 12 +- .../shared-memory/decision-log.md | 15 +- docs/project-memory/shared-memory/pitfalls.md | 50 +- ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 22 +- ...ExpoReactNative与Tauri宿主壳方案-2026-06-17.md | 2 +- packages/image-canvas-core/src/ports.ts | 4 + scripts/check-native-shells.mjs | 8 +- 51 files changed, 4886 insertions(+), 776 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/view/project-development/projectResourceLiveUpdateModel.ts create mode 100644 apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx create mode 100644 apps/ai-game-creator-shell/tests/projectResourceLiveUpdateModel.test.ts diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs index 49cd08903..fad602c11 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs @@ -16,6 +16,7 @@ mod trace; pub(in crate::agent) use canvas_generation::{ commit_prepared_platform_art_asset_at, commit_prepared_platform_art_asset_strict_slices_at, platform_art_generation_error_needs_reconciliation, + platform_art_generation_error_result_unknown, request_platform_art_asset_with_runtime_options_at, validate_platform_art_png_bytes_with_limits, }; @@ -31,6 +32,7 @@ pub(in crate::agent) use external_generation_state::{ pub(crate) use external_generation_state::{ setup_platform_art_generation_runtime_accepted_for_recovery_test, write_platform_art_generation_runtime_accepted_for_test, + write_platform_art_generation_runtime_prepared_for_test, }; pub(in crate::agent) use loop_orchestration::build_game_creator_agent_runtime_llm_client; pub(in crate::agent) use trace::game_creation_agent_group_id; 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 3cafbd068..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 @@ -7,7 +7,7 @@ use super::external_generation_state::{ platform_art_generation_runtime_request_snapshot, platform_art_generation_runtime_status, platform_art_generation_runtime_submission_payload, prepare_platform_art_generation_runtime_state, read_platform_art_generation_runtime_state, - validate_platform_art_generation_external_configuration, + validate_platform_art_generation_external_configuration, PlatformArtGenerationRuntimeState, }; use super::*; @@ -411,6 +411,10 @@ pub(in crate::agent) fn platform_art_generation_error_needs_reconciliation(error || error.starts_with(PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX) } +pub(in crate::agent) fn platform_art_generation_error_result_unknown(error: &str) -> bool { + error.starts_with(EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX) +} + async fn external_editor_json_request( request: reqwest::RequestBuilder, action: &str, @@ -560,6 +564,72 @@ async fn submit_external_generation_request( }) } +async fn resume_prepared_external_generation_at( + root: &Path, + poll_client: &reqwest::Client, + submit_client: &reqwest::Client, + api_base_url: &str, + api_key: &str, + endpoint: &str, + state: PlatformArtGenerationRuntimeState, +) -> Result { + let response = submit_external_generation_request( + submit_client, + api_base_url, + endpoint, + api_key, + platform_art_generation_runtime_idempotency_key(&state), + platform_art_generation_runtime_request_body_json(&state), + ) + .await?; + let status = response.status(); + if !status.is_success() { + return Err(format!( + "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} External Editor prepared 恢复提交返回 HTTP {};原生成账本已保留", + status.as_u16() + )); + } + let submission_payload = response + .json::() + .await + .map_err(|error| { + format!( + "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 解析 External Editor prepared 恢复响应失败:{error}" + ) + })?; + match classify_external_generation_initial_response(status, &submission_payload)? { + ExternalGenerationInitialResponse::LegacyCompleted(generated) => { + mark_platform_art_generation_runtime_legacy_completed(root, state, &generated).map_err( + |error| { + format!( + "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} prepared 恢复的旧同步结果无法持久化:{error}" + ) + }, + )?; + Ok(generated) + } + ExternalGenerationInitialResponse::AsyncSubmission(submission) => { + let operation_id = + json_string_field(external_editor_response_data(&submission), "operationId") + .expect("202 submission was classified with operationId"); + let poll_after_ms = external_generation_poll_after_ms(&submission); + mark_platform_art_generation_runtime_accepted( + root, + state, + &operation_id, + poll_after_ms, + ) + .map_err(|error| { + format!( + "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} prepared 恢复的 operationId 无法持久化:{error}" + ) + })?; + wait_for_external_generation_result(poll_client, api_base_url, api_key, &submission) + .await + } + } +} + async fn prepare_external_canvas_generation_context( root: &Path, client: &reqwest::Client, @@ -1068,14 +1138,6 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at .transpose()? .flatten(); let recovering_generation = persisted_runtime_state.is_some(); - if persisted_runtime_state - .as_ref() - .is_some_and(|state| platform_art_generation_runtime_status(state) == "prepared") - { - return Err(format!( - "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} External Editor 生成账本停在 prepared,POST 是否已受理未知;禁止自动重放" - )); - } // 首次提交必须在任何远端副作用前完成本地输出校验。accepted / legacy-completed // 恢复则先读取已有持久结果,再校验本地安装目标,避免本地漂移阻断 GET-only 恢复。 let prepared_output_path_before_submit = if persisted_runtime_state.is_none() { @@ -1118,18 +1180,34 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} External Editor 生成账本请求快照无法恢复:{error}" ) })?; - let generated = if platform_art_generation_runtime_status(&state) == "accepted" { - let submission = platform_art_generation_runtime_submission_payload(&state) - .map_err(|error| format!("{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} {error}"))?; - wait_for_external_generation_result(&client, &api_base_url, &api_key, &submission) + let generated = match platform_art_generation_runtime_status(&state) { + "prepared" => { + resume_prepared_external_generation_at( + root, + &client, + &submit_client, + &api_base_url, + &api_key, + &snapshot.endpoint, + state, + ) .await? - } else if platform_art_generation_runtime_status(&state) == "legacy-completed" { - platform_art_generation_runtime_legacy_result(&state) - .map_err(|error| format!("{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} {error}"))? - } else { - return Err(format!( - "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} External Editor 生成账本状态无法恢复" - )); + } + "accepted" => { + let submission = platform_art_generation_runtime_submission_payload(&state) + .map_err(|error| { + format!("{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} {error}") + })?; + wait_for_external_generation_result(&client, &api_base_url, &api_key, &submission) + .await? + } + "legacy-completed" => platform_art_generation_runtime_legacy_result(&state) + .map_err(|error| format!("{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} {error}"))?, + _ => { + return Err(format!( + "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} External Editor 生成账本状态无法恢复" + )); + } }; let is_canonical_art_spritesheet = snapshot.generation_kind == "icon-spritesheet"; ( @@ -5630,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]; @@ -5671,6 +5749,13 @@ mod canvas_generation_tests { .expect("expected request header") } + fn test_request_body(request: &str) -> &str { + request + .split_once("\r\n\r\n") + .map(|(_, body)| body) + .expect("expected request body separator") + } + fn rgba_test_png(alpha: u8) -> CanvasResourceDownload { rgba_test_png_with_quality(alpha, CompressionType::Fast, FilterType::Adaptive) } @@ -5871,6 +5956,288 @@ mod canvas_generation_tests { assert!(receiver.recv_timeout(Duration::from_millis(200)).is_err()); } + #[tokio::test] + async fn prepared_runtime_generation_reuses_exact_post_bytes_and_key_before_polling() { + let temporary = tempfile::tempdir().expect("create prepared recovery project"); + let root = temporary.path(); + init_local_game_project_at(root, "prepared-recovery", "原俄罗斯方块项目") + .expect("init prepared recovery project"); + write_project_permission_policy_at( + root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: Vec::new(), + agent_policies: BTreeMap::new(), + }, + ) + .expect("allow prepared recovery generation"); + + let listener = + std::net::TcpListener::bind("127.0.0.1:0").expect("bind prepared recovery fixture"); + let base_url = format!("http://{}", listener.local_addr().expect("fixture address")); + let server_base_url = base_url.clone(); + let png = rgba_test_png(u8::MAX).bytes; + let (request_sender, request_receiver) = std::sync::mpsc::channel(); + let server = std::thread::spawn(move || { + for request_index in 0..4 { + let (mut stream, _) = listener.accept().expect("accept prepared recovery request"); + let request = read_test_http_request(&mut stream); + request_sender + .send(request) + .expect("capture prepared recovery request"); + match request_index { + 0 => {} + 1 => { + let body = serde_json::json!({ + "data": { + "operationId": "prepared-operation-1", + "status": "queued", + "pollAfterMs": 0 + } + }) + .to_string(); + write!( + stream, + "HTTP/1.1 202 Accepted\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ) + .expect("write prepared recovery submission"); + } + 2 => { + let body = serde_json::json!({ + "data": { + "operationId": "prepared-operation-1", + "status": "completed", + "pollAfterMs": 0, + "result": { + "resource": { + "resourceId": "prepared-resource-1", + "projectId": "persisted-canvas-project", + "imageSrc": format!("{server_base_url}/download.png") + } + } + } + }) + .to_string(); + write!( + stream, + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ) + .expect("write prepared recovery result"); + } + 3 => { + write!( + stream, + "HTTP/1.1 200 OK\r\nContent-Type: image/png\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + png.len() + ) + .and_then(|_| stream.write_all(&png)) + .expect("write prepared recovery download"); + } + _ => unreachable!("prepared recovery request count is bounded"), + } + } + }); + let _config_guard = crate::tests::write_test_local_config( + serde_json::json!({ + "editorApi": { + "baseUrl": base_url, + "apiKey": "prepared-recovery-key" + } + }) + .to_string(), + ); + let runtime_context = PlatformArtGenerationRuntimeContext { + agent_id: "art-director".to_string(), + task_id: "art-director".to_string(), + session_id: "prepared-recovery-session".to_string(), + run_id: "prepared-recovery-run".to_string(), + source: "agent-ready-task-scheduler".to_string(), + action_id: "prepared-recovery-action".to_string(), + action_fingerprint: "prepared-recovery-fingerprint".to_string(), + }; + let request_body = serde_json::json!({ + "prompt": "持久化且必须原样重发的生成正文", + "kind": "spec", + "projectId": "persisted-canvas-project", + "assetFolderId": "persisted-asset-folder", + "referenceImageSrcs": [] + }); + let configuration_fingerprint = platform_art_generation_external_configuration_fingerprint( + &base_url, + "prepared-recovery-key", + ); + let (state, created) = prepare_platform_art_generation_runtime_state( + root, + &runtime_context, + "/api/external/v1/editor/images/generations", + "持久化画布名", + "持久化的生成提示词", + &request_body, + &configuration_fingerprint, + ) + .expect("prepare recovery ledger"); + assert!(created); + let stable_key = platform_art_generation_runtime_idempotency_key(&state).to_string(); + let stable_body = platform_art_generation_runtime_request_body_json(&state).to_string(); + let first_submit_client = reqwest::Client::builder() + .timeout(Duration::from_secs(2)) + .build() + .expect("build first submit client"); + let first_error = submit_external_generation_request( + &first_submit_client, + &base_url, + "/api/external/v1/editor/images/generations", + "prepared-recovery-key", + &stable_key, + &stable_body, + ) + .await + .expect_err("first response is intentionally lost"); + assert!(platform_art_generation_error_result_unknown(&first_error)); + + let prepared = request_platform_art_asset_with_runtime_options_at( + root, + "恢复时不得重建这个提示词", + &[], + &PlatformArtAssetGenerationOptions::default(), + Some(&runtime_context), + ) + .await + .expect("resume prepared generation with the durable request"); + server.join().expect("join prepared recovery fixture"); + assert_eq!( + prepared.canvas_context.project_id, + "persisted-canvas-project" + ); + + let requests = std::iter::from_fn(|| { + request_receiver + .recv_timeout(Duration::from_millis(100)) + .ok() + }) + .collect::>(); + assert_eq!(requests.len(), 4); + assert!(requests[0].starts_with("POST /api/external/v1/editor/images/generations ")); + assert!(requests[1].starts_with("POST /api/external/v1/editor/images/generations ")); + assert_eq!( + test_request_header(&requests[0], "idempotency-key"), + &stable_key + ); + assert_eq!( + test_request_header(&requests[1], "idempotency-key"), + &stable_key + ); + assert_eq!(test_request_body(&requests[0]), stable_body); + assert_eq!(test_request_body(&requests[1]), stable_body); + assert!(requests[2].starts_with("GET /api/external/v1/generations/prepared-operation-1 ")); + assert!(requests[3].starts_with("GET /download.png ")); + + let persisted = read_platform_art_generation_runtime_state(root, &runtime_context) + .expect("read accepted recovery ledger") + .expect("accepted recovery ledger exists"); + let submission = platform_art_generation_runtime_submission_payload(&persisted) + .expect("accepted recovery submission payload"); + assert_eq!(submission["operationId"], "prepared-operation-1"); + } + + #[tokio::test] + async fn prepared_recovery_auth_rejection_keeps_original_ledger() { + let temporary = tempfile::tempdir().expect("create prepared auth project"); + let root = temporary.path(); + init_local_game_project_at(root, "prepared-auth", "原俄罗斯方块项目") + .expect("init prepared auth project"); + let listener = + std::net::TcpListener::bind("127.0.0.1:0").expect("bind prepared auth fixture"); + let base_url = format!("http://{}", listener.local_addr().expect("fixture address")); + let (request_sender, request_receiver) = std::sync::mpsc::channel(); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept prepared auth request"); + request_sender + .send(read_test_http_request(&mut stream)) + .expect("capture prepared auth request"); + stream + .write_all( + b"HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ) + .expect("write prepared auth rejection"); + }); + let runtime_context = PlatformArtGenerationRuntimeContext { + agent_id: "art-director".to_string(), + task_id: "art-director".to_string(), + session_id: "prepared-auth-session".to_string(), + run_id: "prepared-auth-run".to_string(), + source: "agent-ready-task-scheduler".to_string(), + action_id: "prepared-auth-action".to_string(), + action_fingerprint: "prepared-auth-fingerprint".to_string(), + }; + let request_body = serde_json::json!({ + "prompt": "持久化且不得因恢复鉴权失败删除的正文", + "kind": "spec", + "projectId": "persisted-canvas-project", + "assetFolderId": "persisted-asset-folder", + "referenceImageSrcs": [] + }); + let fingerprint = platform_art_generation_external_configuration_fingerprint( + &base_url, + "prepared-auth-key", + ); + let (state, created) = prepare_platform_art_generation_runtime_state( + root, + &runtime_context, + "/api/external/v1/editor/images/generations", + "持久化画布名", + "持久化的生成提示词", + &request_body, + &fingerprint, + ) + .expect("prepare auth recovery ledger"); + assert!(created); + let stable_key = platform_art_generation_runtime_idempotency_key(&state).to_string(); + let stable_body = platform_art_generation_runtime_request_body_json(&state).to_string(); + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(2)) + .build() + .expect("build prepared auth client"); + let error = resume_prepared_external_generation_at( + root, + &client, + &client, + &base_url, + "prepared-auth-key", + "/api/external/v1/editor/images/generations", + state, + ) + .await + .expect_err("auth rejection cannot prove the original request was not accepted"); + server.join().expect("join prepared auth fixture"); + assert!(platform_art_generation_error_result_unknown(&error)); + assert!(error.contains("原生成账本已保留")); + let request = request_receiver + .recv_timeout(Duration::from_secs(1)) + .expect("prepared auth request"); + assert_eq!(test_request_header(&request, "idempotency-key"), stable_key); + assert_eq!(test_request_body(&request), stable_body); + let persisted = read_platform_art_generation_runtime_state(root, &runtime_context) + .expect("read preserved auth ledger") + .expect("preserved auth ledger exists"); + assert_eq!( + platform_art_generation_runtime_status(&persisted), + "prepared" + ); + assert_eq!( + platform_art_generation_runtime_idempotency_key(&persisted), + stable_key + ); + assert_eq!( + platform_art_generation_runtime_request_body_json(&persisted), + stable_body + ); + } + #[tokio::test] async fn async_generation_202_polls_queued_running_and_completed_result() { let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind polling fixture"); 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 e439cbb11..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 @@ -62,7 +62,7 @@ pub(super) struct PlatformArtGenerationRuntimeRequestSnapshot { #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(in crate::agent) enum PlatformArtGenerationRuntimeRecovery { Missing, - PreparedResultUnknown, + ResumePrepared, ResumeAccepted, ResumeLegacyCompleted, } @@ -181,6 +181,15 @@ fn validate_platform_art_generation_runtime_identity( if state.request_body_sha256 != request_body_sha256 { return Err("External Editor 生成账本请求正文指纹不匹配".to_string()); } + if state.idempotency_key.is_empty() + || state.idempotency_key.len() > 128 + || !state + .idempotency_key + .bytes() + .all(|byte| byte.is_ascii_graphic()) + { + return Err("External Editor 生成账本 Idempotency-Key 无效".to_string()); + } platform_art_generation_runtime_request_snapshot(state)?; if state.status == PLATFORM_ART_GENERATION_STATUS_ACCEPTED && state.operation_id.as_deref().is_none_or(str::is_empty) @@ -610,7 +619,7 @@ pub(in crate::agent) fn platform_art_generation_runtime_recovery_at( }; Ok(match state.status.as_str() { PLATFORM_ART_GENERATION_STATUS_PREPARED => { - PlatformArtGenerationRuntimeRecovery::PreparedResultUnknown + PlatformArtGenerationRuntimeRecovery::ResumePrepared } PLATFORM_ART_GENERATION_STATUS_ACCEPTED => { PlatformArtGenerationRuntimeRecovery::ResumeAccepted @@ -684,6 +693,38 @@ pub(crate) fn write_platform_art_generation_runtime_accepted_for_test( Ok(()) } +#[cfg(test)] +pub(crate) fn write_platform_art_generation_runtime_prepared_for_test( + root: &Path, + pending: &AgentRuntimePendingToolAction, +) -> Result<(), String> { + let context = platform_art_generation_runtime_context_from_pending(pending); + let api_base_url = + resolve_canvas_sync_api_base_url(None).unwrap_or_else(|_| "http://127.0.0.1:1".to_string()); + let api_key = resolve_canvas_sync_api_key(None).unwrap_or_else(|_| "test-api-key".to_string()); + let external_configuration_fingerprint = + platform_art_generation_external_configuration_fingerprint(&api_base_url, &api_key); + let (_, created) = prepare_platform_art_generation_runtime_state( + root, + &context, + "/api/external/v1/editor/images/generations", + "durable-test-canvas", + "durable test generation", + &serde_json::json!({ + "prompt": "durable test generation", + "kind": "spec", + "projectId": "test-canvas-project", + "assetFolderId": "test-asset-folder", + "referenceImageSrcs": [] + }), + &external_configuration_fingerprint, + )?; + if !created { + return Err("External Editor 测试账本已存在".to_string()); + } + Ok(()) +} + #[cfg(test)] pub(crate) fn setup_platform_art_generation_runtime_accepted_for_recovery_test( root: &Path, @@ -796,7 +837,7 @@ mod external_generation_state_tests { } #[test] - fn prepared_generation_state_reuses_identity_and_only_accepted_can_resume() { + fn prepared_generation_state_reuses_identity_and_transitions_to_accepted() { let temporary = crate::tests::canonical_test_tempdir("external-generation-ledger-"); let root = temporary.path(); init_local_game_project_at(root, "generation-ledger", "生成账本测试") @@ -827,6 +868,13 @@ mod external_generation_state_tests { ) .expect("prepare generation ledger"); assert!(created); + let mut invalid_key = prepared.clone(); + invalid_key.idempotency_key = "invalid key".to_string(); + assert!( + validate_platform_art_generation_runtime_identity(root, &invalid_key, &context) + .expect_err("spaces are forbidden by the External v1 key contract") + .contains("Idempotency-Key") + ); validate_platform_art_generation_external_configuration( &prepared, "https://editor.example.test/", @@ -848,7 +896,7 @@ mod external_generation_state_tests { assert_eq!( platform_art_generation_runtime_recovery_at(root, &pending) .expect("read prepared recovery"), - PlatformArtGenerationRuntimeRecovery::PreparedResultUnknown + PlatformArtGenerationRuntimeRecovery::ResumePrepared ); let stable_key = prepared.idempotency_key.clone(); let (reloaded, created_again) = prepare_platform_art_generation_runtime_state( @@ -1013,7 +1061,7 @@ mod external_generation_state_tests { assert_eq!( platform_art_generation_runtime_recovery_at(root, &pending) .expect("read prepared unsafe legacy recovery"), - PlatformArtGenerationRuntimeRecovery::PreparedResultUnknown + PlatformArtGenerationRuntimeRecovery::ResumePrepared ); } 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 e8e9415a8..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"); @@ -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..7a03a5eed 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs @@ -2,6 +2,9 @@ use super::*; pub(super) static GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE: OnceLock = OnceLock::new(); +pub(super) static GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK: OnceLock< + std::sync::Mutex>, +> = OnceLock::new(); pub(super) static STATIC_DELEGATE_PARENT_WAKE_SINGLEFLIGHT: OnceLock< std::sync::Mutex>, > = OnceLock::new(); @@ -234,15 +237,21 @@ pub(in crate::agent) use recovery_scan::*; pub(in crate::agent) use task_queue::*; pub(in crate::agent) use task_start::*; +#[cfg(test)] +pub(crate) use entrypoints::clear_game_creator_manifest_invalidation_event_sink_for_test; #[allow(unused_imports)] pub(crate) use entrypoints::{ chat_with_game_creator_agent_at, chat_with_game_creator_role_agent_at, chat_with_game_creator_role_agent_for_session_at, chat_with_game_creator_role_agent_runtime_at, chat_with_game_creator_role_agent_runtime_for_session_at, chat_with_game_creator_role_agent_stream_at, - chat_with_game_creator_role_agent_stream_for_session_at, generate_local_game_draft_at, - read_game_creator_agent_runtime_at, read_game_creator_agent_runtime_for_session_at, - read_game_creator_agent_runtimes_at, set_game_creator_agent_runtime_update_app_handle, + chat_with_game_creator_role_agent_stream_for_session_at, + configure_game_creator_manifest_invalidation_event_sink, + emit_game_creator_agent_runtime_update, game_creator_agent_runtime_update_event, + generate_local_game_draft_at, read_game_creator_agent_runtime_at, + read_game_creator_agent_runtime_for_session_at, read_game_creator_agent_runtimes_at, + set_game_creator_agent_runtime_update_app_handle, + start_game_creator_manifest_invalidation_event_sink, }; #[cfg(test)] pub(crate) use finalization::resume_game_creator_agent_finalization_for_test_at; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs index 29e951e8d..04b6afc71 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs @@ -1,30 +1,145 @@ use super::*; +const GAME_CREATOR_MANIFEST_INVALIDATION_RELAY_MAX_BYTES: u64 = 64 * 1024; + +fn lock_game_creator_manifest_invalidation_event_sink( +) -> std::sync::MutexGuard<'static, Option> { + GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK + .get_or_init(|| Mutex::new(None)) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} + pub(crate) fn set_game_creator_agent_runtime_update_app_handle(app: tauri::AppHandle) { let _ = GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE.set(app); } -pub(in crate::agent) fn emit_game_creator_agent_runtime_update(root: &Path, agent_id: &str) { +pub(crate) fn start_game_creator_manifest_invalidation_event_sink( + app: tauri::AppHandle, +) -> Result { + let listener = TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)) + .map_err(|error| format!("绑定 manifest 失效事件接收端失败:{error}"))?; + let port = listener + .local_addr() + .map_err(|error| format!("读取 manifest 失效事件接收端失败:{error}"))? + .port(); + let token = format!( + "{}{}", + uuid::Uuid::new_v4().simple(), + uuid::Uuid::new_v4().simple() + ); + let expected_token = token.clone(); + thread::Builder::new() + .name("manifest-invalidation-event-sink".to_string()) + .spawn(move || { + for incoming in listener.incoming() { + let Ok(mut stream) = incoming else { + continue; + }; + let _ = stream.set_read_timeout(Some(Duration::from_millis(250))); + let mut payload = Vec::new(); + let mut limited = + (&mut stream).take(GAME_CREATOR_MANIFEST_INVALIDATION_RELAY_MAX_BYTES + 1); + if limited.read_to_end(&mut payload).is_err() + || payload.len() as u64 > GAME_CREATOR_MANIFEST_INVALIDATION_RELAY_MAX_BYTES + { + continue; + } + let Ok(envelope) = serde_json::from_slice::< + GameCreatorManifestInvalidationRelayEnvelope, + >(&payload) else { + continue; + }; + if envelope.token != expected_token { + continue; + } + let _ = app.emit("game-creator-manifest-invalidated", envelope.event); + } + }) + .map_err(|error| format!("启动 manifest 失效事件接收端失败:{error}"))?; + Ok(GameCreatorManifestInvalidationEventSink { port, token }) +} + +pub(crate) fn configure_game_creator_manifest_invalidation_event_sink( + port: u16, + token: &str, +) -> Result<(), String> { + if port == 0 { + return Err("manifest 失效事件接收端口无效".to_string()); + } + let token = token.trim(); + if token.len() != 64 || !token.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err("manifest 失效事件接收令牌无效".to_string()); + } + *lock_game_creator_manifest_invalidation_event_sink() = + Some(GameCreatorManifestInvalidationEventSink { + port, + token: token.to_string(), + }); + Ok(()) +} + +#[cfg(test)] +pub(crate) fn clear_game_creator_manifest_invalidation_event_sink_for_test() { + *lock_game_creator_manifest_invalidation_event_sink() = None; +} + +fn relay_game_creator_manifest_invalidation(root: &Path, agent_id: &str) -> Result<(), String> { + let sink = lock_game_creator_manifest_invalidation_event_sink().clone(); + let Some(sink) = sink else { + return Ok(()); + }; + let envelope = GameCreatorManifestInvalidationRelayEnvelope { + token: sink.token, + event: GameCreatorManifestInvalidatedEvent { + project_path: root.to_string_lossy().into_owned(), + agent_id: agent_id.to_string(), + }, + }; + let payload = serde_json::to_vec(&envelope) + .map_err(|error| format!("序列化 manifest 失效事件失败:{error}"))?; + if payload.len() as u64 > GAME_CREATOR_MANIFEST_INVALIDATION_RELAY_MAX_BYTES { + return Err("manifest 失效事件超过大小上限".to_string()); + } + let address = std::net::SocketAddrV4::new(std::net::Ipv4Addr::LOCALHOST, sink.port).into(); + let mut stream = TcpStream::connect_timeout(&address, Duration::from_millis(100)) + .map_err(|error| format!("连接 manifest 失效事件接收端失败:{error}"))?; + stream + .set_write_timeout(Some(Duration::from_millis(100))) + .map_err(|error| format!("配置 manifest 失效事件发送超时失败:{error}"))?; + stream + .write_all(&payload) + .map_err(|error| format!("发送 manifest 失效事件失败:{error}")) +} + +pub(crate) fn game_creator_agent_runtime_update_event( + root: &Path, + runtime: AgentRuntimeResult, +) -> GameCreatorAgentRuntimeUpdateEvent { + GameCreatorAgentRuntimeUpdateEvent { + project_path: root.to_string_lossy().into_owned(), + agent_id: runtime.state.agent_id.clone(), + run_id: runtime.state.run_id.clone(), + status: runtime.state.status.clone(), + phase: runtime.state.phase.clone(), + manifest_invalidated: true, + runtime, + } +} + +pub(crate) fn emit_game_creator_agent_runtime_update(root: &Path, agent_id: &str) { + if GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE.get().is_none() { + let _ = relay_game_creator_manifest_invalidation(root, agent_id); + } let Some(app) = GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE.get() else { return; }; let Ok(runtime) = read_game_creator_agent_runtime_at(root, agent_id) else { return; }; - let agent_id = runtime.state.agent_id.clone(); - let run_id = runtime.state.run_id.clone(); - let status = runtime.state.status.clone(); - let phase = runtime.state.phase.clone(); let _ = app.emit( "game-creator-agent-runtime-update", - GameCreatorAgentRuntimeUpdateEvent { - project_path: root.to_string_lossy().into_owned(), - agent_id, - run_id, - status, - phase, - runtime, - }, + game_creator_agent_runtime_update_event(root, runtime), ); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/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 c7e1e211d..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,7 +63,7 @@ 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() { +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", "硬截止收尾测试") @@ -228,38 +228,6 @@ 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(); } 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 6940911ec..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 @@ -285,28 +285,30 @@ async fn continue_game_creator_agent_pending_tool_action_within_stack_boundary( ); return; } - let pre_observation_context_bundle = - if pending.action.tool == GAME_CREATOR_USER_INPUT_REQUEST_TOOL { - match read_game_creator_agent_runtime_context_bundle_with_superseded_goal( - &root, - &runtime, - Some(&pending), - false, - ) { - Ok(bundle) => Some(bundle), - Err(error) => { - let _ = mark_game_creator_agent_runtime_needs_reconciliation_at( - &root, - &mut runtime, - &pending, - &format!("恢复用户输入请求的 Runtime context bundle 失败:{error}"), - ); - return; - } + let pre_observation_context_bundle = if matches!( + pending.action.tool.as_str(), + GAME_CREATOR_USER_INPUT_REQUEST_TOOL | "canvas.asset_generate" + ) { + match read_game_creator_agent_runtime_context_bundle_with_superseded_goal( + &root, + &runtime, + Some(&pending), + false, + ) { + Ok(bundle) => Some(bundle), + Err(error) => { + let _ = mark_game_creator_agent_runtime_needs_reconciliation_at( + &root, + &mut runtime, + &pending, + &format!("恢复工具动作的 Runtime context bundle 失败:{error}"), + ); + return; } - } else { - None - }; + } + } else { + None + }; if pending.status == AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED { if let Err(error) = validate_agent_runtime_pending_current_goal_snapshot(&root, &pending) { pending.status = AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_REJECTED.to_string(); @@ -1090,7 +1092,9 @@ pub(in crate::agent) fn mark_game_creator_agent_runtime_tool_observation_needs_r observation, Some(&pending.action_id), ); - complete_agent_runtime_active_plan_step(runtime, "failed", &observation_summary); + // needs-reconciliation 是外部结果未知边界,不是结构化计划步骤的确定失败。 + // 保持 active,后续同一 action 对账成功时才能完成该步骤,并让持久 context + // bundle 继续与 Runtime plan projection 保持一致。 let mut error = agent_runtime_public_observation_detail(root, observation) .filter(|detail| !detail.trim().is_empty()) .map(|detail| format!("{observation_summary};{detail}")) 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 38c31ad35..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 @@ -21,6 +21,47 @@ pub(in crate::agent) fn agent_runtime_pending_is_replayable_supervisor_delivery_ ) } +fn prepare_recoverable_canvas_generation_pending_for_resume_at( + root: &Path, + pending: &mut AgentRuntimePendingToolAction, +) -> Result { + if pending.action.tool != "canvas.asset_generate" { + return Ok(false); + } + let recovery = match platform_art_generation_runtime_recovery_at(root, pending) { + Ok(recovery) => recovery, + Err(_) => return Ok(false), + }; + let observed_recoverable = pending.status + == AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_APPROVED + && pending.observation.as_ref().is_some_and(|observation| { + observation.status == AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION + && match recovery { + PlatformArtGenerationRuntimeRecovery::ResumePrepared => { + platform_art_generation_error_result_unknown(&observation.summary) + } + PlatformArtGenerationRuntimeRecovery::ResumeAccepted + | PlatformArtGenerationRuntimeRecovery::ResumeLegacyCompleted => true, + PlatformArtGenerationRuntimeRecovery::Missing => false, + } + }); + let legacy_executing = pending.status == AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING + && matches!( + recovery, + PlatformArtGenerationRuntimeRecovery::ResumePrepared + | PlatformArtGenerationRuntimeRecovery::ResumeAccepted + | PlatformArtGenerationRuntimeRecovery::ResumeLegacyCompleted + ); + if !observed_recoverable && !legacy_executing { + return Ok(false); + } + pending.status = AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED.to_string(); + pending.observation = None; + pending.updated_at = unix_timestamp(); + write_game_creator_agent_runtime_pending_tool_action(root, pending)?; + Ok(true) +} + pub(in crate::agent) fn replay_supervisor_delivery_pending_action_at( root: &Path, pending: &AgentRuntimePendingToolAction, @@ -581,7 +622,15 @@ pub(crate) fn resume_game_creator_agent_pending_tool_action_at( let mut can_repair_terminal_receipt = agent_runtime_pending_has_persisted_terminal_observation(&pending) || agent_runtime_pending_is_replayable_supervisor_delivery_action(&pending); - if has_reconciliation_barrier && !can_repair_terminal_receipt { + let mut resumes_durable_external_generation = false; + if prepare_recoverable_canvas_generation_pending_for_resume_at(root, &mut pending)? { + can_repair_terminal_receipt = false; + resumes_durable_external_generation = true; + } + if has_reconciliation_barrier + && !can_repair_terminal_receipt + && !resumes_durable_external_generation + { return read_game_creator_agent_runtime_at(root, agent_id) .map(AgentRuntimePendingActionResume::Handled); } @@ -774,7 +823,8 @@ pub(crate) fn resume_game_creator_agent_pending_tool_action_at( { match platform_art_generation_runtime_recovery_at(root, &pending) { Ok( - PlatformArtGenerationRuntimeRecovery::ResumeAccepted + PlatformArtGenerationRuntimeRecovery::ResumePrepared + | PlatformArtGenerationRuntimeRecovery::ResumeAccepted | PlatformArtGenerationRuntimeRecovery::ResumeLegacyCompleted, ) => { pending.status = AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED.to_string(); @@ -782,16 +832,6 @@ pub(crate) fn resume_game_creator_agent_pending_tool_action_at( pending.updated_at = unix_timestamp(); write_game_creator_agent_runtime_pending_tool_action(root, &pending)?; } - Ok(PlatformArtGenerationRuntimeRecovery::PreparedResultUnknown) => { - mark_game_creator_agent_runtime_needs_reconciliation_at( - root, - &mut runtime, - &pending, - "External Editor 生成账本停在 prepared,POST 是否受理未知;Runtime 禁止自动重放", - )?; - return read_game_creator_agent_runtime_at(root, agent_id) - .map(AgentRuntimePendingActionResume::Handled); - } Ok(PlatformArtGenerationRuntimeRecovery::Missing) => { mark_game_creator_agent_runtime_needs_reconciliation_at( root, @@ -1285,3 +1325,322 @@ pub(crate) fn resume_game_creator_agent_provider_action_batch_for_test_at( AgentRuntimePendingActionResume::NotFound(_) => Ok("not-found"), } } + +#[cfg(test)] +mod pending_recovery_tests { + use super::*; + + #[test] + fn observed_unknown_canvas_generation_returns_to_same_approved_action() { + 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"); + let mut runtime = start_game_creator_agent_runtime_task_at( + root, + "art-asset-plan", + "继续原俄罗斯方块素材任务", + "prepared-pending-run", + "agent-ready-task-scheduler", + "等待图集生成恢复", + vec!["继续同一素材任务".to_string()], + ) + .expect("start prepared pending runtime"); + runtime.loop_iteration = 1; + let action = AgentRuntimeToolAction { + tool: "canvas.asset_generate".to_string(), + reason: Some("恢复同一幂等图集生成".to_string()), + input: serde_json::json!({ + "prompt": "继续原俄罗斯方块素材任务", + "outputPath": "assets/art-spritesheet.png" + }), + }; + let plan = AgentRuntimeToolPlan { + thinking_summary: "恢复原任务".to_string(), + plan_update: None, + plan: vec!["继续同一素材任务".to_string()], + actions: vec![action.clone()], + response: String::new(), + }; + let revision = + read_game_creator_agent_runtime_project_revision(root).expect("read project revision"); + let repository_fingerprint = build_repository_startup_context_at(root) + .expect("read repository context") + .fingerprint; + let mut pending = build_game_creator_agent_runtime_pending_tool_action( + root, + &runtime, + &runtime.current_task, + &plan, + &[], + &revision, + &repository_fingerprint, + &action, + 0, + AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO, + AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_APPROVED, + Some(AgentRuntimeToolObservation { + tool: "canvas.asset_generate".to_string(), + status: AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION.to_string(), + summary: "platform-generation-result-unknown: 首次 POST 响应丢失".to_string(), + detail: None, + }), + ) + .expect("build observed prepared pending action"); + write_game_creator_agent_runtime_pending_tool_action(root, &pending) + .expect("write observed prepared pending action"); + write_platform_art_generation_runtime_prepared_for_test(root, &pending) + .expect("write prepared generation ledger"); + + assert!( + prepare_recoverable_canvas_generation_pending_for_resume_at(root, &mut pending) + .expect("prepare same action for durable generation resume") + ); + assert_eq!(pending.status, AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED); + assert!(pending.observation.is_none()); + let persisted = read_game_creator_agent_runtime_pending_tool_action( + root, + &pending.agent_id, + &pending.run_id, + ) + .expect("read resumed pending action"); + assert_eq!( + persisted.status, + AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED + ); + assert!(persisted.observation.is_none()); + } + + #[test] + fn legacy_executing_canvas_generation_returns_to_same_approved_action() { + 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"); + let mut runtime = start_game_creator_agent_runtime_task_at( + root, + "art-asset-plan", + "继续旧版俄罗斯方块素材任务", + "executing-prepared-run", + "agent-ready-task-scheduler", + "等待旧版图集生成恢复", + vec!["继续同一素材任务".to_string()], + ) + .expect("start executing prepared runtime"); + runtime.loop_iteration = 1; + let action = AgentRuntimeToolAction { + tool: "canvas.asset_generate".to_string(), + reason: Some("恢复旧版同一幂等图集生成".to_string()), + input: serde_json::json!({ + "prompt": "继续旧版俄罗斯方块素材任务", + "outputPath": "assets/art-spritesheet.png" + }), + }; + let plan = AgentRuntimeToolPlan { + thinking_summary: "恢复旧版原任务".to_string(), + plan_update: None, + plan: vec!["继续同一素材任务".to_string()], + actions: vec![action.clone()], + response: String::new(), + }; + let revision = + read_game_creator_agent_runtime_project_revision(root).expect("read project revision"); + let repository_fingerprint = build_repository_startup_context_at(root) + .expect("read repository context") + .fingerprint; + let mut pending = build_game_creator_agent_runtime_pending_tool_action( + root, + &runtime, + &runtime.current_task, + &plan, + &[], + &revision, + &repository_fingerprint, + &action, + 0, + AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO, + AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING, + None, + ) + .expect("build executing prepared pending action"); + write_game_creator_agent_runtime_pending_tool_action(root, &pending) + .expect("write executing prepared pending action"); + write_platform_art_generation_runtime_prepared_for_test(root, &pending) + .expect("write prepared generation ledger"); + + assert!( + prepare_recoverable_canvas_generation_pending_for_resume_at(root, &mut pending) + .expect("prepare legacy executing action for durable generation resume") + ); + assert_eq!(pending.status, AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED); + assert!(pending.observation.is_none()); + let persisted = read_game_creator_agent_runtime_pending_tool_action( + root, + &pending.agent_id, + &pending.run_id, + ) + .expect("read resumed executing pending action"); + assert_eq!(persisted.action_id, pending.action_id); + assert_eq!( + persisted.status, + AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED + ); + assert!(persisted.observation.is_none()); + } + + #[test] + fn observed_postprocessing_failure_resumes_from_accepted_generation() { + 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"); + let mut runtime = start_game_creator_agent_runtime_task_at( + root, + "art-asset-plan", + "继续已受理的俄罗斯方块素材任务", + "accepted-recovery-run", + "agent-ready-task-scheduler", + "等待素材后处理恢复", + vec!["继续同一素材任务".to_string()], + ) + .expect("start accepted recovery runtime"); + runtime.loop_iteration = 1; + let action = AgentRuntimeToolAction { + tool: "canvas.asset_generate".to_string(), + reason: Some("恢复已受理素材的后处理".to_string()), + input: serde_json::json!({ + "prompt": "继续已受理的俄罗斯方块素材任务", + "outputPath": "assets/art-spritesheet.png" + }), + }; + let plan = AgentRuntimeToolPlan { + thinking_summary: "恢复已受理结果".to_string(), + plan_update: None, + plan: vec!["继续同一素材任务".to_string()], + actions: vec![action.clone()], + response: String::new(), + }; + let revision = + read_game_creator_agent_runtime_project_revision(root).expect("read project revision"); + let repository_fingerprint = build_repository_startup_context_at(root) + .expect("read repository context") + .fingerprint; + let mut pending = build_game_creator_agent_runtime_pending_tool_action( + root, + &runtime, + &runtime.current_task, + &plan, + &[], + &revision, + &repository_fingerprint, + &action, + 0, + AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO, + AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_APPROVED, + Some(AgentRuntimeToolObservation { + tool: "canvas.asset_generate".to_string(), + status: AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION.to_string(), + summary: "画板资产下载暂时失败".to_string(), + detail: None, + }), + ) + .expect("build observed accepted pending action"); + write_game_creator_agent_runtime_pending_tool_action(root, &pending) + .expect("write observed accepted pending action"); + write_platform_art_generation_runtime_accepted_for_test(root, &pending) + .expect("write accepted generation ledger"); + + assert!( + prepare_recoverable_canvas_generation_pending_for_resume_at(root, &mut pending) + .expect("prepare accepted generation postprocessing resume") + ); + assert_eq!(pending.status, AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED); + assert!(pending.observation.is_none()); + } + + #[test] + fn canvas_reconciliation_keeps_the_context_plan_step_active() { + 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"); + let mut runtime = start_game_creator_agent_runtime_task_at( + root, + "art-asset-plan", + "继续俄罗斯方块素材任务", + "reconciliation-context-run", + "agent-ready-task-scheduler", + "等待图集生成", + vec!["继续同一素材任务".to_string()], + ) + .expect("start reconciliation context runtime"); + persist_game_creator_agent_runtime_context( + root, + &runtime, + &runtime.current_task, + &AgentRuntimeToolPlan::default(), + &[], + 0, + &AgentRuntimeContextWindowTracker::default(), + ) + .expect("persist active context bundle"); + let action = AgentRuntimeToolAction { + tool: "canvas.asset_generate".to_string(), + reason: Some("生成同一图集".to_string()), + input: serde_json::json!({ + "prompt": "继续俄罗斯方块素材任务", + "outputPath": "assets/art-spritesheet.png" + }), + }; + let plan = AgentRuntimeToolPlan { + thinking_summary: "等待生成结果".to_string(), + plan_update: None, + plan: vec!["继续同一素材任务".to_string()], + actions: vec![action.clone()], + response: String::new(), + }; + let revision = + read_game_creator_agent_runtime_project_revision(root).expect("read project revision"); + let repository_fingerprint = build_repository_startup_context_at(root) + .expect("read repository context") + .fingerprint; + let pending = build_game_creator_agent_runtime_pending_tool_action( + root, + &runtime, + &runtime.current_task, + &plan, + &[], + &revision, + &repository_fingerprint, + &action, + 0, + AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO, + AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_APPROVED, + None, + ) + .expect("build reconciliation pending action"); + let active_index = runtime.active_plan_step_index; + let observation = AgentRuntimeToolObservation { + tool: "canvas.asset_generate".to_string(), + status: AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION.to_string(), + summary: "platform-generation-result-unknown: 首次 POST 响应丢失".to_string(), + detail: None, + }; + + mark_game_creator_agent_runtime_tool_observation_needs_reconciliation_at( + root, + &mut runtime, + &pending, + &observation, + ) + .expect("persist reconciliation without failing the active plan step"); + assert_eq!(runtime.active_plan_step_index, active_index); + assert!(runtime + .plan_steps + .iter() + .any(|step| step.status == "active")); + read_game_creator_agent_runtime_context_bundle(root, &runtime) + .expect("reconciliation must preserve context bundle plan identity") + .expect("active context bundle must remain available"); + } +} 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_tools/media.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs index 49a670dd5..b38f08aa2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs @@ -722,7 +722,7 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio Some(pending) => match platform_art_generation_runtime_recovery_at(root, pending) { Ok(PlatformArtGenerationRuntimeRecovery::Missing) => false, Ok( - PlatformArtGenerationRuntimeRecovery::PreparedResultUnknown + PlatformArtGenerationRuntimeRecovery::ResumePrepared | PlatformArtGenerationRuntimeRecovery::ResumeAccepted | PlatformArtGenerationRuntimeRecovery::ResumeLegacyCompleted, ) => true, diff --git a/apps/ai-game-creator-shell/src-tauri/src/assets.rs b/apps/ai-game-creator-shell/src-tauri/src/assets.rs index a7ea6d6b9..210a325f0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/assets.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/assets.rs @@ -505,6 +505,36 @@ fn external_asset_host_is_private(host: &str) -> bool { } } +fn external_asset_ip_is_proxy_benchmark(address: std::net::IpAddr) -> bool { + match address { + std::net::IpAddr::V4(address) => { + let [first, second, ..] = address.octets(); + first == 198 && (18..=19).contains(&second) + } + std::net::IpAddr::V6(_) => false, + } +} + +fn external_asset_resolved_addresses_are_safe( + addresses: &[std::net::SocketAddr], + came_from_stable_reference: bool, +) -> bool { + if addresses + .iter() + .all(|address| !external_asset_host_is_private(&address.ip().to_string())) + { + return true; + } + // Clash 等透明代理会把公网域名映射到 RFC 2544 的 198.18.0.0/15 fake-IP。 + // 只有经已鉴权 objectKey/legacy path 换签得到的 URL 可以使用这项窄例外; + // 用户或上游直接提供的 URL、其它私网地址及公私混合解析仍然失败关闭。 + came_from_stable_reference + && !addresses.is_empty() + && addresses + .iter() + .all(|address| external_asset_ip_is_proxy_benchmark(address.ip())) +} + fn validate_external_asset_download_url( value: &str, api_base_url: &str, @@ -531,6 +561,7 @@ fn validate_external_asset_download_url( async fn build_external_asset_download_client( url: &url::Url, api_base_url: &str, + came_from_stable_reference: bool, ) -> Result { let mut builder = reqwest::Client::builder() .connect_timeout(Duration::from_secs(10)) @@ -561,10 +592,7 @@ async fn build_external_asset_download_client( if addresses.is_empty() { return Err("画板资产下载域名没有可用地址".to_string()); } - if addresses - .iter() - .any(|address| external_asset_host_is_private(&address.ip().to_string())) - { + if !external_asset_resolved_addresses_are_safe(&addresses, came_from_stable_reference) { return Err("画板资产下载域名解析到本机或私有网络,已拒绝请求".to_string()); } builder = builder.resolve_to_addrs(host, &addresses); @@ -626,7 +654,9 @@ pub(crate) async fn resolve_canvas_resource_download_with_limit( return Ok(None); }; let url = validate_external_asset_download_url(&url, api_base_url, came_from_stable_reference)?; - let download_client = build_external_asset_download_client(&url, api_base_url).await?; + let download_client = + build_external_asset_download_client(&url, api_base_url, came_from_stable_reference) + .await?; let mut response = download_client .get(url) .send() @@ -1180,6 +1210,31 @@ mod tests { .expect("public HTTPS asset is allowed"); } + #[test] + fn canvas_download_only_allows_proxy_fake_ips_for_stable_resigned_references() { + let fake_ip = ["198.18.0.73:443".parse().expect("parse proxy fake IP")]; + assert!(external_asset_resolved_addresses_are_safe(&fake_ip, true)); + assert!(!external_asset_resolved_addresses_are_safe(&fake_ip, false)); + + for address in [ + "127.0.0.1:443", + "10.0.0.1:443", + "169.254.169.254:80", + "[::1]:443", + ] { + let addresses = [address.parse().expect("parse private address")]; + assert!(!external_asset_resolved_addresses_are_safe( + &addresses, true + )); + } + + let mixed = [ + "198.18.0.73:443".parse().expect("parse proxy fake IP"), + "203.0.113.10:443".parse().expect("parse public fixture IP"), + ]; + assert!(!external_asset_resolved_addresses_are_safe(&mixed, true)); + } + #[tokio::test] async fn canvas_download_rejects_redirects_before_following_private_targets() { let listener = 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 90c183719..f3fca202e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -629,9 +629,30 @@ struct GameCreatorAgentRuntimeUpdateEvent { run_id: String, status: String, phase: String, + manifest_invalidated: bool, runtime: AgentRuntimeResult, } +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct GameCreatorManifestInvalidatedEvent { + project_path: String, + agent_id: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct GameCreatorManifestInvalidationRelayEnvelope { + token: String, + event: GameCreatorManifestInvalidatedEvent, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct GameCreatorManifestInvalidationEventSink { + port: u16, + token: String, +} + #[derive(Clone, Debug, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] struct GameCreatorAgentProgressEvent { @@ -2092,7 +2113,10 @@ fn main() { format!("启动 Agent Runner 失败:{error}"), ) })?; - attach_external_agent_runner_gui_owner() + set_game_creator_agent_runtime_update_app_handle(app.handle().clone()); + let manifest_event_sink = + start_game_creator_manifest_invalidation_event_sink(app.handle().clone())?; + attach_external_agent_runner_gui_owner(&manifest_event_sink) .inspect_err(|error| { if let Some(path) = setup_log.as_deref() { let details = @@ -2113,7 +2137,6 @@ fn main() { if let Some(path) = setup_log.as_deref() { let _ = append_bounded_diagnostic_line(path, "startup.runner.start.complete"); } - set_game_creator_agent_runtime_update_app_handle(app.handle().clone()); #[cfg(all(debug_assertions, not(test)))] if game_chat_launch.is_none() { open_developer_window(app.handle())?; diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs b/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs index b34c59124..05528b20f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs @@ -2112,11 +2112,37 @@ fn validate_agent_db_action_receipt_input( record_type: &str, record: &serde_json::Value, ) -> Result<(), String> { - if record_type != AGENT_DB_ACTION_RECEIPT_RECORD_TYPE + if record_type != AGENT_DB_ACTION_RECEIPT_RECORD_TYPE { + return Err("Agent DB 幂等动作入口只接受 terminal action receipt".to_string()); + } + if record.get("schemaVersion").is_some() || record.get("updatedAt").is_some() { + return Err("Agent 持久动作回执不能预填持久化 envelope".to_string()); + } + validate_agent_db_action_receipt_schema(record) +} + +fn validate_agent_db_action_receipt_schema(record: &serde_json::Value) -> Result<(), String> { + const FIELDS: &[&str] = &[ + "recordType", + "agentId", + "taskId", + "sessionId", + "runId", + "actionId", + "actionFingerprint", + "tool", + "executionMode", + "status", + "inputSummary", + "summary", + "safeDetail", + "detailUnavailable", + ]; + if !agent_db_record_has_exact_payload_fields(record, FIELDS) || record.get("recordType").and_then(serde_json::Value::as_str) != Some(AGENT_DB_ACTION_RECEIPT_RECORD_TYPE) { - return Err("Agent DB 幂等动作入口只接受 terminal action receipt".to_string()); + return Err("Agent 持久动作回执字段集合或 recordType 无效".to_string()); } for field in [ "agentId", @@ -2540,6 +2566,7 @@ fn validate_agent_db_action_records_unlocked( let mut reader = BufReader::new(file); let mut record_count = 0usize; let mut found = false; + let mut canvas_generation_reconciliation_predecessors = 0usize; while let Some(line) = read_agent_db_jsonl_line_bounded(&mut reader, path)? { if !line.complete { break; @@ -2570,8 +2597,24 @@ fn validate_agent_db_action_records_unlocked( { continue; } - validate_agent_db_action_record_identity(&record, expected)?; - found = true; + match validate_agent_db_action_record_identity(&record, expected) { + Ok(()) => found = true, + Err(_) + if agent_db_canvas_generation_reconciliation_precedes_final( + &record, expected, + ) => + { + validate_agent_db_action_receipt_schema(&record)?; + canvas_generation_reconciliation_predecessors = + canvas_generation_reconciliation_predecessors.saturating_add(1); + if canvas_generation_reconciliation_predecessors > 1 { + return Err(format!( + "Agent 持久动作回执存在多个 canvas.asset_generate 对账前序:actionId={action_id}" + )); + } + } + Err(error) => return Err(error), + } } } if !found && record_count >= AGENT_DB_MAX_SCAN_RECORDS { @@ -3043,6 +3086,40 @@ fn validate_agent_db_action_record_identity( Ok(()) } +fn agent_db_canvas_generation_reconciliation_precedes_final( + existing: &serde_json::Value, + expected: &serde_json::Value, +) -> bool { + existing.get("recordType") + == Some(&serde_json::Value::String( + AGENT_DB_ACTION_RECEIPT_RECORD_TYPE.to_string(), + )) + && existing.get("tool").and_then(serde_json::Value::as_str) == Some("canvas.asset_generate") + && expected.get("tool").and_then(serde_json::Value::as_str) == Some("canvas.asset_generate") + && existing.get("status").and_then(serde_json::Value::as_str) + == Some("needs-reconciliation") + && expected + .get("status") + .and_then(serde_json::Value::as_str) + .is_some_and(|status| { + status != "needs-reconciliation" && is_terminal_agent_db_action_status(status) + }) + && [ + "recordType", + "agentId", + "taskId", + "sessionId", + "runId", + "actionId", + "actionFingerprint", + "tool", + "executionMode", + "inputSummary", + ] + .iter() + .all(|field| existing.get(*field) == expected.get(*field)) +} + static PROJECT_APPEND_LOCKS: OnceLock>>>> = OnceLock::new(); fn project_append_locks() -> &'static Mutex>>> { PROJECT_APPEND_LOCKS.get_or_init(|| Mutex::new(BTreeMap::new())) diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/agent_db/security_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project/agent_db/security_tests.rs index 46aa7822f..20d51c937 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/agent_db/security_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/agent_db/security_tests.rs @@ -1221,6 +1221,142 @@ fn action_append_locked_full_scan_rejects_a_conflicting_second_record() { fs::remove_dir_all(root).ok(); } +#[test] +fn canvas_generation_receipt_converges_once_from_reconciliation_to_final() { + let root = unique_agent_db_test_root("canvas-generation-reconciliation-final"); + let mut reconciliation = action_record("External Editor 提交结果未知"); + reconciliation["tool"] = serde_json::Value::String("canvas.asset_generate".to_string()); + reconciliation["status"] = serde_json::Value::String("needs-reconciliation".to_string()); + append_agent_db_record_if_missing_for_action( + &root, + AGENT_DB_ACTION_RECEIPT_RECORD_TYPE, + "implementation-engineer", + "run-1", + TEST_ACTION_ID, + reconciliation, + ) + .expect("append canvas reconciliation receipt"); + + let mut completed = action_record("同一幂等生成已完成"); + completed["tool"] = serde_json::Value::String("canvas.asset_generate".to_string()); + assert!(append_agent_db_record_if_missing_for_action( + &root, + AGENT_DB_ACTION_RECEIPT_RECORD_TYPE, + "implementation-engineer", + "run-1", + TEST_ACTION_ID, + completed.clone(), + ) + .expect("append final canvas receipt after reconciliation")); + assert!(!append_agent_db_record_if_missing_for_action( + &root, + AGENT_DB_ACTION_RECEIPT_RECORD_TYPE, + "implementation-engineer", + "run-1", + TEST_ACTION_ID, + completed.clone(), + ) + .expect("retry exact final canvas receipt")); + + let mut conflicting_final = completed; + conflicting_final["status"] = serde_json::Value::String("failed".to_string()); + conflicting_final["summary"] = serde_json::Value::String("冲突终态".to_string()); + let error = append_agent_db_record_if_missing_for_action( + &root, + AGENT_DB_ACTION_RECEIPT_RECORD_TYPE, + "implementation-engineer", + "run-1", + TEST_ACTION_ID, + conflicting_final, + ) + .expect_err("a second distinct final canvas receipt must fail closed"); + assert!(error.contains("field=status"), "{error}"); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn canvas_generation_receipt_rejects_a_damaged_reconciliation_predecessor() { + let root = unique_agent_db_test_root("canvas-generation-damaged-reconciliation"); + let mut reconciliation = action_record("损坏的 External Editor 对账前序"); + reconciliation["tool"] = serde_json::Value::String("canvas.asset_generate".to_string()); + reconciliation["status"] = serde_json::Value::String("needs-reconciliation".to_string()); + reconciliation["detailUnavailable"] = serde_json::Value::String("invalid".to_string()); + append_agent_db_record_internal(&root, reconciliation) + .expect("append damaged reconciliation fixture"); + + let mut completed = action_record("同一幂等生成已完成"); + completed["tool"] = serde_json::Value::String("canvas.asset_generate".to_string()); + let error = append_agent_db_record_if_missing_for_action( + &root, + AGENT_DB_ACTION_RECEIPT_RECORD_TYPE, + "implementation-engineer", + "run-1", + TEST_ACTION_ID, + completed, + ) + .expect_err("damaged reconciliation predecessor must fail closed"); + assert!(error.contains("安全详情字段"), "{error}"); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn canvas_generation_receipt_rejects_reconciliation_with_extra_fields() { + let root = unique_agent_db_test_root("canvas-generation-extra-reconciliation-field"); + let mut reconciliation = action_record("带异常字段的 External Editor 对账前序"); + reconciliation["tool"] = serde_json::Value::String("canvas.asset_generate".to_string()); + reconciliation["status"] = serde_json::Value::String("needs-reconciliation".to_string()); + reconciliation["unexpectedPayload"] = + serde_json::Value::String("must-not-be-accepted".to_string()); + append_agent_db_record_internal(&root, reconciliation) + .expect("append reconciliation fixture with extra field"); + + let mut completed = action_record("同一幂等生成已完成"); + completed["tool"] = serde_json::Value::String("canvas.asset_generate".to_string()); + let error = append_agent_db_record_if_missing_for_action( + &root, + AGENT_DB_ACTION_RECEIPT_RECORD_TYPE, + "implementation-engineer", + "run-1", + TEST_ACTION_ID, + completed, + ) + .expect_err("extra reconciliation fields must fail closed"); + assert!(error.contains("字段集合"), "{error}"); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn ordinary_receipt_cannot_transition_out_of_reconciliation() { + let root = unique_agent_db_test_root("ordinary-reconciliation-final-rejected"); + let mut reconciliation = action_record("普通工具结果未知"); + reconciliation["status"] = serde_json::Value::String("needs-reconciliation".to_string()); + append_agent_db_record_if_missing_for_action( + &root, + AGENT_DB_ACTION_RECEIPT_RECORD_TYPE, + "implementation-engineer", + "run-1", + TEST_ACTION_ID, + reconciliation, + ) + .expect("append ordinary reconciliation receipt"); + + let error = append_agent_db_record_if_missing_for_action( + &root, + AGENT_DB_ACTION_RECEIPT_RECORD_TYPE, + "implementation-engineer", + "run-1", + TEST_ACTION_ID, + action_record("普通工具不得改写终态"), + ) + .expect_err("ordinary receipt transition must remain forbidden"); + assert!(error.contains("field=status"), "{error}"); + + fs::remove_dir_all(root).ok(); +} + #[test] fn terminal_observation_append_ignores_non_terminal_stage_and_retries_idempotently() { let root = unique_agent_db_test_root("terminal-observation-transition"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs index 69c7da1c9..03ac23701 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs @@ -1,5 +1,8 @@ use super::{dispatch::*, endpoint::*, project_owner::*, protocol::*, state::*}; -use crate::{AgentRuntimeContextCompactionResult, GameCreatorMcpCatalog}; +use crate::{ + AgentRuntimeContextCompactionResult, GameCreatorManifestInvalidationEventSink, + GameCreatorMcpCatalog, +}; use serde_json::Value; use sha2::{Digest as _, Sha256}; use std::ffi::OsString; @@ -911,7 +914,9 @@ pub(crate) fn shutdown_external_agent_runner() -> Result<(), String> { shutdown_external_agent_runner_at(&config_dir) } -pub(crate) fn attach_external_agent_runner_gui_owner() -> Result<(), String> { +pub(crate) fn attach_external_agent_runner_gui_owner( + event_sink: &GameCreatorManifestInvalidationEventSink, +) -> Result<(), String> { EXTERNAL_AGENT_RUNNER_GUI_OWNER_REQUIRED_CLIENT .store(true, std::sync::atomic::Ordering::Release); let config_dir = external_agent_runner_config_dir() @@ -920,12 +925,18 @@ pub(crate) fn attach_external_agent_runner_gui_owner() -> Result<(), String> { let result = send_external_agent_runner_request( &endpoint, "runner.attach_gui_owner", - ExternalAgentRunnerRequestParams::default(), + ExternalAgentRunnerRequestParams { + event_sink_port: Some(event_sink.port), + event_sink_token: Some(event_sink.token.clone()), + ..ExternalAgentRunnerRequestParams::default() + }, )?; - if result.get("attached").and_then(Value::as_bool) == Some(true) { + if result.get("attached").and_then(Value::as_bool) == Some(true) + && result.get("eventSinkAttached").and_then(Value::as_bool) == Some(true) + { Ok(()) } else { - Err("Agent Runner attach_gui_owner 响应未确认 owner".to_string()) + Err("Agent Runner attach_gui_owner 响应未确认 owner 与事件接收端".to_string()) } } @@ -1181,6 +1192,8 @@ pub(super) fn send_external_agent_runner_runtime_request_with_stable_identity( run_id: run_id.map(str::to_string), action_id: action_id.map(str::to_string), steer_id: steer_id.map(str::to_string), + event_sink_port: None, + event_sink_token: None, }; match stable_identity { Some(stable_identity) => { diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs index 5bc8667ef..aa17ffefe 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs @@ -1,4 +1,5 @@ use super::{endpoint::*, project_owner::*, protocol::*, state::*}; +use crate::configure_game_creator_manifest_invalidation_event_sink; use serde::Deserialize; use serde_json::json; use sha2::{Digest as _, Sha256}; @@ -587,10 +588,27 @@ pub(super) fn dispatch_external_agent_runner_runtime_request( "runner.attach_gui_owner" => { match external_agent_runner_gui_owner_is_locked(&state.gui_owner_lock_path) { Ok(true) => { + let event_sink = request + .params + .event_sink_port + .zip(request.params.event_sink_token.as_deref()) + .ok_or_else(|| { + "Agent Runner GUI owner 缺少 manifest 事件接收端".to_string() + }) + .and_then(|(port, token)| { + configure_game_creator_manifest_invalidation_event_sink(port, token) + }); + if let Err(error) = event_sink { + return ExternalAgentRunnerResponse::failure( + &request.request_id, + "event-sink-invalid", + redact_runner_secret(&error, &token), + ); + } state.gui_owner_attached.store(true, Ordering::Release); ExternalAgentRunnerResponse::success( &request.request_id, - json!({ "attached": true }), + json!({ "attached": true, "eventSinkAttached": true }), ) } Ok(false) => ExternalAgentRunnerResponse::failure( diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/protocol.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/protocol.rs index 3eda73886..451309200 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/protocol.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/protocol.rs @@ -9,7 +9,7 @@ use std::sync::atomic::{AtomicBool, AtomicU64}; use std::sync::{Mutex, OnceLock}; use std::time::Duration; -pub(crate) const EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION: u32 = 4; +pub(crate) const EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION: u32 = 5; pub(super) const EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME: &str = "agent-runner.endpoint.json"; pub(super) const EXTERNAL_AGENT_RUNNER_LOCK_FILE_NAME: &str = "agent-runner.lock"; @@ -264,6 +264,10 @@ pub(super) struct ExternalAgentRunnerRequestParams { pub(super) action_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub(super) steer_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) event_sink_port: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) event_sink_token: Option, } #[derive(Deserialize, Serialize)] diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs index 98edcf5f0..4b92b1e10 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs @@ -84,7 +84,7 @@ fn context_compaction_client_uses_long_response_timeout_without_widening_other_m assert!( external_agent_runner_client_read_timeout("mcp.status") > EXTERNAL_AGENT_RUNNER_IO_TIMEOUT ); - assert_eq!(EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, 4); + assert_eq!(EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, 5); } fn test_endpoint(token: &str, boot_id: &str, port: u16) -> ExternalAgentRunnerEndpoint { @@ -583,7 +583,11 @@ fn attached_gui_owner_loss_forces_runner_shutdown() { request_id: "gui-owner-attach-1".to_string(), token: token.to_string(), method: "runner.attach_gui_owner".to_string(), - params: ExternalAgentRunnerRequestParams::default(), + params: ExternalAgentRunnerRequestParams { + event_sink_port: Some(31_318), + event_sink_token: Some("b".repeat(64)), + ..ExternalAgentRunnerRequestParams::default() + }, }, &state, ); @@ -599,6 +603,7 @@ fn attached_gui_owner_loss_forces_runner_shutdown() { assert!(state.draining.load(Ordering::Acquire)); assert!(state.force_shutdown_requested.load(Ordering::Acquire)); assert!(state.shutdown_requested.load(Ordering::Acquire)); + crate::clear_game_creator_manifest_invalidation_event_sink_for_test(); } #[test] diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs index aaa35066c..726e055e2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs @@ -13,6 +13,49 @@ static TEST_PROJECT_COUNTER: AtomicU64 = AtomicU64::new(0); static TEST_MOCK_PORT_COUNTER: AtomicU64 = AtomicU64::new(20_000); static TEST_CONFIG_LOCK: StdMutex<()> = StdMutex::new(()); +#[test] +fn non_supervisor_runtime_update_invalidates_manifest_on_the_wire_and_runner_relay() { + let root = unique_project_path(); + init_local_game_project_at(&root, "runtime-event-contract", "Runtime 事件合同测试") + .expect("init runtime event contract project"); + let runtime = read_game_creator_agent_runtime_at(&root, "art-asset-plan") + .expect("read non-Supervisor runtime"); + let event = game_creator_agent_runtime_update_event(&root, runtime); + let serialized = serde_json::to_value(event).expect("serialize runtime update event"); + + assert_eq!(serialized["agentId"], "art-asset-plan"); + assert_eq!(serialized["manifestInvalidated"], true); + + let relay_listener = TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)) + .expect("bind manifest invalidation relay fixture"); + let relay_port = relay_listener + .local_addr() + .expect("read manifest invalidation relay fixture address") + .port(); + let relay_token = "a".repeat(64); + configure_game_creator_manifest_invalidation_event_sink(relay_port, &relay_token) + .expect("configure manifest invalidation relay fixture"); + emit_game_creator_agent_runtime_update(&root, "art-asset-plan"); + let (mut relay_stream, _) = relay_listener + .accept() + .expect("accept manifest invalidation relay"); + relay_stream + .set_read_timeout(Some(Duration::from_secs(1))) + .expect("set manifest invalidation relay read timeout"); + let mut relay_payload = Vec::new(); + relay_stream + .read_to_end(&mut relay_payload) + .expect("read manifest invalidation relay"); + clear_game_creator_manifest_invalidation_event_sink_for_test(); + let relay: GameCreatorManifestInvalidationRelayEnvelope = + serde_json::from_slice(&relay_payload).expect("parse manifest invalidation relay"); + assert_eq!(relay.token, relay_token); + assert_eq!(relay.event.project_path, root.to_string_lossy()); + assert_eq!(relay.event.agent_id, "art-asset-plan"); + + fs::remove_dir_all(root).ok(); +} + #[test] fn gui_final_exit_is_the_only_run_event_that_requests_runner_shutdown() { assert!(game_creator_gui_run_event_requests_runner_shutdown( diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 006cdef0d..f2028c1dc 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, @@ -241,6 +242,7 @@ import { type ProjectAgentResultSummary, type ProjectAgentRuntimeSummary, } from './view/project-development'; +import type { ProjectManifestSnapshotMetadata } from './view/project-development/projectResourceLiveUpdateModel'; const initialSupervisorMessageClaimsByPage = new WeakMap>(); const LEGACY_GAME_CHAT_AUTO_PREVIEW_AUTHORIZATION_STORAGE_KEY = @@ -569,6 +571,7 @@ type AppProps = { onManifestChange?: ( projectPath: string, manifest: GameCreationAppManifest, + metadata?: ProjectManifestSnapshotMetadata, ) => void; onPreviewChange?: (preview: GameCreationAppPreviewState | null) => void; onAgentRuntimeSummariesChange?: ( @@ -612,6 +615,16 @@ export function App({ ); const localProjectPathRef = useRef(null); localProjectPathRef.current = localProject?.projectPath ?? null; + const manifestRefreshMountedRef = useRef(true); + const manifestRefreshStatesRef = useRef( + new Map< + string, + { + pending: boolean; + inFlight: Promise | null; + } + >(), + ); const [manifest, setManifest] = useState( initialProjectManifest ?? seedManifest, ); @@ -818,6 +831,75 @@ export function App({ const projectSupervisorResponseStreamRef = useRef(null); projectSupervisorResponseStreamRef.current = projectSupervisorResponseStream; + + const refreshManifest = useCallback( + (nextProjectPath = localProjectPathRef.current ?? ''): Promise => { + const invoke = resolveTauriInvoke(); + if (!invoke || !nextProjectPath) { + return Promise.resolve(); + } + + const refreshStates = manifestRefreshStatesRef.current; + let refreshState = refreshStates.get(nextProjectPath); + if (!refreshState) { + refreshState = { pending: false, inFlight: null }; + refreshStates.set(nextProjectPath, refreshState); + } + refreshState.pending = true; + if (refreshState.inFlight) { + return refreshState.inFlight; + } + + const activeRefreshState = refreshState; + const refreshPromise = (async () => { + try { + while (activeRefreshState.pending) { + activeRefreshState.pending = false; + const projectScopeVersion = projectScopeVersionRef.current; + try { + const nextManifest = await invoke( + 'get_local_game_manifest', + { projectPath: nextProjectPath }, + ); + if ( + manifestRefreshMountedRef.current && + localProjectPathRef.current === nextProjectPath && + projectScopeVersionRef.current === projectScopeVersion + ) { + setManifest(nextManifest); + } + } catch { + // Dev-only convenience; command errors are surfaced by the action that triggered them. + } + if ( + !manifestRefreshMountedRef.current || + localProjectPathRef.current !== nextProjectPath || + projectScopeVersionRef.current !== projectScopeVersion + ) { + activeRefreshState.pending = false; + } + } + } finally { + activeRefreshState.inFlight = null; + if (!activeRefreshState.pending) { + refreshStates.delete(nextProjectPath); + } + } + })(); + activeRefreshState.inFlight = refreshPromise; + return refreshPromise; + }, + [], + ); + + useEffect(() => { + const refreshStates = manifestRefreshStatesRef.current; + manifestRefreshMountedRef.current = true; + return () => { + manifestRefreshMountedRef.current = false; + refreshStates.clear(); + }; + }, []); const projectSupervisorRuntimeSyncingRef = useRef(new Set()); const projectSupervisorRefreshConversationRef = useRef< | (( @@ -1306,6 +1388,9 @@ export function App({ if (payload.projectPath !== localProjectPathRef.current) { return; } + if (payload.manifestInvalidated) { + void refreshManifest(payload.projectPath); + } const nextRuntime = agentRuntimeStateFromResult(payload.runtime); if (gameChatOnly && payload.agentId !== PROJECT_SUPERVISOR_AGENT_ID) { appendGameChatFinalReplyMessages(payload.projectPath, [ @@ -1395,10 +1480,43 @@ export function App({ }, [ appendGameChatFinalReplyMessages, gameChatOnly, + refreshManifest, updateProjectSupervisorResponseStream, updateProjectSupervisorRuntime, ]); + useEffect(() => { + const listen = window.__TAURI__?.event?.listen; + if (!listen) { + return; + } + let cleanup: (() => void) | null = null; + let disposed = false; + void listen( + 'game-creator-manifest-invalidated', + (event) => { + if (event.payload.projectPath !== localProjectPathRef.current) { + return; + } + void refreshManifest(event.payload.projectPath); + }, + ) + .then((unlisten) => { + if (disposed) { + unlisten(); + return; + } + cleanup = unlisten; + }) + .catch(() => { + // In-process Runtime events continue to carry the same invalidation signal. + }); + return () => { + disposed = true; + cleanup?.(); + }; + }, [refreshManifest]); + useEffect(() => { const invoke = resolveTauriInvoke(); const nextProjectPath = localProject?.projectPath ?? null; @@ -5633,6 +5751,7 @@ export function App({ PROJECT_SUPERVISOR_AGENT_ID, sessionId, submissionRunProfile, + 'project-supervisor-game-chat', ) : null; let autoPreviewAfterRevision = 0; @@ -5777,6 +5896,7 @@ export function App({ role: 'assistant', text: message, runtimeOwned: true, + updatedAt: Date.now(), }, ]); } finally { @@ -5804,7 +5924,12 @@ export function App({ supervisorChatShouldFollowLatestRef.current = true; setMessages((current) => [ ...current, - { role: 'user', text: latch.prompt, runtimeOwned: true }, + { + role: 'user', + text: latch.prompt, + runtimeOwned: true, + updatedAt: Date.now(), + }, ]); void executeChatAgentReplyRef.current(latch.prompt); }, [chatAgentBusy, gameChatOnly, initialSupervisorMessage, localProject]); @@ -9982,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) ?? '', @@ -10445,7 +10551,41 @@ export function App({ if (!projectSupervisorOnly || !nextProjectPath || !onManifestChange) { return; } - onManifestChange(nextProjectPath, manifest); + const invoke = resolveTauriInvoke(); + if (!invoke) { + return; + } + let cancelled = false; + void (async () => { + for (let attempt = 0; attempt < 2; attempt += 1) { + const before = await invoke( + 'get_local_game_project_revision', + { projectPath: nextProjectPath }, + ); + const currentManifest = await invoke( + 'get_local_game_manifest', + { projectPath: nextProjectPath }, + ); + const after = await invoke( + 'get_local_game_project_revision', + { projectPath: nextProjectPath }, + ); + if (before.revision !== after.revision) { + continue; + } + if (!cancelled) { + onManifestChange(nextProjectPath, currentManifest, { + projectId: currentManifest.projectId, + revision: after.revision, + source: 'supervisor', + }); + } + return; + } + })().catch(() => undefined); + return () => { + cancelled = true; + }; }, [ localProject?.projectPath, manifest, @@ -10769,7 +10909,12 @@ export function App({ setChatInput(''); setMessages((current) => [ ...current, - { role: 'user', text: prompt, runtimeOwned: true }, + { + role: 'user', + text: prompt, + runtimeOwned: true, + updatedAt: Date.now(), + }, ]); void executeChatAgentReply(prompt); } diff --git a/apps/ai-game-creator-shell/src/app/types.ts b/apps/ai-game-creator-shell/src/app/types.ts index 8cec58340..a950826e9 100644 --- a/apps/ai-game-creator-shell/src/app/types.ts +++ b/apps/ai-game-creator-shell/src/app/types.ts @@ -54,6 +54,7 @@ export type LauncherProjectContext = { projectPath: string; projectName: string; manifest: GameCreationAppManifest; + projectRevision: number | null; mode: HomeAgentMode | null; initialPrompt: string; attachments: LauncherImportedAttachment[]; @@ -426,9 +427,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 7cd31249c..0802fb624 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,10 +1,19 @@ -import { Fragment, useCallback, useState } from 'react'; +import { Fragment, useCallback, useEffect, useRef, useState } from 'react'; import { launcherNotifications } from '../../app/constants'; import { closeDialogOnEscape } from '../../app/dialogs'; +import { resolveTauriInvoke } from '../../app/tauri'; +import type { LocalGameProjectRevisionStatus } from '../../app/types'; import HomeView from '../../view/home'; import { type LauncherView, Sidebar } from '../../view/layout'; import ProjectDevelopmentView from '../../view/project-development'; +import { + createProjectManifestMergeState, + mergeProjectManifestSnapshot, + type ProjectManifestMergeState, + type ProjectManifestSnapshot, + type ProjectManifestSnapshotMetadata, +} from '../../view/project-development/projectResourceLiveUpdateModel'; import { RuntimeConfigDialog } from '../runtime-config/RuntimeConfigDialog'; import { AccountWalletBar, AccountWalletDialogs } from './AccountWallet'; import { @@ -56,23 +65,127 @@ export function WorkspaceLauncherShell({ createHomeDraft, openProject, } = homeProject; + const activeProjectContextRef = useRef(currentProjectContext); + const manifestMergeRef = useRef(null); + activeProjectContextRef.current = currentProjectContext; + + useEffect(() => { + const current = activeProjectContextRef.current; + manifestMergeRef.current = + current?.projectRevision === null || !current + ? null + : createProjectManifestMergeState({ + projectPath: current.projectPath, + projectId: current.manifest.projectId, + revision: current.projectRevision, + manifest: current.manifest, + source: 'initial', + }); + }, [currentProjectContext?.createdAt, currentProjectContext?.projectPath]); + + const applyManifestSnapshot = useCallback( + (snapshot: ProjectManifestSnapshot) => { + const current = activeProjectContextRef.current; + if ( + !current || + current.projectPath !== snapshot.projectPath || + current.manifest.projectId !== snapshot.projectId + ) { + return; + } + let previous = manifestMergeRef.current; + if ( + !previous || + previous.projectPath !== current.projectPath || + previous.projectId !== current.manifest.projectId + ) { + previous = + current.projectRevision === null + ? null + : createProjectManifestMergeState({ + projectPath: current.projectPath, + projectId: current.manifest.projectId, + revision: current.projectRevision, + manifest: current.manifest, + source: 'initial', + }); + } + if (!previous) { + manifestMergeRef.current = createProjectManifestMergeState(snapshot); + } else { + const merged = mergeProjectManifestSnapshot(previous, snapshot); + manifestMergeRef.current = merged.state; + if (merged.decision !== 'accepted') { + return; + } + } + setCurrentProjectContext((active) => + active && + active.projectPath === snapshot.projectPath && + active.manifest.projectId === snapshot.projectId + ? { + ...active, + manifest: snapshot.manifest, + projectRevision: snapshot.revision, + } + : active, + ); + }, + [setCurrentProjectContext], + ); const syncActiveProjectManifest = useCallback( ( sourceProjectPath: string, manifest: NonNullable['manifest'], + metadata?: ProjectManifestSnapshotMetadata, ) => { - setCurrentProjectContext((current) => { - if ( - !current || - current.projectPath !== sourceProjectPath || - current.manifest === manifest - ) { - return current; + const current = activeProjectContextRef.current; + if (!current || current.projectPath !== sourceProjectPath) { + return; + } + if (metadata) { + applyManifestSnapshot({ + projectPath: sourceProjectPath, + manifest, + ...metadata, + }); + return; + } + const invoke = resolveTauriInvoke(); + if (!invoke) { + if (current.projectRevision === null) { + setCurrentProjectContext((active) => + active?.projectPath === sourceProjectPath + ? { ...active, manifest } + : active, + ); } - return { ...current, manifest }; - }); + return; + } + void invoke( + 'get_local_game_project_revision', + { projectPath: sourceProjectPath }, + ) + .then((status) => { + applyManifestSnapshot({ + projectPath: sourceProjectPath, + projectId: manifest.projectId, + revision: status.revision, + manifest, + source: 'supervisor', + }); + }) + .catch(() => { + if (activeProjectContextRef.current?.projectRevision === null) { + setCurrentProjectContext((active) => + active?.projectPath === sourceProjectPath + ? { ...active, manifest } + : active, + ); + } + }); }, - [setCurrentProjectContext], + [applyManifestSnapshot, setCurrentProjectContext], ); function showLauncherNotice(title: string) { @@ -174,6 +287,7 @@ export function WorkspaceLauncherShell({ preview={activeProjectPreview} agentRuntimeSummaries={activeProjectAgentRuntimeSummaries} agentResults={activeProjectAgentResults} + onManifestChange={syncActiveProjectManifest} onHomeOpen={() => setLauncherView('home')} onProjectsOpen={() => setLauncherView('projects')} supervisor={ 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 c615e387d..4d05ae21c 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 @@ -17,6 +17,7 @@ import type { ProjectAgentResultSummary, ProjectAgentRuntimeSummary, } from '../../view/project-development'; +import type { ProjectManifestSnapshotMetadata } from '../../view/project-development/projectResourceLiveUpdateModel'; import { isAbsoluteProjectPath } from '../project-summary/projectSummary'; const RECENT_WORKSPACES_STORAGE_KEY = @@ -37,6 +38,7 @@ export type ProjectSupervisorComponentProps = { onManifestChange?: ( projectPath: string, manifest: GameCreationAppManifest, + metadata?: ProjectManifestSnapshotMetadata, ) => void; onPreviewChange?: (preview: GameCreationAppPreviewState | null) => void; onAgentRuntimeSummariesChange?: ( 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 055d266f0..57cc2e6e3 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 @@ -16,6 +16,7 @@ import type { InitLocalProjectResult, LauncherImportedAttachment, LauncherProjectContext, + LocalGameProjectRevisionStatus, LocalProjectDirectoryStatus, PendingNonEmptyProject, TauriInvoke, @@ -96,6 +97,23 @@ export function useHomeProjectCreation({ rememberRecentWorkspace(context.projectPath); } + async function readCurrentProjectRevision( + invoke: TauriInvoke, + nextProjectPath: string, + ) { + try { + const status = await invoke( + 'get_local_game_project_revision', + { projectPath: nextProjectPath }, + ); + return Number.isSafeInteger(status.revision) && status.revision >= 0 + ? status.revision + : null; + } catch { + return null; + } + } + async function importHomeAttachments( invoke: TauriInvoke, nextProjectPath: string, @@ -212,6 +230,10 @@ export function useHomeProjectCreation({ projectName: result.manifest.name || projectNameFromPath(result.projectPath), manifest: result.manifest, + projectRevision: await readCurrentProjectRevision( + invoke, + result.projectPath, + ), mode, initialPrompt: prompt.trim() || @@ -275,6 +297,10 @@ export function useHomeProjectCreation({ projectName: result.manifest.name || projectNameFromPath(result.projectPath), manifest: result.manifest, + projectRevision: await readCurrentProjectRevision( + invoke, + result.projectPath, + ), mode: null, initialPrompt: '', attachments: [], @@ -335,6 +361,10 @@ export function useHomeProjectCreation({ directoryStatus.projectName || projectNameFromPath(trimmedProjectPath), manifest: projectManifest, + projectRevision: await readCurrentProjectRevision( + invoke, + trimmedProjectPath, + ), mode: null, initialPrompt: '', attachments: [], diff --git a/apps/ai-game-creator-shell/src/features/asset-canvas/AssetCanvasSurface.tsx b/apps/ai-game-creator-shell/src/features/asset-canvas/AssetCanvasSurface.tsx index 2235f7c79..8c51dbc0f 100644 --- a/apps/ai-game-creator-shell/src/features/asset-canvas/AssetCanvasSurface.tsx +++ b/apps/ai-game-creator-shell/src/features/asset-canvas/AssetCanvasSurface.tsx @@ -1,3 +1,31 @@ +/* eslint-disable react-refresh/only-export-components -- The surface exports its host callback contracts and deterministic fixture helpers for integration tests. */ + +import './assetCanvasSurface.css'; + +import { + type CanvasLayer, + type CanvasViewport, + createMinimapModel, + fitViewportToLayers, + type ImageCanvasDraft, + type ImageCanvasDraftCanvas, + type ImageCanvasHostScope, + type ImageCanvasMediaRef, + MAX_HISTORY_STEPS, + moveViewportFromMinimapPointer, + moveViewportFromPan, + removeCanvasLayers, + resizeCanvasLayerBounds, + scaleViewportFromScreenPoint, + transformCanvasLayers, +} from '@genarrative/image-canvas-core'; +import { + CanvasViewport as SharedCanvasViewport, + CanvasWorld, + LayerRenderer, + Minimap, + ZoomControls, +} from '@genarrative/image-canvas-react'; import { type ChangeEvent, type PointerEvent as ReactPointerEvent, @@ -8,39 +36,12 @@ import { useState, } from 'react'; -import { - createMinimapModel, - fitViewportToLayers, - MAX_HISTORY_STEPS, - moveViewportFromMinimapPointer, - moveViewportFromPan, - removeCanvasLayers, - resizeCanvasLayerBounds, - scaleViewportFromScreenPoint, - transformCanvasLayers, - type CanvasLayer, - type CanvasViewport, - type ImageCanvasDraft, - type ImageCanvasDraftCanvas, - type ImageCanvasHostScope, - type ImageCanvasMediaRef, -} from '@genarrative/image-canvas-core'; -import { - CanvasViewport as SharedCanvasViewport, - CanvasWorld, - LayerRenderer, - Minimap, - ZoomControls, -} from '@genarrative/image-canvas-react'; - import type { GameCreationAppManifest } from '../../../../../packages/shared/src/contracts/gameCreationApp'; import type { LocalAssetCommittedEvent, TauriImageCanvasHostAdapter, } from './tauriImageCanvasHostAdapter'; -import './assetCanvasSurface.css'; - export type AssetCanvasLifecycleState = | { kind: 'canvas.creating' } | { kind: 'canvas.editing'; dirty: boolean } @@ -56,6 +57,27 @@ export type AssetCanvasLifecycleState = reconciliationRequired: boolean; }; +export type AssetCanvasSaveAttempt = { + saveAttemptId: string; + sessionId: string; + projectId: string; + draftId: string; + commitId: string; +}; + +export type AssetCanvasCommitNotification = { + source: 'command' | 'event'; + projectPath: string; + projectId: string; + draftId: string; + commitId: string; + assetId: string; + manifest: GameCreationAppManifest; + projectRevision: number; + committedProjectRevision: number; + eventId?: string; +}; + type RuntimeCanvasLayer = CanvasLayer & { mediaRef: ImageCanvasMediaRef }; type HistorySnapshot = { layers: RuntimeCanvasLayer[]; @@ -120,11 +142,7 @@ export async function renderAssetCanvasImage({ ); const width = Math.max(1, maxX - minX); const height = Math.max(1, maxY - minY); - if ( - width > 16_384 || - height > 16_384 || - width * height > 268_435_456 - ) { + if (width > 16_384 || height > 16_384 || width * height > 268_435_456) { throw new Error('导出尺寸超过素材画布上限'); } const canvas = document.createElement('canvas'); @@ -219,19 +237,18 @@ export function AssetCanvasSurface({ scope, sessionId, expectedHostRevision, + onCancel, onCommitted, + onSaveAttempt, renderImage = renderAssetCanvasImage, }: { host: TauriImageCanvasHostAdapter; scope: ImageCanvasHostScope; sessionId: string; expectedHostRevision: string; - onCommitted?: (input: { - manifest: GameCreationAppManifest; - hostRevision: string; - resourceId: string; - eventId?: string; - }) => void; + onCancel?: () => void; + onCommitted?: (input: AssetCanvasCommitNotification) => void; + onSaveAttempt?: (input: AssetCanvasSaveAttempt) => void; renderImage?: RenderAssetCanvasImage; }) { const stableScope = useMemo( @@ -359,7 +376,7 @@ export function AssetCanvasSurface({ if (preview.status !== 'ok') { throw new Error( preview.status === 'failed' || - preview.status === 'unsupported-capability' + preview.status === 'unsupported-capability' ? preview.message : '素材媒体读取发生冲突', ); @@ -407,6 +424,7 @@ export function AssetCanvasSurface({ ); useEffect(() => { + const previewUrls = previewUrlsRef.current; const epoch = epochRef.current + 1; epochRef.current = epoch; saveQueueRef.current = Promise.resolve(); @@ -429,9 +447,15 @@ export function AssetCanvasSurface({ } deliveredEventsRef.current.add(event.eventId); onCommitted?.({ + source: 'event', + projectPath: event.projectPath, + projectId: event.projectId, + draftId: event.draftId, + commitId: event.commitId, + assetId: event.asset.id, manifest: event.manifest, - hostRevision: String(event.committedProjectRevision), - resourceId: event.asset.source.resourceId ?? event.asset.id, + projectRevision: event.committedProjectRevision, + committedProjectRevision: event.committedProjectRevision, eventId: event.eventId, }); }); @@ -476,16 +500,16 @@ export function AssetCanvasSurface({ nextDraft = created.value; } else { throw new Error( - loaded.status === 'failed' - ? loaded.message - : '素材画布草稿读取冲突', + loaded.status === 'failed' ? loaded.message : '素材画布草稿读取冲突', ); } await hydrateDraft(nextDraft, epoch); })().catch((error: unknown) => { if ( epoch === epochRef.current && - !(error instanceof Error && error.message === 'stale-asset-canvas-epoch') + !( + error instanceof Error && error.message === 'stale-asset-canvas-epoch' + ) ) { setLifecycle({ kind: 'canvas.failed', @@ -498,8 +522,8 @@ export function AssetCanvasSurface({ return () => { epochRef.current += 1; unlisten?.(); - for (const url of previewUrlsRef.current) URL.revokeObjectURL(url); - previewUrlsRef.current.clear(); + for (const url of previewUrls) URL.revokeObjectURL(url); + previewUrls.clear(); }; }, [ expectedHostRevision, @@ -528,56 +552,55 @@ export function AssetCanvasSurface({ return () => observer.disconnect(); }, [draft]); - const persistDraft = useCallback(async (): Promise => { - const epoch = epochRef.current; - const requestedVersion = documentVersion; - const task = saveQueueRef.current.then(async () => { - const currentDraft = draftRef.current; - if (!currentDraft || epoch !== epochRef.current) return null; - const result = await host.project.updateDraft({ - scope: stableScope, - expectedDraftRevision: currentDraft.revision, - status: 'editing', - canvas: draftCanvasFromRuntime( - layersRef.current, - viewportRef.current, - backgroundRef.current, - selectionRef.current, - ), - generations: currentDraft.generations, + const persistDraft = + useCallback(async (): Promise => { + const epoch = epochRef.current; + const requestedVersion = documentVersion; + const task = saveQueueRef.current.then(async () => { + const currentDraft = draftRef.current; + if (!currentDraft || epoch !== epochRef.current) return null; + const result = await host.project.updateDraft({ + scope: stableScope, + expectedDraftRevision: currentDraft.revision, + status: 'editing', + canvas: draftCanvasFromRuntime( + layersRef.current, + viewportRef.current, + backgroundRef.current, + selectionRef.current, + ), + generations: currentDraft.generations, + }); + if (epoch !== epochRef.current) return null; + if (result.status === 'conflict') { + setLifecycle({ + kind: 'canvas.failed', + code: 'draft-revision-conflict', + message: '草稿已被另一个窗口更新,请重新打开后继续', + reconciliationRequired: false, + }); + return null; + } + if (result.status !== 'ok') { + setLifecycle({ + kind: 'canvas.failed', + code: result.status === 'failed' ? result.code : result.status, + message: + result.status === 'failed' ? result.message : '草稿保存失败', + reconciliationRequired: false, + }); + return null; + } + draftRef.current = result.value; + setDraft(result.value); + if (requestedVersion === documentVersionRef.current) { + setLifecycle({ kind: 'canvas.editing', dirty: false }); + } + return result.value; }); - if (epoch !== epochRef.current) return null; - if (result.status === 'conflict') { - setLifecycle({ - kind: 'canvas.failed', - code: 'draft-revision-conflict', - message: '草稿已被另一个窗口更新,请重新打开后继续', - reconciliationRequired: false, - }); - return null; - } - if (result.status !== 'ok') { - setLifecycle({ - kind: 'canvas.failed', - code: result.status === 'failed' ? result.code : result.status, - message: - result.status === 'failed' - ? result.message - : '草稿保存失败', - reconciliationRequired: false, - }); - return null; - } - draftRef.current = result.value; - setDraft(result.value); - if (requestedVersion === documentVersionRef.current) { - setLifecycle({ kind: 'canvas.editing', dirty: false }); - } - return result.value; - }); - saveQueueRef.current = task.catch(() => undefined); - return await task; - }, [documentVersion, host.project, stableScope]); + saveQueueRef.current = task.catch(() => undefined); + return await task; + }, [documentVersion, host.project, stableScope]); useEffect(() => { if (lifecycle.kind !== 'canvas.editing' || !lifecycle.dirty || !draft) { @@ -593,21 +616,26 @@ export function AssetCanvasSurface({ if (!drag) return; if (drag.kind === 'pan') { setViewport( - moveViewportFromPan({ - startViewport: drag.startViewport, - startClientX: drag.startClientX, - startClientY: drag.startClientY, - kind: 'pan', - pointerId: drag.pointerId, - }, { - x: event.clientX, - y: event.clientY, - }), + moveViewportFromPan( + { + startViewport: drag.startViewport, + startClientX: drag.startClientX, + startClientY: drag.startClientY, + kind: 'pan', + pointerId: drag.pointerId, + }, + { + x: event.clientX, + y: event.clientY, + }, + ), ); return; } - const deltaX = (event.clientX - drag.startClientX) / viewportRef.current.scale; - const deltaY = (event.clientY - drag.startClientY) / viewportRef.current.scale; + const deltaX = + (event.clientX - drag.startClientX) / viewportRef.current.scale; + const deltaY = + (event.clientY - drag.startClientY) / viewportRef.current.scale; if (drag.kind === 'move') { const transforms = new Map( drag.startLayers @@ -618,7 +646,10 @@ export function AssetCanvasSurface({ ]), ); setLayers( - transformCanvasLayers(drag.startLayers, transforms) as RuntimeCanvasLayer[], + transformCanvasLayers( + drag.startLayers, + transforms, + ) as RuntimeCanvasLayer[], ); } else { const layer = drag.startLayers.find((item) => item.id === drag.layerId); @@ -679,7 +710,8 @@ export function AssetCanvasSurface({ }); if (epoch !== epochRef.current) { if (imported.status === 'ok') { - for (const image of imported.value) URL.revokeObjectURL(image.previewUrl); + for (const image of imported.value) + URL.revokeObjectURL(image.previewUrl); } return; } @@ -746,8 +778,12 @@ export function AssetCanvasSurface({ const deleteSelected = useCallback(() => { if (!selectionRef.current.length) return; captureHistory(); - setLayers((current) => - removeCanvasLayers(current, selectionRef.current) as RuntimeCanvasLayer[], + setLayers( + (current) => + removeCanvasLayers( + current, + selectionRef.current, + ) as RuntimeCanvasLayer[], ); setSelectedLayerIds([]); markDirty(); @@ -756,10 +792,29 @@ export function AssetCanvasSurface({ const saveAsset = useCallback(() => { if (savePromiseRef.current) return savePromiseRef.current; const saveEpoch = epochRef.current; + const pending = + pendingCommitRef.current?.documentVersion === documentVersion + ? pendingCommitRef.current + : { + commitId: crypto.randomUUID(), + idempotencyKey: crypto.randomUUID(), + documentVersion, + }; + pendingCommitRef.current = pending; + onSaveAttempt?.({ + saveAttemptId: crypto.randomUUID(), + sessionId, + projectId: stableScope.projectId, + draftId: stableScope.draftId, + commitId: pending.commitId, + }); const task = (async () => { const epoch = saveEpoch; if (!draftRef.current) return; - if (lifecycleRef.current.kind === 'canvas.editing' && lifecycleRef.current.dirty) { + if ( + lifecycleRef.current.kind === 'canvas.editing' && + lifecycleRef.current.dirty + ) { setLifecycle({ kind: 'canvas.saving', stage: 'draft' }); const persisted = await persistDraft(); if (!persisted) return; @@ -773,15 +828,6 @@ export function AssetCanvasSurface({ quality: exportMediaType === 'image/png' ? null : 0.92, }); if (epoch !== epochRef.current) return; - const pending = - pendingCommitRef.current?.documentVersion === documentVersion - ? pendingCommitRef.current - : { - commitId: crypto.randomUUID(), - idempotencyKey: crypto.randomUUID(), - documentVersion, - }; - pendingCommitRef.current = pending; setLifecycle({ kind: 'canvas.saving', stage: 'committing' }); const result = await host.completion.commitImage({ scope: stableScope, @@ -825,15 +871,25 @@ export function AssetCanvasSurface({ setDraft(next); } setLifecycle({ kind: 'canvas.saving', stage: 'projecting' }); - const manifest = result.value.manifest as GameCreationAppManifest | undefined; + const manifest = result.value.manifest as + | GameCreationAppManifest + | undefined; if (manifest) { if (result.value.eventId) { deliveredEventsRef.current.add(result.value.eventId); } onCommitted?.({ + source: 'command', + projectPath: host.projectPath, + projectId: result.value.projectId ?? stableScope.projectId, + draftId: stableScope.draftId, + commitId: result.value.commitId ?? pending.commitId, + assetId: result.value.assetId ?? result.value.resourceId, manifest, - hostRevision: result.value.hostRevision, - resourceId: result.value.resourceId, + projectRevision: Number(result.value.hostRevision), + committedProjectRevision: + result.value.committedProjectRevision ?? + Number(result.value.hostRevision), eventId: result.value.eventId, }); } @@ -863,12 +919,53 @@ export function AssetCanvasSurface({ documentVersion, exportMediaType, host.completion, + host.projectPath, onCommitted, + onSaveAttempt, persistDraft, renderImage, + sessionId, stableScope, ]); + const cancelCanvas = useCallback(() => { + const currentDraft = draftRef.current; + if (!currentDraft || lifecycleRef.current.kind === 'canvas.saving') { + return; + } + const epoch = epochRef.current; + void host.project + .discardDraft({ + scope: stableScope, + expectedDraftRevision: currentDraft.revision, + }) + .then((result) => { + if (epoch !== epochRef.current) return; + if (result.status === 'ok') { + onCancel?.(); + return; + } + setLifecycle({ + kind: 'canvas.failed', + code: result.status === 'failed' ? result.code : result.status, + message: + result.status === 'failed' + ? result.message + : '素材画布取消发生 revision 冲突', + reconciliationRequired: false, + }); + }) + .catch((error: unknown) => { + if (epoch !== epochRef.current) return; + setLifecycle({ + kind: 'canvas.failed', + code: 'canvas-cancel-failed', + message: error instanceof Error ? error.message : String(error), + reconciliationRequired: false, + }); + }); + }, [host.project, onCancel, stableScope]); + const runMockGeneration = useCallback(() => { const currentDraft = draftRef.current; if (!currentDraft) return; @@ -901,14 +998,22 @@ export function AssetCanvasSurface({ [canvasSize, layers, viewport], ); - if (!draft || lifecycle.kind === 'canvas.recovering' || lifecycle.kind === 'canvas.creating') { + if ( + !draft || + lifecycle.kind === 'canvas.recovering' || + lifecycle.kind === 'canvas.creating' + ) { return (
- {lifecycle.kind === 'canvas.recovering' ? '正在恢复画布…' : '正在创建画布…'} + + {lifecycle.kind === 'canvas.recovering' + ? '正在恢复画布…' + : '正在创建画布…'} +
); } @@ -921,6 +1026,13 @@ export function AssetCanvasSurface({ aria-label="素材创作无限画布" >
+
{gameChatMode ? ( -
-
- {status} - {runtimeEvents.length > 4 ? ( - - ) : null} -
- {visibleRuntimeEvents.length > 0 ? ( +
+
+
+
+ + {supervisorProgress?.taskProgress || status} + + + {supervisorProgress?.currentWork || + (projectReady ? '等待新的运行事件' : '请选择项目目录')} + +
+
+ {supervisorProgress?.activeAgents.length ? ( + {`${supervisorProgress.activeAgents.length} 个专业 Agent 活跃`} + ) : null} + {attentionAgentCount > 0 ? ( + {`${attentionAgentCount} 项异常`} + ) : null} + {previewStatus ? {`预览${previewStatus}`} : null} +
+
) : null}
- {projectSupervisorVisibleConversationText( - message.text, - message.role, - )} + + {projectSupervisorVisibleConversationText( + message.text, + message.role, + )} + + {gameChatMode ? ( + + ) : null}

))} - {supervisorProgress ? ( -
-
- {supervisorProgress.title} - {supervisorProgress.taskProgress} -
-

- 当前 - {supervisorProgress.currentWork} -

- {supervisorProgress.activeAgents.length > 0 ? ( -
- 活跃专业 Agent - {supervisorProgress.activeAgents.map((agent) => ( - {agent} - ))} -
- ) : null} - {supervisorProgress.evidence.length > 0 ? ( -
- {supervisorProgress.evidence.map((item) => ( -

- {item.label} - {item.text} -

- ))} -
- ) : null} -
- ) : null} - {resultImagePreviews.length > 0 ? ( -
-
- Supervisor 成果图片 - - {`${resultImagePreviews.filter((image) => image.status === 'loaded').length}/${resultImagePreviews.length}`} - -
-
- {resultImagePreviews.map((image) => ( -
- {image.status === 'loaded' ? ( - - ) : ( -
- {image.status === 'loading' - ? '正在载入图片' - : '图片暂时无法显示'} -
- )} -
- {image.label} - {image.path} -
-
- ))} -
-
- ) : null} {transientReply ? (

) : null} - {running && !transientReply ? ( + {running && !transientReply && !gameChatMode ? (

{ + if (event.target === event.currentTarget) { + setShowRuntimeDetails(false); + } + }} + > +
+
+
+

运行详情

+ {runStateLabel} + +
+ +
+ {supervisorProgress ? ( +
+
+ {supervisorProgress.title} + {supervisorProgress.taskProgress} +
+

+ 当前 + {supervisorProgress.currentWork} +

+ {supervisorProgress.activeAgents.length > 0 ? ( +
+ 活跃专业 Agent + {supervisorProgress.activeAgents.map((agent) => ( + {agent} + ))} +
+ ) : null} + {supervisorProgress.evidence.length > 0 ? ( +
+ {supervisorProgress.evidence.map((item) => ( +

+ {item.label} + {item.text} +

+ ))} +
+ ) : null} +
+ ) : null} + {resultImagePreviews.length > 0 ? ( +
+
+ Supervisor 成果图片 + + {`${resultImagePreviews.filter((image) => image.status === 'loaded').length}/${resultImagePreviews.length}`} + +
+
+ {resultImagePreviews.map((image) => ( +
+ {image.status === 'loaded' ? ( + + ) : ( +
+ {image.status === 'loading' + ? '正在载入图片' + : '图片暂时无法显示'} +
+ )} +
+ {image.label} + {image.path} +
+
+ ))} +
+
+ ) : null} +
+
+ 最近运行活动 + {`${runtimeEvents.length} 条`} +
+ {runtimeEvents.length > 0 ? ( + runtimeEvents.map((item) => ( +
+ + {item.agentLabel} + {formatGameChatRuntimeEvent(item.event)} +
+ )) + ) : ( +

{projectReady ? '暂无运行活动' : '请先选择项目目录'}

+ )} +
+
+
+ ) : null} {pendingNonEmptyProjectCreate ? (
span { + .launcher-agent-chat-waiting > span, + .game-chat-runtime-status[data-tone='active'] .game-chat-runtime-state > span { animation: none; } } @@ -1337,6 +1338,8 @@ textarea { } .supervisor-chat-only-message-list .message { + display: grid; + gap: 5px; align-self: flex-start; width: fit-content; max-width: min(720px, 86%); @@ -1349,6 +1352,23 @@ textarea { line-height: 1.55; } +.game-chat-message-text { + min-width: 0; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.game-chat-message-time { + justify-self: start; + color: #98a2b3; + font-size: 10px; + line-height: 1; +} + +.supervisor-chat-only-message-list .message--user .game-chat-message-time { + justify-self: end; +} + .supervisor-chat-only-message-list .message--user { align-self: flex-end; border-color: #cfd6df; @@ -1477,68 +1497,124 @@ textarea { .game-chat-runtime-status { display: grid; - gap: 7px; + grid-template-columns: auto minmax(180px, 1fr) auto auto; + align-items: center; + gap: 14px; min-width: 0; - padding: 10px 16px; + min-height: 66px; + padding: 9px 16px; border-bottom: 1px solid #e1e5eb; background: #f8f9fb; } -.game-chat-runtime-status header { +.game-chat-runtime-state { display: flex; align-items: center; - justify-content: space-between; - gap: 12px; + gap: 9px; + min-width: 0; } -.game-chat-runtime-status header strong { +.game-chat-runtime-state > span { + width: 10px; + height: 10px; + flex: 0 0 auto; + border-radius: 50%; + background: #98a2b3; + box-shadow: 0 0 0 4px rgb(152 162 179 / 14%); +} + +.game-chat-runtime-status[data-tone='active'] .game-chat-runtime-state > span { + background: #12b76a; + box-shadow: 0 0 0 4px rgb(18 183 106 / 14%); +} + +.game-chat-runtime-status[data-tone='warning'] .game-chat-runtime-state > span { + background: #f79009; + box-shadow: 0 0 0 4px rgb(247 144 9 / 15%); +} + +.game-chat-runtime-status[data-tone='danger'] .game-chat-runtime-state > span { + background: #f04438; + box-shadow: 0 0 0 4px rgb(240 68 56 / 15%); +} + +.game-chat-runtime-status[data-tone='complete'] .game-chat-runtime-state > span { + background: #2e90fa; + box-shadow: 0 0 0 4px rgb(46 144 250 / 14%); +} + +.game-chat-runtime-state > div, +.game-chat-runtime-now { + display: grid; + gap: 2px; min-width: 0; +} + +.game-chat-runtime-state strong, +.game-chat-runtime-now strong, +.game-chat-runtime-now span { overflow: hidden; - color: #263142; - font-size: 12px; text-overflow: ellipsis; white-space: nowrap; } -.game-chat-runtime-status header button { - flex: 0 0 auto; - padding: 2px 7px; - border: 1px solid #d8dde5; - border-radius: 5px; - background: #fff; - color: #4b5563; - font-size: 11px; +.game-chat-runtime-state strong { + color: #1d2939; + font-size: 12px; } -.game-chat-runtime-status > div { - display: grid; - gap: 4px; - max-height: min(26dvh, 220px); - overflow-y: auto; +.game-chat-runtime-state time, +.game-chat-runtime-now span { + color: #667085; + font-size: 10px; } -.game-chat-runtime-status small { +.game-chat-runtime-now strong { + color: #344054; + font-size: 12px; +} + +.game-chat-runtime-meta { display: flex; + flex-wrap: wrap; min-width: 0; - gap: 7px; + justify-content: flex-end; + gap: 5px; +} + +.game-chat-runtime-meta span { + padding: 3px 7px; + border-radius: 999px; + background: #eaecf0; color: #667085; font-size: 11px; - line-height: 1.4; + white-space: nowrap; } -.game-chat-runtime-status small b { +.game-chat-runtime-meta span[data-tone='warning'] { + background: #fef0c7; + color: #b54708; +} + +.game-chat-runtime-detail-button { flex: 0 0 auto; + min-height: 32px; + padding: 0 10px; + border: 1px solid #cfd6df; + border-radius: 6px; + background: #fff; color: #344054; - font-weight: 600; + font-size: 11px; + white-space: nowrap; } -.supervisor-chat-only-message-list .game-chat-progress-message { +.game-chat-runtime-dialog .game-chat-progress-message { display: grid; - width: min(92%, 560px); - max-width: 560px; + width: 100%; gap: 9px; padding: 12px 14px; border: 1px solid #d9e2f0; + border-radius: 8px; background: #f7f9fc; color: #344054; } @@ -1628,13 +1704,13 @@ textarea { overflow-wrap: anywhere; } -.supervisor-chat-only-message-list .game-chat-result-images { +.game-chat-runtime-dialog .game-chat-result-images { display: grid; - width: min(96%, 640px); - max-width: 640px; + width: 100%; gap: 9px; padding: 12px; border: 1px solid #d9e2f0; + border-radius: 8px; background: #f7f9fc; color: #344054; } @@ -1731,6 +1807,133 @@ textarea { font-size: 10px; } +.game-chat-runtime-dialog { + display: grid; + gap: 14px; + width: min(840px, 100%); + max-height: min(860px, calc(100dvh - 48px)); + overflow-y: auto; +} + +.game-chat-runtime-dialog-header { + position: sticky; + z-index: 1; + top: -20px; + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin: -20px -20px 0; + padding: 18px 20px 12px; + border-bottom: 1px solid #eaecf0; + background: #fff; +} + +.game-chat-runtime-dialog-header > div { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 7px; + min-width: 0; +} + +.game-chat-runtime-dialog-header h2 { + width: 100%; + margin: 0; +} + +.game-chat-runtime-dialog-header span, +.game-chat-runtime-dialog-header time { + color: #667085; + font-size: 11px; +} + +.game-chat-runtime-dialog-header span[data-tone='active'], +.game-chat-runtime-dialog-header span[data-tone='complete'] { + color: #067647; +} + +.game-chat-runtime-dialog-header span[data-tone='warning'] { + color: #b54708; +} + +.game-chat-runtime-dialog-header span[data-tone='danger'] { + color: #b42318; +} + +.game-chat-runtime-dialog-header button { + min-height: 32px; + flex: 0 0 auto; + padding: 0 11px; + border: 1px solid #d0d5dd; + border-radius: 6px; + background: #fff; + color: #344054; +} + +.game-chat-runtime-event-list { + display: grid; + gap: 0; + min-width: 0; + overflow: hidden; + border: 1px solid #e4e7ec; + border-radius: 8px; +} + +.game-chat-runtime-event-list > header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 12px; + background: #f9fafb; +} + +.game-chat-runtime-event-list > header strong { + color: #344054; + font-size: 12px; +} + +.game-chat-runtime-event-list > header span, +.game-chat-runtime-event-list > p { + color: #667085; + font-size: 11px; +} + +.game-chat-runtime-event-list > p { + margin: 0; + padding: 12px; +} + +.game-chat-runtime-event-list > div { + display: grid; + grid-template-columns: 64px 116px minmax(0, 1fr); + gap: 9px; + min-width: 0; + padding: 8px 12px; + border-top: 1px solid #eaecf0; + color: #475467; + font-size: 11px; + line-height: 1.45; +} + +.game-chat-runtime-event-list > div time { + color: #98a2b3; + font-variant-numeric: tabular-nums; +} + +.game-chat-runtime-event-list > div b { + overflow: hidden; + color: #344054; + text-overflow: ellipsis; + white-space: nowrap; +} + +.game-chat-runtime-event-list > div span { + min-width: 0; + overflow-wrap: anywhere; +} + .game-chat-image-viewer-backdrop { position: fixed; z-index: 280; @@ -1910,16 +2113,40 @@ textarea { height: 58dvh; } - .game-chat-runtime-status > div { - max-height: 110px; + .game-chat-runtime-status { + grid-template-columns: auto minmax(0, 1fr) auto; + gap: 8px; + padding: 8px 12px; } - .supervisor-chat-only-message-list .game-chat-progress-message { - width: 96%; - max-width: 96%; + .game-chat-runtime-now { + grid-row: 2; + grid-column: 1 / -1; } - .game-chat-result-image-grid { + .game-chat-runtime-meta { + display: none; + } + + .game-chat-runtime-detail-button { + grid-column: 3; + } + + .game-chat-runtime-dialog { + width: 100%; + max-height: 100dvh; + border-radius: 0; + } + + .game-chat-runtime-event-list > div { + grid-template-columns: 58px minmax(0, 1fr); + } + + .game-chat-runtime-event-list > div span { + grid-column: 1 / -1; + } + + .game-chat-runtime-dialog .game-chat-result-image-grid { grid-template-columns: minmax(0, 1fr); } @@ -3845,6 +4072,28 @@ iframe.preview-frame { font-size: 12px; } +.game-resource-live-notice { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin: 8px 12px 0; + padding: 8px 10px; + border: 1px solid #edc7b5; + border-radius: 10px; + background: #fff7f1; + color: #8d5b45; + font-size: 11px; +} + +.game-resource-live-notice button { + flex: 0 0 auto; + border: 1px solid #dc9b7d; + border-radius: 8px; + background: #fff; + color: #9b5537; +} + .game-attachment-errors { display: grid; gap: 4px; diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index 8e186143d..340a40de2 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 @@ -1,3 +1,4 @@ +import type { ImageCanvasHostScope } from '@genarrative/image-canvas-core'; import { ChevronLeft, ChevronRight, @@ -37,10 +38,26 @@ import type { GameCreationAppPreviewState, ProjectResourceCanvasLayoutMode, } from '../../../../../packages/shared/src/contracts/gameCreationApp'; +import { + type AssetCanvasCommitNotification, + type AssetCanvasSaveAttempt, + AssetCanvasSurface, +} from '../../features/asset-canvas/AssetCanvasSurface'; +import { + createTauriImageCanvasHostAdapter, + LOCAL_ASSET_COMMITTED_EVENT, + type LocalAssetCommittedEvent, + type TauriImageCanvasHostAdapter, +} from '../../features/asset-canvas/tauriImageCanvasHostAdapter'; import { LocalGamePreviewFrame, resolveEmbeddedPreviewUrl, } from '../../features/project-workspace/LocalGamePreviewFrame'; +import { + type ProjectManifestSnapshotMetadata, + resolveResourceFocusIntent, + type ResourceFocusIntent, +} from './projectResourceLiveUpdateModel'; import { resourceCanvasSectionExtent } from './resourceCanvasLayoutModel'; import { EMPTY_PROJECT_RESOURCE_GRAPH, @@ -69,6 +86,14 @@ type ResourceSortMode = ProjectResourceCanvasLayoutMode; type WorkbenchMode = 'resources' | 'run'; type ApprovalMode = 'strict' | 'risk' | 'none'; +type AssetCanvasRoute = { + flowId: string; + sessionId: string; + expectedHostRevision: string; + scope: ImageCanvasHostScope; + host: TauriImageCanvasHostAdapter; +}; + type LocalProjectImagePreview = { path: string; mediaType: string; @@ -151,6 +176,11 @@ export type ProjectDevelopmentViewProps = { supervisor: ReactNode; onHomeOpen: () => void; onProjectsOpen: () => void; + onManifestChange?: ( + projectPath: string, + manifest: GameCreationAppManifest, + metadata: ProjectManifestSnapshotMetadata, + ) => void; }; const categoryOrder: ResourceCategory[] = [ @@ -410,6 +440,7 @@ export default function ProjectDevelopmentView({ agentRuntimeSummaries = emptyProjectAgentRuntimeSummaries, agentResults = emptyProjectAgentResults, supervisor, + onManifestChange, }: ProjectDevelopmentViewProps) { const [mode, setMode] = useState('resources'); const [sortMode, setSortMode] = useState('dependency'); @@ -420,6 +451,12 @@ export default function ProjectDevelopmentView({ const [focusedResourceId, setFocusedResourceId] = useState( null, ); + const [assetCanvasRoute, setAssetCanvasRoute] = + useState(null); + const [assetCanvasNotice, setAssetCanvasNotice] = useState(''); + const [hiddenCommittedResourceId, setHiddenCommittedResourceId] = useState< + string | null + >(null); const [approvalMode, setApprovalMode] = useState('strict'); const [approvalDialogOpen, setApprovalDialogOpen] = useState(false); const [approvalNotice, setApprovalNotice] = useState(''); @@ -440,12 +477,28 @@ export default function ProjectDevelopmentView({ }); const [mediaDuration, setMediaDuration] = useState(null); const resourceCanvasRef = useRef(null); + const resourceSearchRef = useRef(null); const resourceFocusRef = useRef(null); const resourceFocusTriggerIdRef = useRef(null); + const previousFocusedResourceIdRef = useRef(null); + const resourceFocusProjectPathRef = useRef(projectPath); + const suppressResourceFocusRestoreRef = useRef(false); const resourceListScrollRef = useRef({ left: 0, top: 0 }); const restoreResourceListScrollRef = useRef(false); + const canvasOpenEpochRef = useRef(0); + const focusGenerationRef = useRef(0); + const pendingResourceFocusRef = useRef(null); + const focusedCommitIdsRef = useRef(new Set()); + const activeFocusFlowIdRef = useRef(null); + const assetCanvasRouteRef = useRef(null); const dependencyDescriptionId = useId(); + assetCanvasRouteRef.current = assetCanvasRoute; + const advanceFocusGeneration = useCallback(() => { + focusGenerationRef.current += 1; + return focusGenerationRef.current; + }, []); + const preview = previewOverride ?? manifest.preview ?? null; const embeddedPreviewUrl = resolveEmbeddedPreviewUrl(preview); const runAvailable = @@ -468,8 +521,17 @@ export default function ProjectDevelopmentView({ ); const resourceGraphScopeKey = useMemo( () => - JSON.stringify([projectPath, manifest.projectId, resourceGraphInputs]), - [manifest.projectId, projectPath, resourceGraphInputs], + JSON.stringify([ + projectPath, + manifest.projectId, + projectedResources.map((resource) => [ + resource.id, + resource.manifestAssetId, + resource.producerTaskId, + resource.referenceResourceIds, + ]), + ]), + [manifest.projectId, projectPath, projectedResources], ); const [resourceGraphState, setResourceGraphState] = useState<{ scopeKey: string; @@ -483,21 +545,11 @@ export default function ProjectDevelopmentView({ 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', + status: 'ready', graph: EMPTY_PROJECT_RESOURCE_GRAPH, }); return () => { @@ -543,21 +595,18 @@ export default function ProjectDevelopmentView({ projectPath, resourceGraphInputs, resourceGraphScopeKey, - sortMode, ]); const resourceGraphScopeMatches = resourceGraphState.scopeKey === resourceGraphScopeKey; const resourceGraphReady = resourceGraphScopeMatches && resourceGraphState.status === 'ready'; + const resourceGraphFailed = + resourceGraphScopeMatches && resourceGraphState.status === 'failed'; const resourceGraph = resourceGraphReady ? resourceGraphState.graph : EMPTY_PROJECT_RESOURCE_GRAPH; - const resourceGraphInitializationReady = - sortMode !== 'dependency' || - (resourceGraphScopeMatches && - (resourceGraphState.status === 'ready' || - resourceGraphState.status === 'failed')); + const resourceGraphInitializationReady = resourceGraphReady; const manifestTaskById = useMemo( () => new Map(manifest.tasks.map((task) => [task.id, task])), [manifest.tasks], @@ -582,18 +631,28 @@ export default function ProjectDevelopmentView({ }), [manifestTaskById, projectedResources, resourceGraph], ); - const { - layout: resourceLayout, - notice: resourceLayoutNotice, - saving: resourceLayoutSaving, - } = useProjectResourceCanvasLayout({ + const dependencyLayout = useProjectResourceCanvasLayout({ projectPath, projectId: manifest.projectId, - mode: sortMode, + mode: 'dependency', resources, initializationReady: resourceGraphInitializationReady, - rederiveAutomaticPositions: sortMode === 'dependency' && resourceGraphReady, + renderFallbackWhileBlocked: resourceGraphFailed, + rederiveAutomaticPositions: false, }); + const typeLayout = useProjectResourceCanvasLayout({ + projectPath, + projectId: manifest.projectId, + mode: 'type', + resources, + initializationReady: true, + rederiveAutomaticPositions: false, + }); + const activeResourceLayout = + sortMode === 'dependency' ? dependencyLayout : typeLayout; + const resourceLayout = activeResourceLayout.layout; + const resourceLayoutNotice = activeResourceLayout.notice; + const resourceLayoutSaving = activeResourceLayout.saving; const resourcePositionById = useMemo( () => new Map( @@ -731,6 +790,10 @@ export default function ProjectDevelopmentView({ resources.find((resource) => resource.id === selectedResourceId) ?? null; const focusedResource = resources.find((resource) => resource.id === focusedResourceId) ?? null; + const focusedResourcePath = focusedResource?.path ?? null; + const focusedResourceCategory = focusedResource?.category ?? null; + const focusedResourceContent = focusedResource?.content; + const focusedResourceMediaType = focusedResource?.mediaType ?? null; const focusedResourceIsImage = Boolean( focusedResource && isRasterImageResource(focusedResource), ); @@ -793,17 +856,38 @@ export default function ProjectDevelopmentView({ useEffect(() => { if (embeddedPreviewUrl) { + canvasOpenEpochRef.current += 1; + advanceFocusGeneration(); + activeFocusFlowIdRef.current = null; + pendingResourceFocusRef.current = null; + setAssetCanvasRoute(null); + suppressResourceFocusRestoreRef.current = true; + restoreResourceListScrollRef.current = false; + resourceFocusTriggerIdRef.current = null; setFocusedResourceId(null); setMode('run'); } - }, [embeddedPreviewUrl]); + }, [advanceFocusGeneration, embeddedPreviewUrl]); - useEffect(() => { + useLayoutEffect(() => { + if (resourceFocusProjectPathRef.current === projectPath) { + return; + } + resourceFocusProjectPathRef.current = projectPath; + canvasOpenEpochRef.current += 1; + advanceFocusGeneration(); + activeFocusFlowIdRef.current = null; + pendingResourceFocusRef.current = null; + setAssetCanvasRoute(null); + setHiddenCommittedResourceId(null); + suppressResourceFocusRestoreRef.current = true; + previousFocusedResourceIdRef.current = null; + resourceFocusTriggerIdRef.current = null; setSelectedResourceId(null); setFocusedResourceId(null); resourceListScrollRef.current = { left: 0, top: 0 }; restoreResourceListScrollRef.current = false; - }, [projectPath]); + }, [advanceFocusGeneration, projectPath]); useEffect(() => { if (!focusedResourceId) { @@ -820,7 +904,7 @@ export default function ProjectDevelopmentView({ }, [focusedResourceId]); useEffect(() => { - if (!focusedResource || !focusedResourceIsImage) { + if (!focusedResourceId || !focusedResourcePath || !focusedResourceIsImage) { setImagePreview({ status: 'idle', resourceId: null }); return undefined; } @@ -828,23 +912,27 @@ export default function ProjectDevelopmentView({ if (!invoke) { setImagePreview({ status: 'failed', - resourceId: focusedResource.id, + resourceId: focusedResourceId, error: '图片预览需要在客户端内打开', }); return undefined; } let cancelled = false; - setImagePreview({ status: 'loading', resourceId: focusedResource.id }); + setImagePreview((current) => + current.resourceId === focusedResourceId + ? current + : { status: 'loading', resourceId: focusedResourceId }, + ); void invoke('read_local_project_image_preview', { projectPath, - relativePath: focusedResource.path, + relativePath: focusedResourcePath, }) .then((preview) => { if (!cancelled) { setImagePreview({ status: 'loaded', - resourceId: focusedResource.id, + resourceId: focusedResourceId, preview, }); } @@ -853,7 +941,7 @@ export default function ProjectDevelopmentView({ if (!cancelled) { setImagePreview({ status: 'failed', - resourceId: focusedResource.id, + resourceId: focusedResourceId, error: imagePreviewErrorMessage(error), }); } @@ -861,22 +949,31 @@ export default function ProjectDevelopmentView({ return () => { cancelled = true; }; - }, [focusedResource, focusedResourceIsImage, projectPath]); + }, [ + focusedResourceId, + focusedResourceIsImage, + focusedResourcePath, + projectPath, + ]); useEffect(() => { - if (!focusedResource || focusedResource.category !== 'document') { + if ( + !focusedResourceId || + !focusedResourcePath || + focusedResourceCategory !== 'document' + ) { setTextPreview({ status: 'idle', resourceId: null }); return undefined; } - if (focusedResource.content !== undefined) { + if (focusedResourceContent !== undefined) { setTextPreview({ status: 'loaded', - resourceId: focusedResource.id, + resourceId: focusedResourceId, preview: { - path: focusedResource.path, - mediaType: focusedResource.mediaType, - byteLen: new TextEncoder().encode(focusedResource.content).byteLength, - content: focusedResource.content, + path: focusedResourcePath, + mediaType: focusedResourceMediaType ?? 'text/plain', + byteLen: new TextEncoder().encode(focusedResourceContent).byteLength, + content: focusedResourceContent, }, }); return undefined; @@ -885,23 +982,27 @@ export default function ProjectDevelopmentView({ if (!invoke) { setTextPreview({ status: 'failed', - resourceId: focusedResource.id, + resourceId: focusedResourceId, error: '文档预览需要在客户端内打开', }); return undefined; } let cancelled = false; - setTextPreview({ status: 'loading', resourceId: focusedResource.id }); + setTextPreview((current) => + current.resourceId === focusedResourceId + ? current + : { status: 'loading', resourceId: focusedResourceId }, + ); void invoke('read_local_project_text_preview', { projectPath, - relativePath: focusedResource.path, + relativePath: focusedResourcePath, }) .then((preview) => { if (!cancelled) { setTextPreview({ status: 'loaded', - resourceId: focusedResource.id, + resourceId: focusedResourceId, preview, }); } @@ -910,7 +1011,7 @@ export default function ProjectDevelopmentView({ if (!cancelled) { setTextPreview({ status: 'failed', - resourceId: focusedResource.id, + resourceId: focusedResourceId, error: mediaPreviewErrorMessage(error), }); } @@ -918,11 +1019,20 @@ export default function ProjectDevelopmentView({ return () => { cancelled = true; }; - }, [focusedResource, projectPath]); + }, [ + focusedResourceCategory, + focusedResourceContent, + focusedResourceId, + focusedResourceMediaType, + focusedResourcePath, + projectPath, + ]); useEffect(() => { if ( - !focusedResource || + !focusedResourceId || + !focusedResourcePath || + !focusedResourceCategory || (!focusedResourceIsExtendedArtMedia && !focusedResourceIsAudio) ) { setMediaPreview({ status: 'idle', resourceId: null }); @@ -933,7 +1043,7 @@ export default function ProjectDevelopmentView({ if (!invoke) { setMediaPreview({ status: 'failed', - resourceId: focusedResource.id, + resourceId: focusedResourceId, error: '媒体预览需要在客户端内打开', }); return undefined; @@ -941,17 +1051,21 @@ export default function ProjectDevelopmentView({ let cancelled = false; setMediaDuration(null); - setMediaPreview({ status: 'loading', resourceId: focusedResource.id }); + setMediaPreview((current) => + current.resourceId === focusedResourceId + ? current + : { status: 'loading', resourceId: focusedResourceId }, + ); void invoke('read_local_project_media_preview', { projectPath, - relativePath: focusedResource.path, - category: focusedResource.category, + relativePath: focusedResourcePath, + category: focusedResourceCategory, }) .then((preview) => { if (!cancelled) { setMediaPreview({ status: 'loaded', - resourceId: focusedResource.id, + resourceId: focusedResourceId, preview, }); } @@ -960,7 +1074,7 @@ export default function ProjectDevelopmentView({ if (!cancelled) { setMediaPreview({ status: 'failed', - resourceId: focusedResource.id, + resourceId: focusedResourceId, error: mediaPreviewErrorMessage(error), }); } @@ -969,17 +1083,39 @@ export default function ProjectDevelopmentView({ cancelled = true; }; }, [ - focusedResource, + focusedResourceCategory, + focusedResourceId, focusedResourceIsAudio, focusedResourceIsExtendedArtMedia, + focusedResourcePath, projectPath, ]); useLayoutEffect(() => { - if (focusedResource) { - resourceFocusRef.current?.focus({ preventScroll: true }); + if (suppressResourceFocusRestoreRef.current) { + suppressResourceFocusRestoreRef.current = false; + previousFocusedResourceIdRef.current = focusedResourceId; return; } + if (focusedResourceId && !focusedResource) { + previousFocusedResourceIdRef.current = null; + resourceFocusTriggerIdRef.current = null; + restoreResourceListScrollRef.current = false; + setSelectedResourceId((current) => + current === focusedResourceId ? null : current, + ); + setFocusedResourceId(null); + resourceSearchRef.current?.focus({ preventScroll: true }); + return; + } + if (focusedResourceId && focusedResource) { + if (previousFocusedResourceIdRef.current !== focusedResourceId) { + resourceFocusRef.current?.focus({ preventScroll: true }); + } + previousFocusedResourceIdRef.current = focusedResourceId; + return; + } + previousFocusedResourceIdRef.current = null; if (!restoreResourceListScrollRef.current) { return; } @@ -988,31 +1124,297 @@ export default function ProjectDevelopmentView({ canvas.scrollLeft = resourceListScrollRef.current.left; canvas.scrollTop = resourceListScrollRef.current.top; const triggerResourceId = resourceFocusTriggerIdRef.current; - if (triggerResourceId) { - Array.from( - canvas.querySelectorAll('[data-resource-id]'), - ) - .find((card) => card.dataset.resourceId === triggerResourceId) - ?.focus({ preventScroll: true }); + const triggerCard = triggerResourceId + ? Array.from( + canvas.querySelectorAll('[data-resource-id]'), + ).find((card) => card.dataset.resourceId === triggerResourceId) + : null; + if (triggerCard) { + triggerCard.focus({ preventScroll: true }); + } else { + resourceSearchRef.current?.focus({ preventScroll: true }); } + } else { + resourceSearchRef.current?.focus({ preventScroll: true }); } + resourceFocusTriggerIdRef.current = null; restoreResourceListScrollRef.current = false; - }, [focusedResource]); + }, [focusedResource, focusedResourceId]); - const handleResourceSelect = useCallback((resourceId: string) => { - const canvas = resourceCanvasRef.current; - if (canvas) { - resourceListScrollRef.current = { - left: canvas.scrollLeft, - top: canvas.scrollTop, + const handleResourceSelect = useCallback( + (resourceId: string) => { + advanceFocusGeneration(); + const canvas = resourceCanvasRef.current; + if (canvas) { + resourceListScrollRef.current = { + left: canvas.scrollLeft, + top: canvas.scrollTop, + }; + } + suppressResourceFocusRestoreRef.current = false; + resourceFocusTriggerIdRef.current = resourceId; + setSelectedResourceId(resourceId); + setFocusedResourceId(resourceId); + setHiddenCommittedResourceId(null); + }, + [advanceFocusGeneration], + ); + + const openAssetCanvas = useCallback( + (intent: 'create' | 'refine', sourceAssetId: string | null) => { + const invoke = window.__TAURI__?.core?.invoke; + if (!invoke) { + setAssetCanvasNotice('素材创作画布需要在客户端内打开'); + return; + } + const openEpoch = canvasOpenEpochRef.current + 1; + canvasOpenEpochRef.current = openEpoch; + advanceFocusGeneration(); + pendingResourceFocusRef.current = null; + setHiddenCommittedResourceId(null); + setAssetCanvasNotice('正在打开素材创作画布…'); + void invoke<{ revision: number }>('get_local_game_project_revision', { + projectPath, + }) + .then((status) => { + if ( + canvasOpenEpochRef.current !== openEpoch || + !Number.isSafeInteger(status.revision) || + status.revision < 0 + ) { + return; + } + const flowId = crypto.randomUUID(); + const sessionId = crypto.randomUUID(); + const scope: ImageCanvasHostScope = { + projectId: manifest.projectId, + draftId: crypto.randomUUID(), + intent, + sourceAssetId, + }; + activeFocusFlowIdRef.current = flowId; + setAssetCanvasRoute({ + flowId, + sessionId, + expectedHostRevision: String(status.revision), + scope, + host: createTauriImageCanvasHostAdapter({ + projectPath, + expectedProjectId: manifest.projectId, + expectedHostRevision: status.revision, + invoke, + }), + }); + setAssetCanvasNotice(''); + }) + .catch((error: unknown) => { + if (canvasOpenEpochRef.current === openEpoch) { + setAssetCanvasNotice( + error instanceof Error ? error.message : String(error), + ); + } + }); + }, + [advanceFocusGeneration, manifest.projectId, projectPath], + ); + + const cancelAssetCanvas = useCallback(() => { + canvasOpenEpochRef.current += 1; + advanceFocusGeneration(); + activeFocusFlowIdRef.current = null; + pendingResourceFocusRef.current = null; + setAssetCanvasRoute(null); + setAssetCanvasNotice(''); + setHiddenCommittedResourceId(null); + }, [advanceFocusGeneration]); + + const handleAssetCanvasSaveAttempt = useCallback( + (attempt: AssetCanvasSaveAttempt) => { + const route = assetCanvasRouteRef.current; + if ( + !route || + route.sessionId !== attempt.sessionId || + route.scope.projectId !== attempt.projectId || + route.scope.draftId !== attempt.draftId + ) { + return; + } + pendingResourceFocusRef.current = { + flowId: route.flowId, + saveAttemptId: attempt.saveAttemptId, + sessionId: attempt.sessionId, + draftId: attempt.draftId, + commitId: attempt.commitId, + projectPath, + projectId: attempt.projectId, + focusGeneration: focusGenerationRef.current, + resourceId: null, + completed: false, }; + }, + [projectPath], + ); + + const handleAssetCommitted = useCallback( + (notification: AssetCanvasCommitNotification) => { + if ( + notification.projectPath !== projectPath || + notification.projectId !== manifest.projectId || + notification.manifest.projectId !== manifest.projectId + ) { + return; + } + onManifestChange?.(projectPath, notification.manifest, { + projectId: notification.projectId, + revision: notification.projectRevision, + source: + notification.source === 'event' ? 'asset-event' : 'asset-command', + commitId: notification.commitId, + eventId: notification.eventId, + }); + + const route = assetCanvasRouteRef.current; + const focusIntent = pendingResourceFocusRef.current; + if ( + !route || + route.flowId !== activeFocusFlowIdRef.current || + route.scope.draftId !== notification.draftId || + !focusIntent || + focusIntent.flowId !== route.flowId || + focusIntent.commitId !== notification.commitId + ) { + return; + } + pendingResourceFocusRef.current = { + ...focusIntent, + resourceId: `asset:${notification.assetId}`, + }; + setAssetCanvasRoute(null); + setMode('resources'); + setFocusedResourceId(null); + setAssetCanvasNotice('素材已保存,正在同步资源与布局…'); + }, + [manifest.projectId, onManifestChange, projectPath], + ); + + useEffect(() => { + const listen = window.__TAURI__?.event?.listen; + if (!listen) { + return undefined; } - resourceFocusTriggerIdRef.current = resourceId; - setSelectedResourceId(resourceId); - setFocusedResourceId(resourceId); - }, []); + let disposed = false; + let unlisten: (() => void) | undefined; + void listen( + LOCAL_ASSET_COMMITTED_EVENT, + (event) => { + const payload = event.payload; + handleAssetCommitted({ + source: 'event', + projectPath: payload.projectPath, + projectId: payload.projectId, + draftId: payload.draftId, + commitId: payload.commitId, + assetId: payload.asset.id, + manifest: payload.manifest, + projectRevision: payload.committedProjectRevision, + committedProjectRevision: payload.committedProjectRevision, + eventId: payload.eventId, + }); + }, + ) + .then((cleanup) => { + if (disposed) { + cleanup(); + } else { + unlisten = cleanup; + } + }) + .catch(() => undefined); + return () => { + disposed = true; + unlisten?.(); + }; + }, [handleAssetCommitted]); + + useLayoutEffect(() => { + const intent = pendingResourceFocusRef.current; + if (!intent) { + return; + } + const targetResource = intent.resourceId + ? resources.find((resource) => resource.id === intent.resourceId) + : undefined; + const targetVisible = intent.resourceId + ? visibleResourceIds.has(intent.resourceId) + : false; + const card = intent.resourceId + ? Array.from( + resourceCanvasRef.current?.querySelectorAll( + '[data-resource-id]', + ) ?? [], + ).find((element) => element.dataset.resourceId === intent.resourceId) + : undefined; + const resolution = resolveResourceFocusIntent(intent, { + projectPath, + projectId: manifest.projectId, + flowId: activeFocusFlowIdRef.current, + focusGeneration: focusGenerationRef.current, + projected: Boolean(targetResource), + dependencyLayoutSettled: dependencyLayout.settled, + dependencyPositioned: Boolean( + intent.resourceId && + dependencyLayout.layout.positions.some( + (position) => position.resourceId === intent.resourceId, + ), + ), + typeLayoutSettled: typeLayout.settled, + typePositioned: Boolean( + intent.resourceId && + typeLayout.layout.positions.some( + (position) => position.resourceId === intent.resourceId, + ), + ), + visible: targetVisible, + domRendered: Boolean(card), + }); + if (resolution === 'invalid') { + pendingResourceFocusRef.current = null; + setAssetCanvasNotice(''); + return; + } + if (resolution === 'hidden' && intent.resourceId) { + setHiddenCommittedResourceId(intent.resourceId); + setAssetCanvasNotice('新资源已保存,但被当前搜索条件隐藏'); + return; + } + if (resolution !== 'focus' || !intent.resourceId || !card) { + return; + } + if (focusedCommitIdsRef.current.has(intent.commitId)) { + pendingResourceFocusRef.current = null; + return; + } + intent.completed = true; + focusedCommitIdsRef.current.add(intent.commitId); + pendingResourceFocusRef.current = null; + setAssetCanvasNotice(''); + setHiddenCommittedResourceId(null); + card.scrollIntoView?.({ block: 'nearest', inline: 'nearest' }); + handleResourceSelect(intent.resourceId); + }, [ + dependencyLayout.layout.positions, + dependencyLayout.settled, + handleResourceSelect, + manifest.projectId, + projectPath, + resources, + typeLayout.layout.positions, + typeLayout.settled, + visibleResourceIds, + ]); function closeResourceFocus() { + advanceFocusGeneration(); restoreResourceListScrollRef.current = true; setFocusedResourceId(null); } @@ -1021,6 +1423,15 @@ export default function ProjectDevelopmentView({ if (!runAvailable) { return; } + canvasOpenEpochRef.current += 1; + advanceFocusGeneration(); + activeFocusFlowIdRef.current = null; + pendingResourceFocusRef.current = null; + setAssetCanvasRoute(null); + setHiddenCommittedResourceId(null); + suppressResourceFocusRestoreRef.current = true; + restoreResourceListScrollRef.current = false; + resourceFocusTriggerIdRef.current = null; setFocusedResourceId(null); setMode('run'); } @@ -1036,9 +1447,11 @@ export default function ProjectDevelopmentView({ aria-label="项目主视窗" data-resource-view-state={ mode === 'resources' - ? focusedResource - ? `resources.focused.${focusedResource.category}` - : 'resources.list' + ? assetCanvasRoute + ? `resources.asset-canvas.${assetCanvasRoute.scope.intent}` + : focusedResource + ? `resources.focused.${focusedResource.category}` + : 'resources.list' : undefined } > @@ -1049,7 +1462,10 @@ export default function ProjectDevelopmentView({ role="tab" aria-selected={mode === 'resources'} className={mode === 'resources' ? 'is-active' : ''} - onClick={() => setMode('resources')} + onClick={() => { + advanceFocusGeneration(); + setMode('resources'); + }} > 资源管理 @@ -1068,13 +1484,23 @@ export default function ProjectDevelopmentView({
- {mode === 'resources' && !focusedResource ? ( + {mode === 'resources' && !focusedResource && !assetCanvasRoute ? ( <> +
- {!runAvailable && !focusedResource ? ( + {!runAvailable && !focusedResource && !assetCanvasRoute ? (

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

+ {focusedResourceIsImage && focusedResource.manifestAssetId ? ( + + ) : null} + ) : null} + + ) : null} {attachments.some( (attachment) => attachment.status === 'failed', ) ? ( diff --git a/apps/ai-game-creator-shell/src/view/project-development/projectResourceLiveUpdateModel.ts b/apps/ai-game-creator-shell/src/view/project-development/projectResourceLiveUpdateModel.ts new file mode 100644 index 000000000..2e0ddb29d --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/projectResourceLiveUpdateModel.ts @@ -0,0 +1,199 @@ +import type { GameCreationAppManifest } from '../../../../../packages/shared/src/contracts/gameCreationApp'; + +export type ProjectManifestSnapshotSource = + | 'initial' + | 'supervisor' + | 'asset-command' + | 'asset-event' + | 'poll'; + +export type ProjectManifestSnapshot = { + projectPath: string; + projectId: string; + revision: number; + manifest: GameCreationAppManifest; + source: ProjectManifestSnapshotSource; + commitId?: string; + eventId?: string; +}; + +export type ProjectManifestSnapshotMetadata = Omit< + ProjectManifestSnapshot, + 'projectPath' | 'manifest' +>; + +export type ProjectManifestMergeState = { + projectPath: string; + projectId: string; + revision: number; + manifestFingerprint: string; + seenCommitRevisions: string[]; + seenEventIds: string[]; +}; + +export type ProjectManifestMergeDecision = + | 'accepted' + | 'duplicate' + | 'stale-revision' + | 'scope-mismatch' + | 'revision-conflict'; + +const MAX_DEDUPE_IDENTITIES = 256; + +function appendBounded(values: string[], value: string | undefined) { + if (!value || values.includes(value)) { + return values; + } + return [...values, value].slice(-MAX_DEDUPE_IDENTITIES); +} + +function manifestFingerprint(manifest: GameCreationAppManifest) { + return JSON.stringify(manifest); +} + +function commitRevisionIdentity(snapshot: ProjectManifestSnapshot) { + return snapshot.commitId + ? `${snapshot.projectId}:${snapshot.commitId}:${snapshot.revision}` + : undefined; +} + +function withDedupeIdentities( + state: ProjectManifestMergeState, + snapshot: ProjectManifestSnapshot, +) { + return { + ...state, + seenCommitRevisions: appendBounded( + state.seenCommitRevisions, + commitRevisionIdentity(snapshot), + ), + seenEventIds: appendBounded(state.seenEventIds, snapshot.eventId), + }; +} + +export function createProjectManifestMergeState( + snapshot: ProjectManifestSnapshot, +): ProjectManifestMergeState { + return withDedupeIdentities( + { + projectPath: snapshot.projectPath, + projectId: snapshot.projectId, + revision: snapshot.revision, + manifestFingerprint: manifestFingerprint(snapshot.manifest), + seenCommitRevisions: [], + seenEventIds: [], + }, + snapshot, + ); +} + +export function mergeProjectManifestSnapshot( + current: ProjectManifestMergeState, + snapshot: ProjectManifestSnapshot, +): { + decision: ProjectManifestMergeDecision; + state: ProjectManifestMergeState; +} { + if ( + snapshot.projectPath !== current.projectPath || + snapshot.projectId !== current.projectId || + snapshot.manifest.projectId !== snapshot.projectId + ) { + return { decision: 'scope-mismatch', state: current }; + } + if (snapshot.revision < current.revision) { + return { decision: 'stale-revision', state: current }; + } + + const fingerprint = manifestFingerprint(snapshot.manifest); + if (snapshot.revision === current.revision) { + if (fingerprint !== current.manifestFingerprint) { + return { decision: 'revision-conflict', state: current }; + } + return { + decision: 'duplicate', + state: withDedupeIdentities(current, snapshot), + }; + } + + return { + decision: 'accepted', + state: withDedupeIdentities( + { + ...current, + revision: snapshot.revision, + manifestFingerprint: fingerprint, + }, + snapshot, + ), + }; +} + +export type ResourceFocusIntent = { + flowId: string; + saveAttemptId: string; + sessionId: string; + draftId: string; + commitId: string; + projectPath: string; + projectId: string; + focusGeneration: number; + resourceId: string | null; + completed: boolean; +}; + +export type ResourceFocusResolution = + | 'invalid' + | 'wait-projection' + | 'wait-layout' + | 'hidden' + | 'wait-dom' + | 'focus' + | 'completed'; + +export function resolveResourceFocusIntent( + intent: ResourceFocusIntent, + input: { + projectPath: string; + projectId: string; + flowId: string | null; + focusGeneration: number; + projected: boolean; + dependencyLayoutSettled: boolean; + dependencyPositioned: boolean; + typeLayoutSettled: boolean; + typePositioned: boolean; + visible: boolean; + domRendered: boolean; + }, +): ResourceFocusResolution { + if (intent.completed) { + return 'completed'; + } + if ( + intent.projectPath !== input.projectPath || + intent.projectId !== input.projectId || + intent.flowId !== input.flowId || + intent.focusGeneration !== input.focusGeneration + ) { + return 'invalid'; + } + if (!intent.resourceId || !input.projected) { + return 'wait-projection'; + } + if ( + !input.dependencyLayoutSettled || + !input.typeLayoutSettled || + !input.dependencyPositioned || + !input.typePositioned + ) { + return 'wait-layout'; + } + if (!input.visible) { + return 'hidden'; + } + if (!input.domRendered) { + return 'wait-dom'; + } + return 'focus'; +} diff --git a/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts b/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts index 66a6a6802..bf1f284a6 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts @@ -28,6 +28,7 @@ type LayoutScope = { projectPath: string; projectId: string; mode: ProjectResourceCanvasLayoutMode; + resourceSignature: string; }; type ManualLayoutWriteIntent = { @@ -54,8 +55,9 @@ function createScopeKey( projectPath: string, projectId: string, mode: ProjectResourceCanvasLayoutMode, + resourceSignature: string, ) { - return JSON.stringify([projectPath, projectId, mode]); + return JSON.stringify([projectPath, projectId, mode, resourceSignature]); } export function createResourceSignature(resources: ResourceCanvasItem[]) { @@ -125,6 +127,7 @@ export function useProjectResourceCanvasLayout({ mode, resources, initializationReady = true, + renderFallbackWhileBlocked = false, rederiveAutomaticPositions = false, }: { projectPath: string; @@ -132,29 +135,36 @@ export function useProjectResourceCanvasLayout({ mode: ProjectResourceCanvasLayoutMode; resources: ResourceCanvasItem[]; initializationReady?: boolean; + renderFallbackWhileBlocked?: boolean; rederiveAutomaticPositions?: boolean; }) { - const scopeKey = createScopeKey(projectPath, projectId, mode); const resourceSignature = useMemo( () => createResourceSignature(resources), [resources], ); - const fallback = useMemo( - () => { - const empty = createEmptyResourceCanvasLayout(projectId, mode); - return initializationReady - ? reconcileLayout(empty, resources, rederiveAutomaticPositions).layout - : empty; - }, [ - initializationReady, - mode, - projectId, - rederiveAutomaticPositions, - resources, - ]); + const scopeKey = createScopeKey( + projectPath, + projectId, + mode, + resourceSignature, + ); + const fallback = useMemo(() => { + const empty = createEmptyResourceCanvasLayout(projectId, mode); + return initializationReady || renderFallbackWhileBlocked + ? reconcileLayout(empty, resources, rederiveAutomaticPositions).layout + : empty; + }, [ + initializationReady, + mode, + projectId, + rederiveAutomaticPositions, + renderFallbackWhileBlocked, + resources, + ]); const [layout, setLayout] = useState(fallback); const [notice, setNotice] = useState(''); const [saving, setSaving] = useState(false); + const [readyScopeKey, setReadyScopeKey] = useState(null); const mountedRef = useRef(true); const layoutRef = useRef(fallback); const persistedLayoutRef = useRef(fallback); @@ -167,6 +177,7 @@ export function useProjectResourceCanvasLayout({ projectPath, projectId, mode, + resourceSignature, }); const writeQueueRef = useRef([]); const activeWriteIntentRef = useRef(null); @@ -383,8 +394,7 @@ export function useProjectResourceCanvasLayout({ result.layout, resourcesRef.current, rederiveAutomaticPositions, - ) - .changed + ).changed ) { enqueueResourceSyncRef.current(currentScope.epoch); } @@ -483,12 +493,14 @@ export function useProjectResourceCanvasLayout({ projectPath, projectId, mode, + resourceSignature, }; scopeRef.current = scope; initializedScopeEpochRef.current = null; writeQueueRef.current = []; activeWriteIntentRef.current = null; redragRequiredScopeEpochRef.current = null; + setReadyScopeKey(null); const emptyLayout = createEmptyResourceCanvasLayout(projectId, mode); if (!initializationReady) { persistedLayoutRef.current = emptyLayout; @@ -510,6 +522,7 @@ export function useProjectResourceCanvasLayout({ const invoke = window.__TAURI__?.core?.invoke; if (!invoke) { initializedScopeEpochRef.current = epoch; + setReadyScopeKey(scopeKey); pumpWritesRef.current(); return undefined; } @@ -531,6 +544,7 @@ export function useProjectResourceCanvasLayout({ } persistedLayoutRef.current = loaded; initializedScopeEpochRef.current = epoch; + setReadyScopeKey(scopeKey); if ( reconcileLayout( loaded, @@ -549,6 +563,7 @@ export function useProjectResourceCanvasLayout({ } persistedLayoutRef.current = initialFallback; initializedScopeEpochRef.current = epoch; + setReadyScopeKey(scopeKey); rebuildOptimisticLayout(epoch); setNotice('布局读取失败,已使用当前会话布局'); pumpWritesRef.current(); @@ -564,6 +579,7 @@ export function useProjectResourceCanvasLayout({ projectPath, rebuildOptimisticLayout, rederiveAutomaticPositions, + resourceSignature, scopeKey, ]); @@ -670,11 +686,30 @@ export function useProjectResourceCanvasLayout({ scopeRef.current.key === scopeKey && layout.projectId === projectId && layout.mode === mode; + const ready = scopeMatches && readyScopeKey === scopeKey; + const allResourcesPositioned = resources.every((resource) => + layout.positions.some( + (position) => + position.resourceId === resource.id && + position.section === resource.category, + ), + ); + const settled = + ready && + !saving && + activeWriteIntentRef.current === null && + !writeQueueRef.current.some( + (intent) => intent.scopeEpoch === scopeRef.current.epoch, + ) && + allResourcesPositioned; return { layout: scopeMatches ? layout : fallback, notice, saving, + ready, + settled, + scopeIdentity: scopeKey, commitPosition, }; } diff --git a/apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts b/apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts index 9a9721f8d..367cca150 100644 --- a/apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts +++ b/apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts @@ -82,7 +82,8 @@ describe('Game Chat stream identity and source', () => { eventPath: 'events.jsonl', } satisfies AgentRuntimeResult; const invoke = vi.fn( - async (_command: string, _args?: Record) => runtimeResult, + async (_command: string, _args?: Record) => + runtimeResult, ); await submitProjectSupervisorRuntimeTask({ @@ -116,6 +117,7 @@ describe('Game Chat stream identity and source', () => { ...runtimeResult.state, runId: 'steer-run', runProfile: 'autonomous-game-build', + source: 'project-supervisor-game-chat', status: 'running', phase: 'planning', } satisfies AgentRuntimeState; @@ -157,6 +159,24 @@ describe('Game Chat stream identity and source', () => { runProfile: 'autonomous-game-build', }); expect(invoke.mock.calls[0]?.[1]).not.toHaveProperty('source'); + + invoke.mockClear(); + await submitProjectSupervisorRuntimeTask({ + invoke, + projectPath: '/tmp/game-chat', + sessionId: 'session-provider-retry', + prompt: 'continue from the app', + runtime: { + ...steerRuntime, + source: 'project-supervisor-cli', + }, + runProfile: 'autonomous-game-build', + source: 'project-supervisor-game-chat', + }); + expect(invoke).toHaveBeenCalledWith( + 'start_game_creator_supervisor_runtime_task', + expect.objectContaining({ source: 'project-supervisor-game-chat' }), + ); }); test('game-chat hydration keeps identical text from a different runtime response identity', () => { @@ -178,10 +198,9 @@ describe('Game Chat stream identity and source', () => { }, ]; - expect(mergeGameChatRuntimeResponseMessagesIntoHistory(history, pending)).toEqual([ - pending[0], - history[0], - ]); + expect( + mergeGameChatRuntimeResponseMessagesIntoHistory(history, pending), + ).toEqual([pending[0], history[0]]); expect( mergeGameChatRuntimeResponseMessagesIntoHistory(history, [ { ...pending[0], messageId: 'persisted-message-1' }, diff --git a/apps/ai-game-creator-shell/tests/appSurface/harness.ts b/apps/ai-game-creator-shell/tests/appSurface/harness.ts index 7555d2a6d..3fd45b7a7 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/harness.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/harness.ts @@ -334,10 +334,19 @@ function createProjectSupervisorRuntimeHarness({ runId: string; status: string; phase: string; + manifestInvalidated: boolean; runtime: Record; }; }) => void) | null = null; + let manifestInvalidatedHandler: + | ((event: { + payload: { + projectPath: string; + agentId: string; + }; + }) => void) + | null = null; const conversationRecord = ( role: 'user' | 'assistant', @@ -517,6 +526,7 @@ function createProjectSupervisorRuntimeHarness({ currentRuntime = runtimeState({ runId, runProfile: args?.runProfile, + source: args?.source ?? 'project-supervisor', status: 'running', phase: 'planning', currentTask: String(args?.task ?? ''), @@ -614,10 +624,16 @@ function createProjectSupervisorRuntimeHarness({ if (eventName === 'game-creator-agent-runtime-update') { runtimeUpdateHandler = handler; } + if (eventName === 'game-creator-manifest-invalidated') { + manifestInvalidatedHandler = handler as unknown as typeof manifestInvalidatedHandler; + } return () => { if (runtimeUpdateHandler === handler) { runtimeUpdateHandler = null; } + if (manifestInvalidatedHandler === handler) { + manifestInvalidatedHandler = null; + } }; }, ); @@ -666,6 +682,7 @@ function createProjectSupervisorRuntimeHarness({ runId: String(state.runId), status: String(state.status), phase: String(state.phase), + manifestInvalidated: true, runtime: runtimeResult(currentRuntime, currentResponseStream), }, }); @@ -678,10 +695,19 @@ function createProjectSupervisorRuntimeHarness({ runId: String(state.runId ?? ''), status: String(state.status ?? ''), phase: String(state.phase ?? ''), + manifestInvalidated: true, runtime: runtimeResult(state, null), }, }); }, + emitManifestInvalidated(agentId: string) { + manifestInvalidatedHandler?.({ + payload: { + projectPath, + agentId, + }, + }); + }, }; } diff --git a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts index 694c94b02..60494e31e 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -2,6 +2,7 @@ import type { ProjectSupervisorComponentProps } from '../../src/features/app-she import { WorkspaceLauncherShell } from '../../src/features/app-shell/WorkspaceLauncher'; import { act, + App, cleanup, createGameCreationAppManifest, createProjectSupervisorRuntimeHarness, @@ -172,6 +173,330 @@ export function registerClientHomeTests() { }); }); + it('re-reads the live manifest from a non-Supervisor Runtime event without reopening the project', async () => { + const projectPath = '/tmp/live-runtime-manifest-workbench'; + const initialManifest = createGameCreationAppManifest( + 'live-runtime-manifest-project', + '真实事件清单项目', + ); + const updatedManifest = { + ...initialManifest, + tasks: initialManifest.tasks.map((task) => + task.id === 'code-prototype' + ? { ...task, status: 'completed' as const } + : task, + ), + assets: [ + { + id: 'runtime-live-hero', + kind: 'art-spritesheet', + mediaType: 'image/png', + localPath: 'assets/runtime-live-hero.png', + source: { + kind: 'canvas' as const, + taskId: 'art-asset-plan', + resourceId: 'canvas-runtime-live-hero', + }, + }, + ], + versions: [ + { + versionId: 'version-runtime-live-1', + parentVersionId: null, + projectRevision: 1, + resourceBindings: [ + { slotId: 'hero', resourceId: 'runtime-live-hero' }, + ], + createdReason: 'initial' as const, + createdAt: 1, + }, + ], + }; + const runtimeHarness = createProjectSupervisorRuntimeHarness({ + projectPath, + }); + let manifestChanged = false; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'inspect_local_project_directory') { + return { + projectPath, + exists: true, + isDirectory: true, + isGameCreatorProject: true, + projectName: '真实事件清单项目', + recentRunStatus: null, + recentRunStopReason: null, + }; + } + if (command === 'get_local_game_manifest') { + return manifestChanged ? updatedManifest : initialManifest; + } + if (command === 'read_local_project_resource_graph') { + return { + resourceIds: (args?.resources as Array<{ resourceId: string }>).map( + (resource) => resource.resourceId, + ), + referenceEdges: [], + taskFlows: [], + connectionIndex: [], + producerAssignments: [], + dependencyDepths: [], + unresolvedReferenceResourceIds: [], + cyclicResourceIds: [], + cyclicTaskIds: [], + producerMappingTruncated: false, + }; + } + if (command === 'read_local_project_resource_canvas_layout') { + return { + schemaVersion: 'game-creator-resource-layout.v1', + projectId: 'live-runtime-manifest-project', + mode: args?.mode, + revision: 0, + positions: [], + updatedAt: 0, + }; + } + if (command === 'update_local_project_resource_canvas_layout') { + return { + status: 'updated', + layout: { + schemaVersion: 'game-creator-resource-layout.v1', + projectId: 'live-runtime-manifest-project', + mode: args?.mode, + revision: 1, + positions: args?.positions, + updatedAt: 1, + }, + }; + } + return runtimeHarness.invoke(command, args); + }, + ); + window.__TAURI__ = { + core: { invoke }, + event: { listen: runtimeHarness.listen }, + }; + render( + React.createElement(WorkspaceLauncherShell, { + currentUser: testAuthUser, + initialView: 'projects', + onLogout: vi.fn(), + ProjectSupervisor: App, + }), + ); + + fireEvent.change(screen.getByLabelText('项目目录'), { + target: { value: projectPath }, + }); + fireEvent.click(screen.getByRole('button', { name: '打开' })); + const runButton = await screen.findByRole('tab', { name: '运行' }); + expect(runButton.getAttribute('data-unavailable')).toBe('true'); + expect( + screen.queryByRole('button', { name: /runtime-live-hero\.png/ }), + ).toBeNull(); + await waitFor(() => { + expect( + invoke.mock.calls.filter( + ([command]) => command === 'inspect_local_project_directory', + ), + ).toHaveLength(2); + }); + const manifestReadsBeforeEvent = invoke.mock.calls.filter( + ([command]) => command === 'get_local_game_manifest', + ).length; + const inspectionsBeforeEvent = invoke.mock.calls.filter( + ([command]) => command === 'inspect_local_project_directory', + ).length; + + manifestChanged = true; + runtimeHarness.setProjectRevision(1); + act(() => { + runtimeHarness.emitManifestInvalidated('art-asset-plan'); + }); + + expect( + await screen.findByRole('button', { name: /runtime-live-hero\.png/ }), + ).not.toBeNull(); + expect(screen.getByRole('button', { name: /版本 1/ })).not.toBeNull(); + expect(runButton.getAttribute('data-unavailable')).toBeNull(); + await waitFor(() => { + expect( + invoke.mock.calls.filter( + ([command]) => command === 'get_local_game_manifest', + ).length, + ).toBeGreaterThan(manifestReadsBeforeEvent); + }); + expect( + invoke.mock.calls.filter( + ([command]) => command === 'inspect_local_project_directory', + ).length, + ).toBe(inspectionsBeforeEvent); + }); + + it('does not let a late manifest refresh from the previous project replace the active project', async () => { + const firstProjectPath = '/tmp/live-manifest-project-first'; + const secondProjectPath = '/tmp/live-manifest-project-second'; + const firstManifest = createGameCreationAppManifest( + 'live-manifest-project-first', + '旧项目', + ); + const staleFirstManifest = { + ...firstManifest, + assets: [ + { + id: 'stale-first-asset', + kind: 'art-spritesheet', + mediaType: 'image/png', + localPath: 'assets/stale-first.png', + source: { kind: 'generated' as const }, + }, + ], + }; + const secondManifest = createGameCreationAppManifest( + 'live-manifest-project-second', + '新项目', + ); + secondManifest.assets = [ + { + id: 'second-asset', + kind: 'art-spritesheet', + mediaType: 'image/png', + localPath: 'assets/second.png', + source: { kind: 'generated' }, + }, + ]; + let resolveStaleRefresh!: ( + manifest: typeof staleFirstManifest, + ) => void; + const staleRefresh = new Promise((resolve) => { + resolveStaleRefresh = resolve; + }); + let holdFirstRefresh = false; + let signalFirstRefreshStarted!: () => void; + const firstRefreshStarted = new Promise((resolve) => { + signalFirstRefreshStarted = resolve; + }); + const runtimeHarness = createProjectSupervisorRuntimeHarness({ + projectPath: firstProjectPath, + }); + const invoke = vi.fn( + async (command: string, args?: Record) => { + const requestedPath = String(args?.projectPath ?? ''); + if (command === 'inspect_local_project_directory') { + return { + projectPath: requestedPath, + exists: true, + isDirectory: true, + isGameCreatorProject: true, + projectName: + requestedPath === secondProjectPath ? '新项目' : '旧项目', + recentRunStatus: null, + recentRunStopReason: null, + }; + } + if (command === 'get_local_game_manifest') { + if (requestedPath === secondProjectPath) { + return secondManifest; + } + if (holdFirstRefresh) { + signalFirstRefreshStarted(); + return staleRefresh; + } + return firstManifest; + } + if (command === 'read_local_project_resource_graph') { + return { + resourceIds: (args?.resources as Array<{ resourceId: string }>).map( + (resource) => resource.resourceId, + ), + referenceEdges: [], + taskFlows: [], + connectionIndex: [], + producerAssignments: [], + dependencyDepths: [], + unresolvedReferenceResourceIds: [], + cyclicResourceIds: [], + cyclicTaskIds: [], + producerMappingTruncated: false, + }; + } + if (command === 'read_local_project_resource_canvas_layout') { + return { + schemaVersion: 'game-creator-resource-layout.v1', + projectId: + requestedPath === secondProjectPath + ? secondManifest.projectId + : firstManifest.projectId, + mode: args?.mode, + revision: 0, + positions: [], + updatedAt: 0, + }; + } + if (command === 'update_local_project_resource_canvas_layout') { + return { + status: 'updated', + layout: { + schemaVersion: 'game-creator-resource-layout.v1', + projectId: String(args?.expectedProjectId ?? ''), + mode: args?.mode, + revision: 1, + positions: args?.positions, + updatedAt: 1, + }, + }; + } + return runtimeHarness.invoke(command, args); + }, + ); + window.__TAURI__ = { + core: { invoke }, + event: { listen: runtimeHarness.listen }, + }; + render( + React.createElement(WorkspaceLauncherShell, { + currentUser: testAuthUser, + initialView: 'projects', + onLogout: vi.fn(), + ProjectSupervisor: App, + }), + ); + + fireEvent.change(screen.getByLabelText('项目目录'), { + target: { value: firstProjectPath }, + }); + fireEvent.click(screen.getByRole('button', { name: '打开' })); + await screen.findByLabelText('项目开发工作台'); + holdFirstRefresh = true; + act(() => { + runtimeHarness.emitManifestInvalidated('code-prototype'); + }); + await firstRefreshStarted; + + fireEvent.click(screen.getByRole('button', { name: '项目组' })); + fireEvent.change(screen.getByLabelText('项目目录'), { + target: { value: secondProjectPath }, + }); + fireEvent.click( + within(screen.getByLabelText('项目目录').closest('form')!).getByRole( + 'button', + { name: '打开' }, + ), + ); + expect( + await screen.findByRole('button', { name: /second\.png/ }), + ).not.toBeNull(); + + act(() => resolveStaleRefresh(staleFirstManifest)); + await Promise.resolve(); + expect( + screen.queryByRole('button', { name: /stale-first\.png/ }), + ).toBeNull(); + expect(screen.getByRole('button', { name: /second\.png/ })).not.toBeNull(); + }); + it('starts from the client home and opens a project in the same window', async () => { const fetchSpy = vi .spyOn(globalThis, 'fetch') diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index 56a2b1c50..fce5fe07b 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -129,7 +129,7 @@ function gameChatRuntimeEvent({ runId, source: agentId === 'project-supervisor' - ? 'project-supervisor' + ? 'project-supervisor-game-chat' : 'agent-delegate', eventId, eventType, @@ -151,7 +151,7 @@ function gameChatRuntimeState( taskId: 'project-supervisor', sessionId: 'game-chat-supervisor-session', runId: 'game-chat-supervisor-run', - source: 'project-supervisor', + source: 'project-supervisor-game-chat', status: 'running', phase: 'execution', currentTask: '生成首版游戏', @@ -304,7 +304,11 @@ async function renderGameChatAutoPreviewDriver({ callIndex: number, ) => Promise; }) { - const harness = createProjectSupervisorRuntimeHarness({ projectPath }); + let backgroundRuntimes: Array> = []; + const harness = createProjectSupervisorRuntimeHarness({ + projectPath, + runtimeMapLoader: async () => backgroundRuntimes, + }); const manifest = createGameCreationAppManifest( projectPath.split(/[\\/]/u).filter(Boolean).at(-1) ?? 'game-chat-race', 'game-chat-race', @@ -421,14 +425,14 @@ async function renderGameChatAutoPreviewDriver({ }; const emitValidation = async (revision: number, validatedAt: number) => { harness.setProjectRevision(revision); + const runtime = gameChatPreviewPlaytestRuntime({ + parentRunId, + revision, + updatedAt: validatedAt, + }); + backgroundRuntimes = [runtime]; await act(async () => { - harness.emitAgentRuntime( - gameChatPreviewPlaytestRuntime({ - parentRunId, - revision, - updatedAt: validatedAt, - }), - ); + harness.emitAgentRuntime(runtime); await Promise.resolve(); }); }; @@ -505,7 +509,7 @@ async function assertNewGameChatAuthorizationSupersedesDeferredAttempt( : 'start_local_game_preview'), ); expect(reachedDeferredStage).toBe(true); - }); + }, { timeout: 2_500 }); const firstAuthorization = driver.readAuthorization(); expect(firstAuthorization?.authorizationId).toEqual(expect.any(String)); @@ -1148,6 +1152,130 @@ export function registerProjectWorkbenchFoundationTests() { expect(screen.getByLabelText('子 Agent 状态栏')).not.toBeNull(); }); + it('preserves internal media focus across same-resource manifest updates and falls back when the resource is deleted', async () => { + const manifest = createGameCreationAppManifest( + 'workbench-resource-focus-updates', + '资源焦点更新测试', + ); + manifest.assets = [ + { + id: 'focus-audio', + kind: 'background-music', + mediaType: 'audio/mpeg', + localPath: 'assets/focus.mp3', + source: { kind: 'generated' }, + }, + ]; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'read_local_project_resource_graph') { + return resourceGraphForInputs(args); + } + if (command === 'read_local_project_resource_canvas_layout') { + return { + schemaVersion: 'game-creator-resource-layout.v1', + projectId: manifest.projectId, + mode: args?.mode, + revision: 0, + positions: [], + updatedAt: 0, + }; + } + if (command === 'update_local_project_resource_canvas_layout') { + return { + status: 'updated', + layout: { + schemaVersion: 'game-creator-resource-layout.v1', + projectId: manifest.projectId, + mode: args?.mode, + revision: 1, + positions: args?.positions, + updatedAt: 1, + }, + }; + } + if (command === 'read_local_project_media_preview') { + return { + path: 'assets/focus.mp3', + mediaType: 'audio/mpeg', + byteLen: 1024, + dataUrl: 'data:audio/mpeg;base64,SUQz', + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + const viewProps = { + projectName: manifest.name, + projectPath: '/tmp/workbench-resource-focus-updates', + attachments: [], + recentRunStatus: null, + recentRunStopReason: null, + supervisor: React.createElement('div', null, '项目总控'), + onHomeOpen: vi.fn(), + onProjectsOpen: vi.fn(), + }; + const rendered = render( + React.createElement(ProjectDevelopmentView, { + ...viewProps, + manifest, + }), + ); + + fireEvent.click( + await screen.findByRole('button', { name: /focus\.mp3/ }), + ); + const audio = (await screen.findByLabelText( + 'focus.mp3 音频播放器', + )) as HTMLAudioElement; + audio.focus(); + expect(document.activeElement).toBe(audio); + + const updatedManifest = { + ...manifest, + tasks: manifest.tasks.map((task) => + task.id === 'audio-director' + ? { ...task, status: 'completed' as const } + : task, + ), + assets: manifest.assets.map((asset) => ({ + ...asset, + source: { ...asset.source, taskId: 'audio-director' }, + })), + }; + rendered.rerender( + React.createElement(ProjectDevelopmentView, { + ...viewProps, + manifest: updatedManifest, + }), + ); + + expect(document.activeElement).toBe(audio); + expect( + screen.getByRole('region', { name: 'focus.mp3' }), + ).not.toBe(document.activeElement); + + rendered.rerender( + React.createElement(ProjectDevelopmentView, { + ...viewProps, + manifest: { ...updatedManifest, assets: [] }, + }), + ); + + await waitFor(() => { + expect(screen.queryByRole('region', { name: 'focus.mp3' })).toBeNull(); + expect(document.activeElement).toBe( + screen.getByLabelText('搜索项目资源'), + ); + }); + expect( + screen + .queryAllByTitle('打开资源详情') + .some((card) => card.getAttribute('aria-pressed') === 'true'), + ).toBe(false); + }); + it('renders, filters, and destroys persistent resource dependency lines without cross-section task flows', async () => { const manifest = createGameCreationAppManifest( 'workbench-resource-graph', @@ -1481,7 +1609,7 @@ export function registerProjectWorkbenchFoundationTests() { }, ]; let resolveGraph: (() => void) | null = null; - let layoutReads = 0; + let dependencyLayoutReads = 0; let layoutRevision = 0; const invoke = vi.fn( async (command: string, args?: Record) => { @@ -1491,11 +1619,13 @@ export function registerProjectWorkbenchFoundationTests() { }); } if (command === 'read_local_project_resource_canvas_layout') { - layoutReads += 1; + if (args?.mode === 'dependency') { + dependencyLayoutReads += 1; + } return { schemaVersion: 'game-creator-resource-layout.v1', projectId, - mode: 'dependency', + mode: args?.mode, revision: layoutRevision, positions: [], updatedAt: 0, @@ -1508,7 +1638,7 @@ export function registerProjectWorkbenchFoundationTests() { layout: { schemaVersion: 'game-creator-resource-layout.v1', projectId, - mode: 'dependency', + mode: args?.mode, revision: layoutRevision, positions: args?.positions, updatedAt: layoutRevision, @@ -1532,7 +1662,7 @@ export function registerProjectWorkbenchFoundationTests() { ); await waitFor(() => expect(resolveGraph).not.toBeNull()); - expect(layoutReads).toBe(0); + expect(dependencyLayoutReads).toBe(0); expect(screen.queryByText('延迟依赖图回执')).toBeNull(); await act(async () => { @@ -1540,7 +1670,7 @@ export function registerProjectWorkbenchFoundationTests() { await Promise.resolve(); }); expect(await screen.findByText('延迟依赖图回执')).not.toBeNull(); - expect(layoutReads).toBe(1); + expect(dependencyLayoutReads).toBe(1); }); it('keeps trusted truncated-graph depths through the workbench without persisting a flat automatic layout', async () => { @@ -3909,16 +4039,21 @@ export function registerProjectSupervisorSurfaceTests() { await waitFor(() => { expect(runtimeEventAppends()).toHaveLength(2); }); + const firstRuntimeMessage = screen + .getByText(/First visible runtime output/) + .closest('p'); + const secondRuntimeMessage = screen + .getByText(/Second visible runtime output/) + .closest('p'); expect( - screen - .getAllByText(/First visible runtime output/) - .some((element) => element.tagName === 'P'), - ).toBe(true); + firstRuntimeMessage?.querySelector('time')?.getAttribute('datetime'), + ).toBe('1970-01-01T00:00:40.000Z'); + expect(firstRuntimeMessage?.querySelector('time')?.textContent).toMatch( + /^\d{2}:\d{2}:\d{2}$/u, + ); expect( - screen - .getAllByText(/Second visible runtime output/) - .some((element) => element.tagName === 'P'), - ).toBe(true); + secondRuntimeMessage?.querySelector('time')?.getAttribute('datetime'), + ).toBe('1970-01-01T00:00:50.000Z'); expect( invoke.mock.calls.filter( ([command]) => command === 'start_game_creator_supervisor_runtime_task', @@ -3929,15 +4064,11 @@ export function registerProjectSupervisorSurfaceTests() { rendered = renderApp(); await waitFor(() => { expect( - screen - .getAllByText(/First visible runtime output/) - .some((element) => element.tagName === 'P'), - ).toBe(true); + screen.getByText(/First visible runtime output/).closest('p'), + ).not.toBeNull(); expect( - screen - .getAllByText(/Second visible runtime output/) - .some((element) => element.tagName === 'P'), - ).toBe(true); + screen.getByText(/Second visible runtime output/).closest('p'), + ).not.toBeNull(); }); expect(runtimeEventAppends()).toHaveLength(2); rendered.unmount(); @@ -4312,7 +4443,7 @@ export function registerProjectSupervisorSurfaceTests() { ).toBeNull(); }); - it('shows four latest game-chat events by default and expands to the capped latest twenty', () => { + it('keeps game-chat status compact and shows the capped latest twenty events in runtime details', () => { const runtime = gameChatRuntimeState({ runId: 'game-chat-events-run', recentEvents: Array.from({ length: 23 }, (_, index) => @@ -4329,34 +4460,27 @@ export function registerProjectSupervisorSurfaceTests() { renderGameChatStatus({ runtime }); const statusCard = screen.getByLabelText('最新状态'); - expect(statusCard.querySelectorAll(':scope > div > small')).toHaveLength(4); - expect(statusCard.textContent).toContain('状态事件 23'); - expect(statusCard.textContent).not.toContain('状态事件 19'); - const expand = within(statusCard).getByRole('button', { - name: '展开 20 条', - }); - expect(expand.getAttribute('aria-expanded')).toBe('false'); - - fireEvent.click(expand); - - expect(statusCard.querySelectorAll(':scope > div > small')).toHaveLength( - 20, + expect(statusCard.textContent).not.toContain('状态事件 23'); + expect(statusCard.textContent).toContain('运行详情'); + fireEvent.click( + within(statusCard).getByRole('button', { name: '运行详情' }), ); + + const details = screen.getByRole('dialog', { name: '运行详情' }); + const eventList = within(details).getByLabelText('最近运行活动'); + expect(eventList.querySelectorAll(':scope > div')).toHaveLength(20); const expandedEventTexts = Array.from( - statusCard.querySelectorAll(':scope > div > small'), + eventList.querySelectorAll(':scope > div'), (element) => element.textContent, ); + expect(expandedEventTexts[0]).toContain('状态事件 23'); expect( expandedEventTexts.some((text) => text?.includes('状态事件 4')), ).toBe(true); expect( expandedEventTexts.some((text) => text?.includes('状态事件 3')), ).toBe(false); - expect( - within(statusCard) - .getByRole('button', { name: '收起' }) - .getAttribute('aria-expanded'), - ).toBe('true'); + expect(eventList.querySelectorAll('time')).toHaveLength(20); }); it('hides internal loop iteration wording from game-chat latest status events', () => { @@ -4375,10 +4499,12 @@ export function registerProjectSupervisorSurfaceTests() { renderGameChatStatus({ runtime }); - const statusCard = screen.getByLabelText('最新状态'); expect(document.body.textContent).not.toMatch(/第\s*\d+\s*轮/u); - expect(statusCard.textContent).toContain('Agent 已形成本轮有效进度'); - expect(statusCard.textContent).toContain('生成 Agent 工具计划(本轮)'); + fireEvent.click(screen.getByRole('button', { name: '运行详情' })); + const eventList = screen.getByLabelText('最近运行活动'); + expect(eventList.textContent).toContain('Agent 已形成本轮有效进度'); + expect(eventList.textContent).toContain('生成 Agent 工具计划(本轮)'); + expect(document.body.textContent).not.toMatch(/第\s*\d+\s*轮/u); }); it('shows and archives the explicit mud point interruption from a failed art child runtime', () => { @@ -4446,10 +4572,13 @@ export function registerProjectSupervisorSurfaceTests() { renderGameChatStatus({ runtime, manifest }); + const status = screen.getByLabelText('最新状态'); + expect(status.textContent).toContain('任务图 7/7'); + expect(status.textContent).not.toContain('publish-strategy'); + expect(status.textContent).not.toContain('publish-package'); + fireEvent.click(within(status).getByRole('button', { name: '运行详情' })); const progress = screen.getByLabelText('Supervisor 进度播报'); expect(progress.textContent).toContain('任务图 7/7'); - expect(progress.textContent).not.toContain('publish-strategy'); - expect(progress.textContent).not.toContain('publish-package'); }); it('keeps one live progress card while persisting every public game-chat output as a message', async () => { @@ -4621,6 +4750,21 @@ export function registerProjectSupervisorSurfaceTests() { }), ); + const status = await screen.findByLabelText('最新状态'); + expect(status.textContent).toContain('任务图 3/7 · 进行中 1 · 计划 1/3'); + expect(status.textContent).toContain('核对首版试玩诊断'); + expect(status.textContent).toContain('1 个专业 Agent 活跃'); + const runtimeEventAppends = () => + invoke.mock.calls.filter( + ([command, args]) => + command === 'append_local_conversation_message' && + args?.agentId === null && + String(args?.messageId ?? '').startsWith('game-chat-runtime-event:'), + ); + await waitFor(() => { + expect(runtimeEventAppends()).toHaveLength(1); + }); + fireEvent.click(within(status).getByRole('button', { name: '运行详情' })); const progress = await screen.findByLabelText('Supervisor 进度播报'); expect(screen.getAllByLabelText('Supervisor 进度播报')).toHaveLength(1); expect(progress.getAttribute('data-runtime-owned')).toBe('true'); @@ -4641,17 +4785,6 @@ export function registerProjectSupervisorSurfaceTests() { 'revision 7 · 诊断 2 项 · 角色仍会穿过右侧墙体 · 失败后重开按钮没有响应', ), ).not.toBeNull(); - const runtimeEventAppends = () => - invoke.mock.calls.filter( - ([command, args]) => - command === 'append_local_conversation_message' && - args?.agentId === null && - String(args?.messageId ?? '').startsWith('game-chat-runtime-event:'), - ); - await waitFor(() => { - expect(runtimeEventAppends()).toHaveLength(1); - }); - const delegateDecision = gameChatRuntimeEvent({ runId: supervisorRunId, eventType: 'action', @@ -4823,6 +4956,9 @@ export function registerProjectSupervisorSurfaceTests() { }), ); + fireEvent.click( + await screen.findByRole('button', { name: '运行详情 · 成果 2' }), + ); const imageCard = await screen.findByLabelText('Supervisor 成果图片'); expect(imageCard.getAttribute('data-runtime-owned')).toBe('true'); expect(screen.getAllByLabelText('Supervisor 成果图片')).toHaveLength(1); @@ -5106,16 +5242,17 @@ export function registerProjectSupervisorSurfaceTests() { }); }); - const stageRecord = await screen.findByText( + const stageRecordText = await screen.findByText( /【Supervisor 阶段记录】[\s\S]*本轮生成进度 · 本轮已完成/, ); - expect(stageRecord.className).toContain('game-chat-stage-record'); + const stageRecord = stageRecordText.closest('p'); + expect(stageRecord?.className).toContain('game-chat-stage-record'); expect(screen.queryByLabelText('Supervisor 进度播报')).toBeNull(); - expect(stageRecord.textContent).toContain('试玩通过:revision 9'); - expect(stageRecord.textContent).toContain( + expect(stageRecordText.textContent).toContain('试玩通过:revision 9'); + expect(stageRecordText.textContent).toContain( '返工决定:根据上一版试玩诊断安排程序 Agent 完成返工', ); - expect(stageRecord.textContent).toContain( + expect(stageRecordText.textContent).toContain( '成果图片:美术素材图集(assets/art-spritesheet.png)', ); await waitFor(() => { @@ -6524,13 +6661,17 @@ export function registerProjectSupervisorSurfaceTests() { await waitFor(() => { expect(acceptedRunId).toMatch(/^project-supervisor-task-/); expect(postStartRuntimeReads).toBeGreaterThan(0); - expect( - within(screen.getByLabelText('最新状态')).getByText( - /新受理 Run 正在执行/, - ), - ).not.toBeNull(); + expect(screen.getByLabelText('最新状态').textContent).toContain( + '生成 accepted run 首版游戏', + ); }); expect(screen.queryByText('等待新的运行事件')).toBeNull(); + fireEvent.click(screen.getByRole('button', { name: '运行详情' })); + expect( + within(screen.getByLabelText('最近运行活动')).getByText( + /新受理 Run 正在执行/, + ), + ).not.toBeNull(); harness.setProjectRevision(4); await act(async () => { harness.emitAgentRuntime( @@ -6568,14 +6709,12 @@ export function registerProjectSupervisorSurfaceTests() { allowCompletion = true; await waitFor( () => { - expect( - within(screen.getByLabelText('最新状态')).getByText('本轮已完成'), - ).not.toBeNull(); - expect( - within(screen.getByLabelText('最新状态')).getByText( - /新受理 Run 已完成/, - ), - ).not.toBeNull(); + expect(screen.getByLabelText('最新状态').textContent).toContain( + '本轮已完成', + ); + expect(screen.getByLabelText('最近运行活动').textContent).toContain( + '新受理 Run 已完成', + ); }, { timeout: 2500 }, ); @@ -6681,9 +6820,11 @@ export function registerProjectSupervisorSurfaceTests() { await Promise.resolve(); }); - expect( - await within(surface).findByText('项目权限策略拒绝执行:preview.start'), - ).not.toBeNull(); + await waitFor(() => { + expect(within(surface).getByLabelText('最新状态').textContent).toContain( + '项目权限策略拒绝执行:preview.start', + ); + }); expect( invoke.mock.calls.filter( ([command]) => command === 'start_local_game_preview', diff --git a/apps/ai-game-creator-shell/tests/assetCanvasSurface.test.tsx b/apps/ai-game-creator-shell/tests/assetCanvasSurface.test.tsx index d7fef72d4..5ce6979b9 100644 --- a/apps/ai-game-creator-shell/tests/assetCanvasSurface.test.tsx +++ b/apps/ai-game-creator-shell/tests/assetCanvasSurface.test.tsx @@ -2,15 +2,20 @@ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; -import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; -import React from 'react'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - import type { ImageCanvasDraft, ImageCanvasDraftCanvas, ImageCanvasHostScope, } from '@genarrative/image-canvas-core'; +import { + act, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; +import React from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { GameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp'; import { @@ -27,7 +32,10 @@ import { class TestPointerEvent extends MouseEvent { readonly pointerId: number; - constructor(type: string, init: MouseEventInit & { pointerId?: number } = {}) { + constructor( + type: string, + init: MouseEventInit & { pointerId?: number } = {}, + ) { super(type, init); this.pointerId = init.pointerId ?? 1; } @@ -123,11 +131,17 @@ function memoryHost(input?: { const commits: Array<{ commitId: string; idempotencyKey: string }> = []; const revokedSubscriptions = vi.fn(); const manifest = manifestFixture(draft?.projectId ?? scope.projectId); - const loadDraft = vi.fn(async () => ({ status: 'ok' as const, value: draft })); + const loadDraft = vi.fn(async () => ({ + status: 'ok' as const, + value: draft, + })); const recover = vi.fn(async () => { await input?.recoverGate?.promise; if (input?.recoveryEvent) eventListener?.(input.recoveryEvent); - return { status: 'ok' as const, value: { projectRevision: hostRevision, manifest } }; + return { + status: 'ok' as const, + value: { projectRevision: hostRevision, manifest }, + }; }); const host: TauriImageCanvasHostAdapter = { kind: 'tauri', @@ -276,9 +290,13 @@ function renderSurface( host: TauriImageCanvasHostAdapter, canvasScope: ImageCanvasHostScope = scope, onCommitted = vi.fn(), + onSaveAttempt = vi.fn(), + onCancel = vi.fn(), ) { return { onCommitted, + onSaveAttempt, + onCancel, ...render(
, @@ -314,9 +334,18 @@ afterEach(() => { }); describe('Tauri 素材创作无限画布独立 Surface', () => { + it('取消时先把草稿标记为 cancelled,再返回资源总览', async () => { + const memory = memoryHost(); + const { onCancel } = renderSurface(memory.host); + expect(await screen.findByText('canvas.editing')).toBeTruthy(); + fireEvent.click(screen.getByRole('button', { name: '取消并返回' })); + await waitFor(() => expect(onCancel).toHaveBeenCalledTimes(1)); + expect(memory.getDraft()?.status).toBe('cancelled'); + }); + it('新建、导入、编辑、撤销重做并完成 durable commit', async () => { const memory = memoryHost(); - const { onCommitted } = renderSurface(memory.host); + const { onCommitted, onSaveAttempt } = renderSurface(memory.host); expect(await screen.findByText('canvas.editing')).toBeTruthy(); const file = new File([Uint8Array.from([1, 2, 3])], 'fixture.png', { @@ -345,6 +374,25 @@ describe('Tauri 素材创作无限画布独立 Surface', () => { fireEvent.click(screen.getByRole('button', { name: '保存到项目' })); await waitFor(() => expect(onCommitted).toHaveBeenCalledTimes(1)); + expect(onSaveAttempt).toHaveBeenCalledTimes(1); + expect(onSaveAttempt).toHaveBeenCalledWith( + expect.objectContaining({ + projectId: scope.projectId, + draftId: scope.draftId, + commitId: memory.commits[0]!.commitId, + }), + ); + expect(onCommitted).toHaveBeenCalledWith( + expect.objectContaining({ + source: 'command', + projectPath: '/fixture/project', + projectId: scope.projectId, + draftId: scope.draftId, + commitId: memory.commits[0]!.commitId, + assetId: 'local-asset:canvas-commit', + projectRevision: 1, + }), + ); expect(memory.commits).toHaveLength(1); expect(memory.updates.at(-1)?.layers).toHaveLength(1); expect(screen.getByText(/已保存到项目 assets/)).toBeTruthy(); @@ -379,7 +427,10 @@ describe('Tauri 素材创作无限画布独立 Surface', () => { flipX: false, flipY: false, }); - const memory = memoryHost({ initialDraft: draftFixture(scope, canvas), commitGate }); + const memory = memoryHost({ + initialDraft: draftFixture(scope, canvas), + commitGate, + }); const { onCommitted } = renderSurface(memory.host); await screen.findByLabelText('图层 已有图层'); const save = screen.getByRole('button', { name: '保存到项目' }); @@ -493,7 +544,9 @@ describe('Tauri 素材创作无限画布独立 Surface', () => { fireEvent.click(screen.getByRole('button', { name: 'Mock 生成' })); expect(await screen.findByText(/明确 mock/)).toBeTruthy(); expect(screen.getByRole('button', { name: /复位/ })).toBeTruthy(); - expect(document.querySelector('.genarrative-image-canvas__minimap')).not.toBeNull(); + expect( + document.querySelector('.genarrative-image-canvas__minimap'), + ).not.toBeNull(); const css = readFileSync( resolve( process.cwd(), @@ -612,35 +665,37 @@ describe('Tauri 素材创作无限画布独立 Surface', () => { it('Tauri adapter 绑定 expectedProjectId,并在未知结果重试时复用 staging token', async () => { const manifest = manifestFixture(scope.projectId); let commitCalls = 0; - const invokeSpy = vi.fn(async (command: string, args?: Record) => { - if (command === 'stage_local_project_asset_canvas_image') { - return { - status: 'staged', - stagedImageToken: 'stable-staging-token', - draftId: scope.draftId, - draftRevision: 0, - draft: null, - }; - } - if (command === 'commit_local_project_asset') { - commitCalls += 1; - if (commitCalls === 1) throw new Error('IPC response lost'); - return { - status: 'already-committed', - projectId: scope.projectId, - projectRevision: 1, - committedProjectRevision: 1, - draftId: scope.draftId, - draftRevision: 1, - commitId: '77777777-7777-4777-8777-777777777777', - idempotencyKey: '88888888-8888-4888-8888-888888888888', - eventId: 'stable-event', - asset: manifest.assets[0], - manifest, - }; - } - throw new Error(`unexpected command: ${command}`); - }); + const invokeSpy = vi.fn( + async (command: string, args?: Record) => { + if (command === 'stage_local_project_asset_canvas_image') { + return { + status: 'staged', + stagedImageToken: 'stable-staging-token', + draftId: scope.draftId, + draftRevision: 0, + draft: null, + }; + } + if (command === 'commit_local_project_asset') { + commitCalls += 1; + if (commitCalls === 1) throw new Error('IPC response lost'); + return { + status: 'already-committed', + projectId: scope.projectId, + projectRevision: 1, + committedProjectRevision: 1, + draftId: scope.draftId, + draftRevision: 1, + commitId: '77777777-7777-4777-8777-777777777777', + idempotencyKey: '88888888-8888-4888-8888-888888888888', + eventId: 'stable-event', + asset: manifest.assets[0], + manifest, + }; + } + throw new Error(`unexpected command: ${command}`); + }, + ); const adapter = createTauriImageCanvasHostAdapter({ projectPath: '/fixture/project', expectedProjectId: scope.projectId, diff --git a/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx b/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx new file mode 100644 index 000000000..116ca14d1 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx @@ -0,0 +1,353 @@ +/** @vitest-environment jsdom */ +import React, { useState } from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { GameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp'; + +const canvasFixture = vi.hoisted(() => ({ + manifest: null as GameCreationAppManifest | null, + revision: 0, + sequence: 0, +})); + +vi.mock('../src/features/asset-canvas/AssetCanvasSurface', async () => { + const ReactModule = await import('react'); + return { + AssetCanvasSurface: (props: { + scope: { + projectId: string; + draftId: string; + intent: 'create' | 'refine'; + sourceAssetId: string | null; + }; + sessionId: string; + onCancel?: () => void; + onSaveAttempt?: (attempt: { + saveAttemptId: string; + sessionId: string; + projectId: string; + draftId: string; + commitId: string; + }) => void; + onCommitted?: (notification: Record) => void; + }) => + ReactModule.createElement( + 'section', + { 'aria-label': '测试素材创作画布' }, + ReactModule.createElement( + 'span', + null, + `${props.scope.intent}:${props.scope.sourceAssetId ?? 'none'}`, + ), + ReactModule.createElement( + 'button', + { type: 'button', onClick: props.onCancel }, + '取消并返回', + ), + ReactModule.createElement( + 'button', + { + type: 'button', + onClick: () => { + const base = canvasFixture.manifest; + if (!base) throw new Error('missing manifest fixture'); + canvasFixture.sequence += 1; + canvasFixture.revision += 1; + const assetId = `canvas-output-${canvasFixture.sequence}`; + const commitId = `commit-${canvasFixture.sequence}`; + const sourceResourceId = props.scope.sourceAssetId + ? `asset:${props.scope.sourceAssetId}` + : null; + const nextManifest = { + ...base, + assets: [ + ...base.assets, + { + id: assetId, + kind: 'art-image', + mediaType: 'image/png', + localPath: `assets/${assetId}.png`, + source: { + kind: 'canvas' as const, + taskId: null, + resourceId: `canvas-resource-${canvasFixture.sequence}`, + referenceResourceIds: sourceResourceId + ? [sourceResourceId] + : [], + }, + }, + ], + }; + canvasFixture.manifest = nextManifest; + props.onSaveAttempt?.({ + saveAttemptId: `save-${canvasFixture.sequence}`, + sessionId: props.sessionId, + projectId: props.scope.projectId, + draftId: props.scope.draftId, + commitId, + }); + props.onCommitted?.({ + source: 'command', + projectPath: '/tmp/live-canvas-integration', + projectId: props.scope.projectId, + draftId: props.scope.draftId, + commitId, + assetId, + manifest: nextManifest, + projectRevision: canvasFixture.revision, + committedProjectRevision: canvasFixture.revision, + eventId: `event-${canvasFixture.sequence}`, + }); + }, + }, + '完成测试保存', + ), + ), + }; +}); + +import ProjectDevelopmentView from '../src/view/project-development'; +import { + createGameCreationAppManifest, + fireEvent, + render, + screen, + waitFor, +} from './appSurface/harness'; + +const projectPath = '/tmp/live-canvas-integration'; + +function graphFor( + resources: Array<{ resourceId: string }>, + manifest: GameCreationAppManifest, +) { + const resourceIds = resources.map((resource) => resource.resourceId); + const referenceEdges = manifest.assets.flatMap((asset) => + (asset.source.referenceResourceIds ?? []).map((sourceResourceId) => ({ + id: `reference:${sourceResourceId}:${asset.id}`, + kind: 'asset-reference' as const, + sourceResourceId, + targetResourceId: `asset:${asset.id}`, + cyclic: false, + })), + ); + return { + resourceIds, + referenceEdges, + taskFlows: [], + connectionIndex: resourceIds.map((resourceId) => ({ + resourceId, + upstreamReferenceResourceIds: referenceEdges + .filter((edge) => edge.targetResourceId === resourceId) + .map((edge) => edge.sourceResourceId), + downstreamReferenceResourceIds: [], + referenceEdgeIds: referenceEdges + .filter( + (edge) => + edge.targetResourceId === resourceId || + edge.sourceResourceId === resourceId, + ) + .map((edge) => edge.id), + taskFlowIds: [], + })), + producerAssignments: [], + dependencyDepths: resourceIds.map((resourceId) => ({ + resourceId, + dependencyDepth: referenceEdges.some( + (edge) => edge.targetResourceId === resourceId, + ) + ? 1 + : 0, + })), + unresolvedReferenceResourceIds: [], + cyclicResourceIds: [], + cyclicTaskIds: [], + producerMappingTruncated: false, + }; +} + +function LiveWorkbench() { + const initial = createGameCreationAppManifest( + 'live-canvas-project', + '实时画布项目', + ); + initial.assets = [ + { + id: 'source-art', + kind: 'art-image', + mediaType: 'image/png', + localPath: 'assets/source-art.png', + source: { kind: 'canvas', taskId: null, resourceId: 'source-resource' }, + }, + ]; + const [manifest, setManifest] = useState(initial); + canvasFixture.manifest = manifest; + + return ( + Supervisor} + onHomeOpen={() => undefined} + onProjectsOpen={() => undefined} + onManifestChange={(_path, nextManifest) => setManifest(nextManifest)} + /> + ); +} + +describe('project resource live canvas integration', () => { + afterEach(() => { + delete window.__TAURI__; + canvasFixture.manifest = null; + canvasFixture.revision = 0; + canvasFixture.sequence = 0; + }); + + function installTauri() { + const layoutWrites: Array> = []; + const graphReads: Array> = []; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'get_local_game_project_revision') { + return { revision: canvasFixture.revision }; + } + if (command === 'read_local_project_resource_graph') { + graphReads.push(structuredClone(args ?? {})); + return graphFor( + (args?.resources ?? []) as Array<{ resourceId: string }>, + canvasFixture.manifest!, + ); + } + if (command === 'read_local_project_resource_canvas_layout') { + return { + schemaVersion: 'game-creator-resource-layout.v1', + projectId: 'live-canvas-project', + mode: args?.mode, + revision: 0, + positions: [], + updatedAt: 0, + }; + } + if (command === 'update_local_project_resource_canvas_layout') { + layoutWrites.push(structuredClone(args ?? {})); + return { + status: 'updated', + layout: { + schemaVersion: 'game-creator-resource-layout.v1', + projectId: 'live-canvas-project', + mode: args?.mode, + revision: Number(args?.expectedRevision ?? 0) + 1, + positions: args?.positions, + updatedAt: 1, + }, + }; + } + if (command === 'read_local_project_image_preview') { + return { + path: String(args?.relativePath ?? ''), + mediaType: 'image/png', + byteLen: 1, + dataUrl: 'data:image/png;base64,AA==', + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { + core: { invoke }, + event: { listen: async () => () => undefined }, + }; + return { graphReads, layoutWrites }; + } + + it('enters create/refine in the central view, cancels to context, and coordinates both layouts after a durable refine', async () => { + const { graphReads, layoutWrites } = installTauri(); + render(); + + const sourceCard = await screen.findByRole('button', { + name: /source-art\.png/, + }); + fireEvent.click(sourceCard); + fireEvent.click(screen.getByRole('button', { name: '精修资源' })); + expect( + (await screen.findByLabelText('测试素材创作画布')).textContent, + ).toContain('refine:source-art'); + fireEvent.click(screen.getByRole('button', { name: '取消并返回' })); + expect( + await screen.findByRole('region', { name: /source-art\.png/ }), + ).not.toBeNull(); + + fireEvent.click(screen.getByRole('button', { name: '精修资源' })); + fireEvent.click( + await screen.findByRole('button', { name: '完成测试保存' }), + ); + expect( + await screen.findByRole('region', { name: /canvas-output-1\.png/ }), + ).not.toBeNull(); + fireEvent.click(screen.getByRole('button', { name: '收起资源' })); + expect( + await screen.findByText(/canvas-output-1\.png 引用 source-art\.png/), + ).not.toBeNull(); + + await waitFor(() => { + expect( + layoutWrites.some( + (write) => + write.mode === 'dependency' && + (write.positions as Array<{ resourceId: string }>).some( + (position) => position.resourceId === 'asset:canvas-output-1', + ), + ), + ).toBe(true); + expect( + layoutWrites.some( + (write) => + write.mode === 'type' && + (write.positions as Array<{ resourceId: string }>).some( + (position) => position.resourceId === 'asset:canvas-output-1', + ), + ), + ).toBe(true); + }); + expect( + graphReads.some((read) => { + const resourceIds = ( + read.resources as Array<{ resourceId: string }> + ).map((resource) => resource.resourceId); + return ( + resourceIds.includes('asset:source-art') && + resourceIds.includes('asset:canvas-output-1') + ); + }), + ).toBe(true); + }); + + it('keeps an existing search when the new resource is hidden and locates only after the explicit action', async () => { + installTauri(); + render(); + const search = await screen.findByLabelText('搜索项目资源'); + fireEvent.change(search, { target: { value: 'source-art' } }); + fireEvent.click(screen.getByRole('button', { name: '新增资源' })); + fireEvent.click( + await screen.findByRole('button', { name: '完成测试保存' }), + ); + + expect( + await screen.findByText('新资源已保存,但被当前搜索条件隐藏'), + ).not.toBeNull(); + expect((search as HTMLInputElement).value).toBe('source-art'); + fireEvent.click(screen.getByRole('button', { name: '清除搜索并定位' })); + expect( + await screen.findByRole('region', { name: /canvas-output-1\.png/ }), + ).not.toBeNull(); + fireEvent.click(screen.getByRole('button', { name: '收起资源' })); + expect( + ((await screen.findByLabelText('搜索项目资源')) as HTMLInputElement) + .value, + ).toBe(''); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/projectResourceLiveUpdateModel.test.ts b/apps/ai-game-creator-shell/tests/projectResourceLiveUpdateModel.test.ts new file mode 100644 index 000000000..c70af9e8e --- /dev/null +++ b/apps/ai-game-creator-shell/tests/projectResourceLiveUpdateModel.test.ts @@ -0,0 +1,200 @@ +import { readFileSync } from 'node:fs'; + +import { describe, expect, it } from 'vitest'; + +import type { GameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp'; +import { + createProjectManifestMergeState, + mergeProjectManifestSnapshot, + type ProjectManifestSnapshot, + resolveResourceFocusIntent, + type ResourceFocusIntent, +} from '../src/view/project-development/projectResourceLiveUpdateModel'; + +function manifest(projectId: string, assetIds: string[] = []) { + return { + schemaVersion: 'game-creation-app.v1', + projectId, + name: projectId, + tasks: [], + assets: assetIds.map((id) => ({ + id, + kind: 'asset', + mediaType: 'image/png', + localPath: `assets/${id}.png`, + source: { kind: 'canvas' as const, taskId: null }, + })), + versions: [], + } as unknown as GameCreationAppManifest; +} + +function snapshot( + revision: number, + source: ProjectManifestSnapshot['source'], + assetIds: string[] = [], +): ProjectManifestSnapshot { + const value = manifest('project-live', assetIds); + return { + projectPath: '/tmp/project-live', + projectId: value.projectId, + revision, + manifest: value, + source, + commitId: assetIds.length > 0 ? `commit-${assetIds.at(-1)}` : undefined, + eventId: source === 'asset-event' ? `event-${revision}` : undefined, + }; +} + +function focusIntent( + input?: Partial, +): ResourceFocusIntent { + return { + flowId: 'flow-1', + saveAttemptId: 'save-1', + sessionId: 'session-1', + draftId: 'draft-1', + commitId: 'commit-1', + projectPath: '/tmp/project-live', + projectId: 'project-live', + focusGeneration: 3, + resourceId: 'asset:new-art', + completed: false, + ...input, + }; +} + +function focusEnvironment( + input?: Partial[1]>, +) { + return { + projectPath: '/tmp/project-live', + projectId: 'project-live', + flowId: 'flow-1', + focusGeneration: 3, + projected: true, + dependencyLayoutSettled: true, + dependencyPositioned: true, + typeLayoutSettled: true, + typePositioned: true, + visible: true, + domRendered: true, + ...input, + }; +} + +describe('project resource live update model', () => { + it('deduplicates command-first and event-first commit delivery', () => { + const initial = createProjectManifestMergeState(snapshot(4, 'initial')); + const command = snapshot(5, 'asset-command', ['new-art']); + const event = { + ...snapshot(5, 'asset-event', ['new-art']), + eventId: 'event-5', + }; + + const commandFirst = mergeProjectManifestSnapshot(initial, command); + expect(commandFirst.decision).toBe('accepted'); + const commandThenEvent = mergeProjectManifestSnapshot( + commandFirst.state, + event, + ); + expect(commandThenEvent.decision).toBe('duplicate'); + expect(commandThenEvent.state.seenEventIds).toEqual(['event-5']); + + const eventFirst = mergeProjectManifestSnapshot(initial, event); + expect(eventFirst.decision).toBe('accepted'); + expect( + mergeProjectManifestSnapshot(eventFirst.state, command).decision, + ).toBe('duplicate'); + }); + + it('rejects an old poll and fails closed on a divergent equal revision', () => { + const accepted = createProjectManifestMergeState( + snapshot(8, 'asset-event', ['new-art']), + ); + expect( + mergeProjectManifestSnapshot(accepted, snapshot(7, 'poll')).decision, + ).toBe('stale-revision'); + expect( + mergeProjectManifestSnapshot( + accepted, + snapshot(8, 'poll', ['different-art']), + ).decision, + ).toBe('revision-conflict'); + }); + + it('keeps project switching and user selection generations from stealing focus', () => { + expect( + resolveResourceFocusIntent( + focusIntent(), + focusEnvironment({ projectId: 'other-project' }), + ), + ).toBe('invalid'); + expect( + resolveResourceFocusIntent( + focusIntent(), + focusEnvironment({ focusGeneration: 4 }), + ), + ).toBe('invalid'); + }); + + it('lets only the latest consecutive save intent focus', () => { + expect( + resolveResourceFocusIntent( + focusIntent({ flowId: 'flow-old', focusGeneration: 2 }), + focusEnvironment(), + ), + ).toBe('invalid'); + expect(resolveResourceFocusIntent(focusIntent(), focusEnvironment())).toBe( + 'focus', + ); + }); + + it('distinguishes projection, both layouts, filtering, DOM and one-shot completion', () => { + const intent = focusIntent(); + expect( + resolveResourceFocusIntent( + intent, + focusEnvironment({ projected: false }), + ), + ).toBe('wait-projection'); + expect( + resolveResourceFocusIntent( + intent, + focusEnvironment({ dependencyLayoutSettled: false }), + ), + ).toBe('wait-layout'); + expect( + resolveResourceFocusIntent( + intent, + focusEnvironment({ typePositioned: false }), + ), + ).toBe('wait-layout'); + expect( + resolveResourceFocusIntent(intent, focusEnvironment({ visible: false })), + ).toBe('hidden'); + expect( + resolveResourceFocusIntent( + intent, + focusEnvironment({ domRendered: false }), + ), + ).toBe('wait-dom'); + expect(resolveResourceFocusIntent(intent, focusEnvironment())).toBe( + 'focus', + ); + expect( + resolveResourceFocusIntent( + { ...intent, completed: true }, + focusEnvironment(), + ), + ).toBe('completed'); + }); + + it('does not implement the live completion path with reload or project reopen', () => { + const source = readFileSync( + new URL('../src/view/project-development/index.tsx', import.meta.url), + 'utf8', + ); + expect(source).not.toMatch(/(?:location|window\.location)\.reload\s*\(/u); + expect(source).not.toContain('open_local_game_project'); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/resourceDependencyGraphModel.test.ts b/apps/ai-game-creator-shell/tests/resourceDependencyGraphModel.test.ts index 9fdc7a1f7..985bbb7cd 100644 --- a/apps/ai-game-creator-shell/tests/resourceDependencyGraphModel.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceDependencyGraphModel.test.ts @@ -128,6 +128,48 @@ describe('resource dependency graph model', () => { ); }); + it('keeps a refined asset connected to its source through a real reference edge', () => { + const graph = normalizeProjectResourceGraph( + readModel({ + resourceIds: ['asset:source-art', 'asset:refined-art'], + referenceEdges: [ + { + id: 'asset-reference:source-refined', + kind: 'asset-reference', + sourceResourceId: 'asset:source-art', + targetResourceId: 'asset:refined-art', + cyclic: false, + }, + ], + connectionIndex: [ + { + resourceId: 'asset:refined-art', + upstreamReferenceResourceIds: ['asset:source-art'], + downstreamReferenceResourceIds: [], + referenceEdgeIds: ['asset-reference:source-refined'], + taskFlowIds: [], + }, + ], + dependencyDepths: [ + { resourceId: 'asset:source-art', dependencyDepth: 0 }, + { resourceId: 'asset:refined-art', dependencyDepth: 1 }, + ], + }), + ); + + expect(graph.referenceEdges).toEqual([ + expect.objectContaining({ + sourceResourceId: 'asset:source-art', + targetResourceId: 'asset:refined-art', + }), + ]); + expect(graph.dependencyDepthByResourceId.get('asset:refined-art')).toBe(1); + expect( + projectResourceGraphNeighbors(graph, 'asset:refined-art') + .upstreamResourceIds, + ).toEqual(new Set(['asset:source-art'])); + }); + it('fails closed only for producer-derived data when the audit tail is truncated', () => { const graph = normalizeProjectResourceGraph( readModel({ @@ -243,9 +285,7 @@ describe('resource dependency graph model', () => { expect(graph.referenceEdges.map((edge) => edge.id)).toEqual([ 'reference:spec-ui', ]); - expect(graph.unresolvedReferenceResourceIds).toEqual([ - 'external:missing', - ]); + expect(graph.unresolvedReferenceResourceIds).toEqual(['external:missing']); expect(graph.cyclicResourceIds).toEqual(new Set(['asset:ui'])); expect(graph.producerMappingTruncated).toBe(true); expect(projectResourceGraphNeighbors(graph, 'asset:ui')).toEqual({ diff --git a/apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts b/apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts index 1b4cf3e2c..0c98b33f3 100644 --- a/apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts +++ b/apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts @@ -139,6 +139,33 @@ describe('useProjectResourceCanvasLayout', () => { ); }); + it('can render an unpersisted fallback after graph failure without becoming settled', () => { + const invoke = vi.fn(); + window.__TAURI__ = { core: { invoke } }; + + const { result } = renderHook(() => + useProjectResourceCanvasLayout({ + projectPath, + projectId, + mode: 'dependency', + resources: [resource('resource-a')], + initializationReady: false, + renderFallbackWhileBlocked: true, + }), + ); + + expect(result.current.layout.positions).toEqual([ + expect.objectContaining({ + resourceId: 'resource-a', + section: 'document', + manuallyPlaced: false, + }), + ]); + expect(result.current.ready).toBe(false); + expect(result.current.settled).toBe(false); + expect(invoke).not.toHaveBeenCalled(); + }); + it('persists distinct automatic columns for dependency depths 0, 1, and 2', async () => { const updates: ProjectResourceCanvasPosition[][] = []; const invoke = vi.fn( @@ -333,11 +360,11 @@ describe('useProjectResourceCanvasLayout', () => { expect(result.current.saving).toBe(false); }); - it('uses the latest resource snapshot when the initial scope read resolves', async () => { + it('starts a new signed scope and ignores the old layout read when resources change', async () => { const resourceA = resource('resource-a'); const resourceB = resource('resource-b'); - let resolveRead: ((layout: ProjectResourceCanvasLayout) => void) | null = - null; + const resolveReads: Array<(layout: ProjectResourceCanvasLayout) => void> = + []; const updates: Array<{ expectedProjectId: string; expectedRevision: number; @@ -347,7 +374,7 @@ describe('useProjectResourceCanvasLayout', () => { async (command: string, args?: Record) => { if (command === 'read_local_project_resource_canvas_layout') { return await new Promise((resolve) => { - resolveRead = resolve; + resolveReads.push(resolve); }); } if (command === 'update_local_project_resource_canvas_layout') { @@ -380,11 +407,19 @@ describe('useProjectResourceCanvasLayout', () => { }), { initialProps: { resources: [resourceA] } }, ); - await waitFor(() => expect(resolveRead).not.toBeNull()); + await waitFor(() => expect(resolveReads).toHaveLength(1)); rerender({ resources: [resourceA, resourceB] }); + await waitFor(() => expect(resolveReads).toHaveLength(2)); await act(async () => { - resolveRead?.( + resolveReads[0]?.( + persistedLayout('dependency', 99, [position('resource-a', 900, 900)]), + ); + await Promise.resolve(); + }); + expect(updates).toHaveLength(0); + await act(async () => { + resolveReads[1]?.( persistedLayout('dependency', 1, [position('resource-a', 10, 20)]), ); await Promise.resolve(); @@ -405,10 +440,10 @@ describe('useProjectResourceCanvasLayout', () => { invoke.mock.calls.filter( ([command]) => command === 'read_local_project_resource_canvas_layout', ), - ).toHaveLength(1); + ).toHaveLength(2); }); - it('keeps an in-flight manual CAS alive and serializes resource sync behind its revision', async () => { + it('does not let an old in-flight layout CAS block or overwrite a new signed scope', async () => { const resourceA = resource('resource-a'); const resourceB = resource('resource-b'); const updates: Array<{ @@ -417,7 +452,7 @@ describe('useProjectResourceCanvasLayout', () => { }> = []; let resolveFirstUpdate: | ((result: { - status: 'updated'; + status: 'updated' | 'conflict'; layout: ProjectResourceCanvasLayout; }) => void) | null = null; @@ -474,29 +509,34 @@ describe('useProjectResourceCanvasLayout', () => { ), ).toBe(true), ); - expect(updates).toHaveLength(1); - - await act(async () => { - resolveFirstUpdate?.({ - status: 'updated', - layout: persistedLayout( - 'dependency', - 2, - structuredClone(updates[0]!.positions), - ), - }); - await Promise.resolve(); - }); - await waitFor(() => expect(updates).toHaveLength(2)); expect(updates.map(({ expectedRevision }) => expectedRevision)).toEqual([ - 1, 2, + 1, 1, ]); expect( updates[1]?.positions.some( ({ resourceId }) => resourceId === 'resource-b', ), ).toBe(true); + + await act(async () => { + resolveFirstUpdate?.({ + status: 'conflict', + layout: persistedLayout( + 'dependency', + 2, + structuredClone(updates[1]!.positions), + ), + }); + await Promise.resolve(); + }); + + expect(result.current.layout.positions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ resourceId: 'resource-a', x: 10, y: 20 }), + expect.objectContaining({ resourceId: 'resource-b' }), + ]), + ); }); it('coalesces repeated queued manual placements for the same resource behind an in-flight CAS', async () => { diff --git a/apps/desktop-shell/scripts/check-config.mjs b/apps/desktop-shell/scripts/check-config.mjs index 26ca9cc53..0765ae9cc 100644 --- a/apps/desktop-shell/scripts/check-config.mjs +++ b/apps/desktop-shell/scripts/check-config.mjs @@ -733,7 +733,14 @@ for (const snippet of [ 'header[3] === 0x46', '(stat.mode & 0o111) === 0', 'header.readUInt32BE(0)', + 'machMagic === 0xcafebabe', + 'machMagic === 0xbebafeca', + 'machMagic === 0xcafebabf', + 'machMagic === 0xbfbafeca', + 'machMagic === 0xfeedface', + 'machMagic === 0xcefaedfe', 'machMagic === 0xfeedfacf', + 'machMagic === 0xcffaedfe', 'header[0] !== 0x4d', 'header[1] !== 0x5a', "console.log('[check:native-shells] desktop-release-binary-artifact')", @@ -755,6 +762,15 @@ for (const snippet of [ "'desktop'", 'fs.copyFileSync(sourcePath, stagedPath)', 'fs.chmodSync(stagedPath, sourceMode & 0o777)', + 'header.readUInt32BE(0)', + 'machMagic === 0xcafebabe', + 'machMagic === 0xbebafeca', + 'machMagic === 0xcafebabf', + 'machMagic === 0xbfbafeca', + 'machMagic === 0xfeedface', + 'machMagic === 0xcefaedfe', + 'machMagic === 0xfeedfacf', + 'machMagic === 0xcffaedfe', "console.log(`[desktop-shell:stage-release-binary] ${stagedPath}`)", ]) { if (!stageReleaseBinarySource.includes(snippet)) { diff --git a/apps/desktop-shell/scripts/stage-release-binary.mjs b/apps/desktop-shell/scripts/stage-release-binary.mjs index 13ce5bac8..44cbc143a 100644 --- a/apps/desktop-shell/scripts/stage-release-binary.mjs +++ b/apps/desktop-shell/scripts/stage-release-binary.mjs @@ -49,9 +49,13 @@ function assertExecutable(filePath, label) { const machMagic = header.readUInt32BE(0); const isMachO = machMagic === 0xcafebabe || - machMagic === 0xcafed00d || + machMagic === 0xbebafeca || + machMagic === 0xcafebabf || + machMagic === 0xbfbafeca || machMagic === 0xfeedface || - machMagic === 0xfeedfacf; + machMagic === 0xcefaedfe || + machMagic === 0xfeedfacf || + machMagic === 0xcffaedfe; if (!isMachO || (stat.mode & 0o111) === 0) { throw new Error(`${label} must be an executable Mach-O file`); } diff --git a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md index 294d1372b..d0d30af4f 100644 --- a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md +++ b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md @@ -1,6 +1,6 @@ # AI 游戏创作项目开发工作台 PRD -更新时间:`2026-08-05`(冻结客户端素材创作无限画布阶段一合同) +更新时间:`2026-08-05`(实时 manifest、资源焦点状态机与客户端素材创作无限画布合同收口) ## 1. 产品定位 @@ -134,8 +134,9 @@ idle -> focused(document|art|audio|version) -> idle - 版本:只展示 manifest 中正式、不可变的迭代版本记录;版本卡展示项目修订、创建原因与父版本,聚焦态同时展示直接子版本和资源绑定。点击版本卡后高亮仍存在于当前资源投影中的引用资源;缺失历史资源只保留绑定身份,不生成幽灵资源卡。资源替换仍留给后续切片。 - 资源聚焦不提供通用工具栏或工具侧边栏;图片聚焦态允许一个明确的“精修资源”业务动作进入素材创作无限画布,该动作不是在聚焦容器中内嵌编辑器或恢复通用工具栏。 - 点击资源后,中央主视窗从 `resource-overview.list` 切换为 `resource-overview.focused.document / art / audio / version`,左侧平台导航、右侧 Supervisor 对话和底部 Agent 状态栏保持原位;聚焦容器只包含标题、资源主体、必要元数据与右上角收起按钮,不使用页面级浮层或可拖动标题栏。 -- 退出聚焦后恢复进入前的搜索条件、dependency / type 布局模式、资源画布滚动位置和选中资源;这些只属于当前前端会话,不写入布局 sidecar。 -- 资源管理阶段四至阶段七只交付上述受控读取、媒体展示、正式版本只读展示和引用高亮;`2026-08-05` 起,后续素材创作切片已冻结完整图片闭环,不再把图片导入、基础编辑、生成、导出或本地回写列为非目标。入口必须与草稿 CAS、正式事务提交、`referenceResourceIds` 血缘、新资源即时投影、布局和焦点竞态一次实现,不能只打开一个没有回写的画板。 +- 焦点转换以稳定资源 ID 为准。只有从资源列表进入详情或从一个资源 ID 切换到另一个 ID 时聚焦详情 region;同一资源 ID 因 manifest 更新而重新投影时,不得抢走详情内音频 / 视频控件、文档链接或收起按钮的当前焦点。 +- 显式收起或按 Escape 后恢复进入前的搜索条件、dependency / type 布局模式、资源画布滚动位置和选中资源,并优先把键盘焦点还给原触发资源卡;这些只属于当前前端会话,不写入布局 sidecar。若资源已经被后台删除,必须清理 stale focused / selected ID、关闭详情并把焦点落到“搜索项目资源”,不得落到 `body`。项目切换和进入运行视图必须取消旧项目的焦点恢复意图。 +- 资源管理阶段四至阶段七只交付上述受控读取、媒体展示、正式版本只读展示和引用高亮;`2026-08-05` 起,后续素材创作切片已冻结完整图片闭环,不再把图片导入、基础编辑、生成、导出或本地回写列为非目标。入口必须与草稿 CAS、正式事务提交、`referenceResourceIds` 血缘、新资源即时投影、两份布局和焦点竞态一次实现,不能只打开一个没有回写的画板。 ### 4.4 历史成果与当前状态 @@ -431,6 +432,8 @@ type ProjectAgentMudPointAttribution = { 7. 未识别任务产物不进入“项目版本”,只有正式版本 read model 可以生成版本卡;资源显示名称变化不改变资源身份。 8. 点击任一资源后只替换中央主视窗,右侧对话与底部 Agent 状态栏保持原位;收起或按 Escape 退出后恢复原搜索、布局模式、滚动位置、选中资源和触发资源卡键盘焦点。 9. 当前 Supervisor 运行期间 manifest 新增资产、任务状态、预览状态和正式版本后,工作台无需重开项目即可同步更新资源列表、依赖图输入、运行入口和版本卡;旧项目迟到回调不得覆盖当前项目。 +10. 实时 manifest 验收必须捕获真实 App Tauri listener,并让 `get_local_game_manifest` 在非 Supervisor Agent 的 Runtime / manifest 失效事件后返回新快照;测试不得直接调用 `onManifestChange` 冒充数据源。读取合并、旧项目迟到响应和项目切换隔离必须分别有回归证据。 +11. 音频或视频控件获得焦点后,同一资源 ID 的 manifest 更新不得把焦点移回详情 region;当前资源被删除后详情关闭、focused / selected ID 清理且焦点落到资源搜索框。显式收起与 Escape 的原卡片焦点和滚动恢复继续成立。 ### 7.2 P1 资源画布布局持久化验收 @@ -473,6 +476,8 @@ type ProjectAgentMudPointAttribution = { ### 7.6 素材创作无限画布阶段一验收 +实现状态(2026-08-05,阶段四):资源总览新增/图片精修入口、中央素材画布、取消恢复、正式 manifest/revision 实时合并、依赖图重建、dependency/type 双布局协调和三阶段自动定位已经接通。command/event 任意顺序按项目、commit、event 与 revision 去重;低 revision、旧 graph/layout 和失效 focus generation 均不能倒灌。真实 AI 生成仍按合同保持 mock 非目标。 + 1. 网站与 Tauri 实际 import 同一份 `@genarrative/image-canvas-core` 和 `@genarrative/image-canvas-react`,客户端没有复制的主站画布目录;宿主差异只位于 adapter。 2. “新增资源”和“精修资源”分别进入 create/refine 素材画布;精修保留原资产、创建新资产,并用规范 `referenceResourceIds` 登记直接血缘。 3. 草稿 schema、revision、容量、项目身份、OS 锁、CAS、恢复副本和媒体引用符合权威专题;损坏、未知 schema、身份错配和超限均失败关闭。 @@ -480,6 +485,7 @@ type ProjectAgentMudPointAttribution = { 5. 保存成功后不刷新、不重开项目即可进入 manifest 投影、依赖图、dependency/type 布局和允许时的选中定位;切项目、切中央状态、改选择或改搜索后的迟到结果不得抢焦点。 6. 搜索/筛选隐藏新资源时保留条件,明确提示“新资源已保存,当前筛选条件下不可见”,只通过显式动作清除条件并定位。 7. 新增、精修、生成、保存、取消、失败和恢复必须覆盖权威专题 §13 的完整验收矩阵;只完成画布 UI 或只完成本地写文件都不能算正式闭环。 +8. 自动定位必须分别证明资源已投影、dependency/type 两份布局都 settled 且存在目标位置、目标卡 DOM 已提交;搜索隐藏走显式清除/定位,任何 commit 最多自动聚焦一次。 ## 8. 非目标 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 20c550dcd..63bc7eac7 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -266,7 +266,7 @@ - 背景:项目开发工作台当前只在 React 会话内保存同分类资源的一维拖拽顺序,项目切换或客户端重启后重建默认排列;工作台 PRD 虽已给出二维位置字段,但缺少落盘路径、坐标系、Tauri API、CAS、异常与安全边界,仍不足以直接编码。 - 决策:dependency 与 type 两套布局分别保存为项目内 `.agent/workbench/resource-layouts/dependency.json` 和 `type.json`,统一使用 `game-creator-resource-layout.v1`。`x / y` 是 section 内容 CSS 像素,revision 从缺文件时的 `0` 单调递增;新资源首次默认放置,任何已有坐标不因排序、筛选、模式切换或 resize 被自动覆盖。type 默认布局固定按 `subtype -> mediaType -> label -> id` 排序,manifest 资产使用 `asset.kind`,任务产物、附件与 Agent 文本成果使用稳定 fallback,subtype 同时进入资源协调签名。 -- 并发与失败:Tauri 用 `read_local_project_resource_canvas_layout` 和 `update_local_project_resource_canvas_layout` 暴露读写,以 `projectId + mode + expectedRevision` 在专用跨窗口布局锁内做 CAS。更新额外携带只读结果中的 `expectedProjectId` 身份栅栏,路径被重建为新项目时旧窗口在锁副作用前失败;Rust 内部 revision 保留 `u64`,但共享 serde、Tauri 输入和前端 IPC 统一限制为 `0..=Number.MAX_SAFE_INTEGER`,达到上限时保持原文件。锁入口文件持久存在,Unix 以 `flock` 文件描述符、Windows 以不共享句柄持有互斥;应用不按 mtime / PID 猜测 stale、不删除锁文件,进程退出由操作系统释放。更新在创建锁目录前只读验证 manifest,锁内复核 projectId;无效根保持零 workbench 副作用。前端以 project/path/mode epoch 丢弃旧 scope 迟到响应,资源变化不得取消首读或同 scope 在途写;同 scope 的手动拖动与资源协调进入单写者 FIFO,后一笔只使用前一笔权威响应的 revision。切换 scope 会释放旧活动槽,旧请求即使卡死也不能阻塞新 scope;同资源尚未发送的连续拖动折叠为最后坐标,已经在途的 CAS 不取消。冲突返回最新完整布局且零写入,前端载入最新值、丢弃基于旧快照排队的手动拖动并要求重新操作;资源协调最多追加两次冲突重试,普通失败恢复最近可信布局。写入复用项目安全路径、链接校验、容量上限、恢复副本与原子替换,损坏或身份冲突不能被空布局覆盖。 +- 并发与失败:Tauri 用 `read_local_project_resource_canvas_layout` 和 `update_local_project_resource_canvas_layout` 暴露读写,以 `projectId + mode + expectedRevision` 在专用跨窗口布局锁内做 CAS。更新额外携带只读结果中的 `expectedProjectId` 身份栅栏,路径被重建为新项目时旧窗口在锁副作用前失败;Rust 内部 revision 保留 `u64`,但共享 serde、Tauri 输入和前端 IPC 统一限制为 `0..=Number.MAX_SAFE_INTEGER`,达到上限时保持原文件。锁入口文件持久存在,Unix 以 `flock` 文件描述符、Windows 以不共享句柄持有互斥;应用不按 mtime / PID 猜测 stale、不删除锁文件,进程退出由操作系统释放。更新在创建锁目录前只读验证 manifest,锁内复核 projectId;无效根保持零 workbench 副作用。2026-08-05 阶段四把完整资源输入签名加入 `projectPath + projectId + mode` scope identity:资源投影或可信依赖深度变化立即建立新 epoch,旧读写继续由后端 CAS 收束,但不占用新 scope 的前端单写者槽且迟到响应被丢弃;同一签名 scope 内的资源协调仍使用 FIFO 和前一笔权威 revision。冲突返回最新完整布局且零写入,资源协调最多追加两次冲突重试,普通失败恢复最近可信布局。写入复用项目安全路径、链接校验、容量上限、恢复副本与原子替换,损坏或身份冲突不能被空布局覆盖。 - 业务边界:布局是本地工作台 UI sidecar,不进入 manifest,不推进游戏项目 mutation revision,不使 Runtime verification 失效,不触发 Agent 权限,也不属于资产、Agent 产物、Git 或云端事实。本切片不包含关系线、资源替换、浮层位置、缩放 / 平移、搜索 / 筛选条件和当前 mode。 - 影响范围:`packages/shared` 与 Rust `shared-contracts` 的跨边界 DTO、AI 游戏创作 Tauri 项目持久层与命令、项目开发资源画布、定向 Rust / React 测试、工作台 PRD 和客户端实施计划。 - 验证方式:序列化与字段上限测试、缺文件 / 损坏 / 原子恢复 / 链接安全测试、同 revision 双写最多一个成功、两种 mode 跨重启独立恢复、新增资源不移动旧坐标、`1280×800` 横屏无页面级溢出,以及 `npm run agc:typecheck`、定向测试、`npm run check:encoding`、`git diff --check`。 @@ -6048,10 +6048,10 @@ ## 2026-07-31 External v1 生成统一异步并提供托管 MCP 与完整 Skill 包 -- 异步契约:External v1 的图片生成、图片编辑、图标图集、UI 素材提取、角色动画、视频、音效和背景音乐八类 POST 固定持久化入 `external_generation_job` 并返回 HTTP `202 + operationId/statusUrl/pollAfterMs`;不受站内 `GENARRATIVE_EXTERNAL_GENERATION_MODE=inline` 影响。每次逻辑生成必须携带稳定 `Idempotency-Key`,网络结果未知或调用方轮询超时时复用原键和原 operationId,不得换键重提。 -- 发布窗口兼容:AI 游戏创作桌面客户端严格按 HTTP 状态分流生成首响应;旧服务 `200` 只作为已经完成且含可下载媒体的同步结果消费,旧图集允许从顶层 `spritesheetImageSrc` 换签下载且无效值不得遮蔽可用 `objectKey`;新服务 `202` 必须取得 `operationId` 后轮询,轮询间隔按 OpenAPI 限制在 `250..=5000ms`,其他 2xx 失败关闭。Runtime 在 POST 前原子持久化精确请求体、请求 SHA-256 与稳定幂等键,`202` 后先原子追加 `operationId` 并回读一致再查询;重启时 `accepted` 账本只恢复 GET,`prepared` 表示提交结果未知并禁止自动 POST。生成 POST 使用独立三十五分钟等待预算且不自动重提;game-chat 的两次串行生成纳入父 run `4200` 秒软预算与从 root `bound_at` 起算的 `4500` 秒绝对硬截止,但截止时若 `canvas.asset_generate` 已进入 executing,客户端与预览照常退出,Runtime 保留 pending action、provider batch、生成账本与 `needs-reconciliation`。响应丢失、旧 `200` 结果损坏、`202` 缺 operationId、轮询超时、状态损坏、透明派生失败或外部完成后的本地提交失败统一投影为不可自动重生的对账边界。非阻断 general warning 继续消费结果并与 `sliceWarning` 分别展示。权威 External v1 OpenAPI 仍只声明新异步 `202`,不把部署过渡兼容公开成正式双协议。 +- 异步契约:External v1 的图片生成、图片编辑、图标图集、UI 素材提取、角色动画、视频、音效和背景音乐八类 POST 固定持久化入 `external_generation_job` 并返回 HTTP `202 + operationId/statusUrl/pollAfterMs`;不受站内 `GENARRATIVE_EXTERNAL_GENERATION_MODE=inline` 影响。每次逻辑生成必须携带稳定 `Idempotency-Key`;提交结果未知时复用原 endpoint、原始正文和原键恢复 POST,轮询超时时保留已有 `operationId` 并只继续 GET,不得换键重提。 +- 发布窗口兼容与幂等恢复:AI 游戏创作桌面客户端严格按 HTTP 状态分流生成首响应;旧服务 `200` 只作为已经完成且含可下载媒体的同步结果消费,旧图集允许从顶层 `spritesheetImageSrc` 换签下载且无效值不得遮蔽可用 `objectKey`;新服务 `202` 必须取得 `operationId` 后轮询,轮询间隔按 OpenAPI 限制在 `250..=5000ms`,其他 2xx 失败关闭。Runtime 在 POST 前原子持久化 endpoint、精确请求体字节、请求 SHA-256 与稳定幂等键,`202` 后先原子追加 `operationId` 并回读一致再查询;`accepted` 账本只恢复 GET,`prepared` 只允许在身份、配置指纹与请求 SHA 校验通过后使用账本中的原 endpoint、原始正文和同一键恢复 POST。该 POST 是 External v1 服务端幂等合同下的同一逻辑提交恢复,不放宽通用 ToolHost 未知副作用禁重放规则。恢复得到 `202` 后升级同一账本并继续 GET,旧 `200` 走兼容完成路径;再次 transport 失败仍保留同一账本。显式“继续/恢复”复用原 action、pending、provider batch 和生成账本身份,不创建 successor 请求或新键。生成 POST 使用独立三十五分钟等待预算;game-chat 的两次串行生成纳入父 run `4200` 秒软预算与从 root `bound_at` 起算的 `4500` 秒绝对硬截止,但截止时若 `canvas.asset_generate` 已进入 executing,客户端与预览照常退出,Runtime 保留 pending action、provider batch、生成账本与 `needs-reconciliation`。轮询超时保留 `accepted + operationId` 并在恢复时继续 GET;旧 `200` 结果损坏、`202` 缺 operationId、状态损坏、透明派生失败或外部完成后的本地提交失败仍进入对账边界。非阻断 general warning 继续消费结果并与 `sliceWarning` 分别展示。权威 External v1 OpenAPI 仍只声明新异步 `202`,不把部署过渡兼容公开成正式双协议。 - 查询与结果:新增 owner-safe `GET /api/external/v1/generations/{operationId}`。`queued/running` 返回 phase/progress,`completed` 返回 compact 稳定 artifact 引用,`failed` 返回脱敏错误,跨 owner 按不存在处理。compact result 允许 objectKey、resource/asset ID、assetObjectId、尺寸、媒体类型、taskId 和告警;禁止完整 project/canvas、Data URL、Blob URL、临时 signed URL、内部 provider 原文和 lease/fencing 控制字段。 -- 客户端 durable 查询约束:私有生成账本同时绑定 base URL/API Key 配置指纹,指纹不一致不查询旧 operation。旧 `200` 兼容结果只持久恢复允许字段和安全媒体引用。operation 明确 failed 的账本保留到 pending observation 和 Provider batch 终态落盘后再清理。生成提交只有契约明确的 `400 / 401 / 403` 可判定为入队前拒绝并清理 prepared 账本;其它非成功状态一律保留账本进入对账。账本路径解析、扫描和删除逐级拒绝符号链接,非法控制路径失败关闭。 +- 客户端 durable 查询约束:私有生成账本同时绑定 base URL/API Key 配置指纹,指纹不一致不恢复 POST 或查询旧 operation。旧 `200` 兼容结果只持久恢复允许字段和安全媒体引用。operation 明确 failed 的账本保留到 pending observation 和 Provider batch 终态落盘后再清理。只有首次提交直接取得契约明确的 `400 / 401 / 403` 才可判定为入队前拒绝并清理 prepared 账本;首次结果已经未知后,恢复 POST 的临时 `401 / 403` 等响应不能证明原请求未入队,不得删除账本。其它非成功状态一律保留账本进入对账。账本路径解析、扫描和删除逐级拒绝符号链接,非法控制路径失败关闭。 - MCP:新增托管 `/api/external/v1/mcp`,使用现有 External API Key Bearer 鉴权和无协议 session 的 Streamable HTTP JSON direct 模式。MCP tools 从同一 OpenAPI operation 形成并复用 External REST router;生成 tool 显式要求 `idempotencyKey`,另有统一任务查询 tool。MCP resources 提供使用说明、OpenAPI、Skill 入口 `SKILL.md` 和 `references/capability-routing.md`、`references/api-operations.md`、`references/authentication-and-safety.md`、`references/requests-and-outputs.md` 四篇稳定 reference;日后新增 reference 时必须同步新增独立 resource。MCP Agent 直接调用托管 tools,不安装 CLI,也不将脚本、测试或 workflow 暴露为 MCP resources。禁止开放内部 SpacetimeDB MCP、worker procedure、controller 或队列控制面。 - Agent 发现:新增公开 `agent-integration.json`、`skill/SKILL.md` 和 `skill.zip`。manifest 同时声明 MCP、OpenAPI、完整 Skill archive、SHA-256 和包内清单;archive 必须包含 `SKILL.md`、上述四篇 references、stdlib Python helper 和 `agents/openai.yaml` 七个声明文件,不能只提供 OpenAPI JSON,也不能包含 API Key、本机路径或个人配置。完整 `skill.zip` 只供不支持 MCP 或需要本地文件上传编排的 Agent 使用,不作为 MCP resource。 - 兼容边界:这是基于「截至 2026-07-31 尚无外部第三方存量调用方」接受的 v1 原地 breaking change;一旦出现外部活跃 Key、公开契约或联调方,后续破坏性变更必须保留兼容、经过弃用期或升级 `/api/external/v2`。 @@ -6063,6 +6063,13 @@ - UI 决策:Project Supervisor 持有运行中 manifest 状态并向外层启动器同步完整快照;外层项目上下文继续是工作台投影的唯一输入,只接受当前项目路径的更新,不另建资产、任务或版本平行状态。 - 依赖图决策:Agent DB 尾部读取一旦截断,审计 producer、task flow 与对应 `cyclicTaskIds` 失败关闭;独立 `dependencyDepths` 仍由 Rust 从当前 manifest、精确资源引用和仍可信的任务深度下限构建,前端只做资源存在性与非负安全整数校验后继续消费。manifest 精确资源引用、reference connection index、资源环和 unresolved reference 与审计生产者证据分离。SVG 保持装饰性,辅助技术消费画布关联的文本关系列表。 +## 2026-08-05 AI 游戏项目实时 manifest 失效与资源焦点状态机 + +- 失效源决策:后台 `task.update`、`canvas.asset_generate`、任务起止 / 终态投影、正式版本追加和 autonomous manifest reset 都处于 Runtime 动作或生命周期内,并在写入后回到共用 Runtime emitter;因此以该 emitter 作为统一 manifest 失效因果点,不在 WorkspaceLauncher 新增平行回调,也不轮询 manifest。Rust / TypeScript 的 `game-creator-agent-runtime-update` 合同增加 `manifestInvalidated`,App 在全部 Supervisor、selected agent、session / run early return 之前消费它。 +- 跨进程决策:External Runner 没有 GUI `AppHandle`,不能假设普通 Tauri Runtime event 会跨进程到达。Runner IPC 协议升级为 v5,GUI owner attach 同时登记 GUI 创建的 loopback 随机端口和 64 位随机十六进制令牌;Runner 内同一 Runtime emitter 发送最小 `projectPath + agentId` relay,GUI 校验令牌后转成 `game-creator-manifest-invalidated`。GUI 内 Runtime 继续直接发送完整 Runtime update。两条路径汇合到同一个 App manifest 重读器。 +- 重读决策:`get_local_game_manifest` 按项目 single-flight;同项目读取中再次失效只排队一轮后续读取,不启动并发请求。响应应用必须同时匹配 mounted、活动项目路径和 project scope version,旧项目、旧 scope 或卸载后的响应全部丢弃。重读后的 App state 继续沿既有 `onManifestChange -> currentProjectContext -> ProjectDevelopmentView` 单向投影,不复制资产 / 任务 / 版本状态。 +- 焦点决策:资源详情焦点以稳定 `resourceId` 的转换而非重建后的资源对象决定。`null -> id` 和 `idA -> idB` 聚焦详情;`idA -> idA` 保留详情内部 active element。显式收起 / Escape 恢复滚动并优先返回触发卡片;资源已删除时清理 focused / matching selected ID 并聚焦资源搜索框;项目或运行视图切换清除旧 trigger 与 restore 标志,禁止跨项目恢复。 + ## 2026-08-04 图片画布素材类型采用资源默认值与布局覆盖双层模型 - 背景:画布复制逻辑曾为副本生成 `local-resource-copy-*`,导致同一媒体被伪装成未登记资源;随后改为复用 `resourceId`,但手动修改图层标签仍通过“按新 `assetKind` 查找 / 创建项目资源并换绑当前图层”实现。这会让单纯标签修改增加资源行、漂移 `resourceId`,并在异步回填与复制交错时形成“新类型 + 旧资源”的副本。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 3cc03f918..6ea774b4a 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -3829,6 +3829,7 @@ - Provider action 安全持久化补充:pending / provider action 的泄密检测不能因裸自然语言短语 `api key` 直接拒绝,否则 `agent.delegate` 中“不要暴露 External Editor API Key”等安全约束会被误报并阻断首批协作。赋值形式只允许完整匹配受控的“未配置 / 不可用 / 禁止读取”等状态或固定无密钥降级说明,不能用 `starts_with` 放行 `none-but-secret`、`not configured; actual value ...` 等安全前缀后的凭据;`**API Key**:`、`` `API Key`: ``、`API Key(生产):` 等装饰或限定标签也必须识别为赋值。结构化字段标记 `apiKey / api_key`、`Authorization / Cookie`、`token / Bearer` 以及已知 secret token 形状仍必须检测并失败关闭。 - Windows retry 扫描补充:`Path::strip_prefix(root)` 在 Windows 上得到的相对 `Path` 转字符串后使用反斜杠,不能直接传给只接受 portable `/` 的 Runtime JSON sidecar 读取器;否则 Runner 重启或显式 `--agent-resume` 扫描已到期 retry 时会报“项目文件路径不能包含反斜杠”,任务持续停在 `waiting-for-provider-retry`。目录扫描应按路径组件重组成 `/` 分隔的 UTF-8 相对路径,不要放宽全局路径校验。 - 恢复交互:`needs-reconciliation` 即使没有 `pendingToolAction`,也必须提供显式“已核对,结束旧任务”;它只取消旧 run,不直接 retry。若取消后仍有 pending task,由 Runner 自动继续;只有队列为空且旧 run 已取消时,才允许创建新的 retry run,避免重复执行同一用户输入。自主构建 Supervisor 的 retry 不能改写为普通 `agent-background-task` source,必须从已验证的原 Run Profile 绑定恢复 `project-supervisor-gui / project-supervisor-cli` 可信来源;不得只信可追加的 task journal。 +- Steer source:前端选择可 steer Runtime 时不能只比较 Agent、Session 和 Run Profile,还必须在调用方声明 source 时精确比较持久 `source`。例如 game-chat 只能 steer `project-supervisor-game-chat`,不能把同 Session/Profile 的 `project-supervisor-cli` run 当成目标;source 不一致时应按当前入口新建或排队自己的 run,不能先调用后端再把“steer source 与当前 Run 不一致”暴露给用户。 - 验证:前端回归同时覆盖零历史、无 Session 的初始空态、无 active Session 索引但存在持久 `needs-reconciliation` 总控 Runtime 的恢复展示,以及“先取消、队列为空后才重试”;真实 Windows 运行全部 tool-plan handoff 测试,确保相对句柄 rename、覆盖安装、回读和清理均通过。Responses 回归覆盖 system / user / assistant 文本分别序列化,并保留 user `input_text + input_image`;Runtime 回归覆盖“无效计划 → repair transport 等待 → steer → 新 cursor 再修复”,断言 cursor `0 / 1` 各有一条审计且不冲突。 ## 固定画布产物返工不能变成任意覆盖,design-foundation 不能越权修程序 @@ -4108,10 +4109,11 @@ - 现象:生成提交发生客户端超时、连接中断或响应丢失后,调用方创建新的 `Idempotency-Key` 再提交一次;原任务其实已经入队,最终造成重复生成、重复扣费和重复画布 / 素材库写入。 - 原因:把“客户端没有收到结果”误判为“服务端没有受理”,又没有持久保留逻辑请求的幂等键和服务端返回的 `operationId`。托管 MCP 若绕过 External REST router 直接调用 worker 或 SpacetimeDB,也会形成第二套去重与状态语义。 -- 处理:一次逻辑生成只分配一个稳定幂等键。桌面 Runtime 在 POST 前先把精确请求体、SHA-256 和幂等键原子写入私有生成账本并回读一致;收到 `202 + operationId` 后先把账本升级为 `accepted` 再轮询。重启时 `accepted` 只恢复 GET,`prepared`、响应丢失、`202` 缺 operationId、轮询超时和状态损坏都进入 `needs-reconciliation`,绝不自动 POST。game-chat 的 4500 秒硬截止可以结束本轮、关闭预览和客户端,但 executing 的 `canvas.asset_generate` 必须保留 pending action、provider batch 与生成账本;旧 `200` 图集的 `spritesheetResource` 允许为空,此时只在顶层 `spritesheetImageSrc` 是有效下载引用时优先使用,否则回退可用 `objectKey`。`postprocess-failed-source-preserved` 进入不可自动重生的对账边界;其它 non-blocking warning 继续消费成功结果并单独展示。旧 `200` 兼容不改变权威 External v1 的异步契约。MCP 生成工具必须把 `idempotencyKey` 映射到同一 REST header,并复用同一 External router、owner 和任务账本。 +- 处理:一次逻辑生成只分配一个稳定幂等键。桌面 Runtime 在 POST 前先把 endpoint、精确请求体字节、SHA-256 和幂等键原子写入私有生成账本并回读一致;收到 `202 + operationId` 后先把账本升级为 `accepted` 再轮询。`accepted` 只恢复 GET;`prepared` 或提交响应丢失时,只允许校验账本身份、配置指纹和请求 SHA 后,以账本保存的原 endpoint、原始正文与同一键恢复同一逻辑 POST,不得重建画布上下文、重组正文或换键。恢复 `202` 后继续 GET,恢复再次 transport 失败仍保留原账本;轮询超时只保留既有 operation 并恢复 GET。game-chat 的 4500 秒硬截止可以结束本轮、关闭预览和客户端,但 executing 的 `canvas.asset_generate` 必须保留 pending action、provider batch 与生成账本;旧 `200` 图集的 `spritesheetResource` 允许为空,此时只在顶层 `spritesheetImageSrc` 是有效下载引用时优先使用,否则回退可用 `objectKey`。`202` 缺 operationId、状态损坏与 `postprocess-failed-source-preserved` 仍进入对账边界;其它 non-blocking warning 继续消费成功结果并单独展示。旧 `200` 兼容不改变权威 External v1 的异步契约。MCP 生成工具必须把 `idempotencyKey` 映射到同一 REST header,并复用同一 External router、owner 和任务账本。这是 External v1 的专用幂等恢复,不是通用副作用自动重放。 - 补充:不能把“accepted 分支里没有生成 POST”误当成 GET-only 恢复。若读取账本前仍重做项目/素材目录准备、输出路径预检或请求正文构造,恢复仍可能创建远端资源或在查询 operation 前失败。恢复必须直接使用 durable snapshot;清理必须最后删除 pending 身份锚点,活动 orphan 不得自动删除。完整恢复 future 还要在默认 Tokio worker 栈下验证,不能靠测试环境调大 `RUST_MIN_STACK` 掩盖栈溢出。 -- 加固:durable snapshot 必须绑定不含明文凭据的 base URL/API Key 配置指纹,配置漂移时连 GET 也必须阻断。accepted operation 明确 failed 也不能在 observation 持久化前删账本。旧 `200` durable result 只保留允许字段与安全 objectKey/相对路径,签名 URL、query/fragment 和未知字段不落盘。提交只有契约明确的 `400 / 401 / 403` 可证明未入队并清理 prepared 账本;超时、冲突、限流、网关错误及其它意外状态均保留账本进入对账。账本根目录、扫描和删除必须通过受控路径解析逐级拒绝符号链接,不能让项目内链接把清理目标指向项目外。 -- 验证:覆盖“服务端已入队但提交响应丢失”后原键重试仍返回同一 operation、换 owner 不可见、查询最终只出现一份 completed result 和一次计费 / 写回;MCP 与 REST 对同一 owner、同一请求和同一键必须命中同一 operation。 +- 加固:durable snapshot 必须绑定不含明文凭据的 base URL/API Key 配置指纹,配置漂移时恢复 POST 和 GET 都必须阻断。accepted operation 明确 failed 也不能在 observation 持久化前删账本。旧 `200` durable result 只保留允许字段与安全 objectKey/相对路径,签名 URL、query/fragment 和未知字段不落盘。只有首次提交直接返回契约明确的 `400 / 401 / 403` 才可证明未入队并清理 prepared 账本;首次结果已经未知后,恢复请求的临时鉴权错误、超时、冲突、限流、网关错误及其它意外状态均保留同一账本。账本根目录、扫描和删除必须通过受控路径解析逐级拒绝符号链接,不能让项目内链接把清理目标指向项目外。 +- 代理 DNS:Clash 等透明代理可能把公网对象存储域名解析到 RFC 2544 的 `198.18.0.0/15` fake-IP。下载器只对已通过鉴权 `objectKey` 或受控 legacy path 换签得到的 URL 接受“全部地址均位于该 benchmark 段”的窄例外;直接 URL、其它本机/私网地址、公私混合解析和重定向仍必须失败关闭,不能为了兼容代理整体移除 SSRF 校验。 +- 验证:覆盖“服务端已入队但提交响应丢失”后两次 POST 的 endpoint、正文 bytes 与 `Idempotency-Key` 完全相同,原键重试仍返回同一 operation,最终只出现一份 completed result 和一次计费 / 写回;恢复再次 transport 失败或临时鉴权失败仍保留同一账本;换 owner 不可见;MCP 与 REST 对同一 owner、同一请求和同一键必须命中同一 operation。 - 关联:`server-rs/crates/api-server/src/external_generation.rs`、`server-rs/crates/api-server/src/external_mcp.rs`、`docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md`。 ## api-server 嵌入仓库外资源时必须同步容器构建上下文(2026-07-31) @@ -4147,6 +4149,34 @@ - 处理:调用方在未认证时不得启动受保护的钱包刷新;可取消的读取要为每轮分配 `AbortController`,新读取先失效并中止旧读取,组件卸载时同时推进 revision、abort 当前请求并清空句柄。所有 `then / catch / finally` 在更新状态前都要检查 signal 与 revision。 - 验证:定向测试覆盖卸载后请求 signal 已中止;同时复跑触发钱包刷新回调的画布生成集成测试和完整前端测试,不能以单文件偶然快速收束代替全量验证。 +## 下游 manifest 回调测试不能冒充实时数据源(2026-08-05) + +- 现象:工作台的资源、任务与版本重投影单测保持绿色,但后台 Agent 已更新 `.agent/manifest.json` 后,打开中的工作台仍长期显示旧快照,只有重开项目才更新。 +- 原因:测试 Supervisor 直接调用 `onManifestChange`,只证明 `App manifest -> WorkspaceLauncher -> ProjectDevelopmentView` 的下游桥接;真实 Runtime event 没有失效字段,监听器也没有重读 manifest。External Runner 又与 GUI 分属不同进程,Runner 内无法使用 GUI `AppHandle`,只补普通 Tauri event 仍不能形成生产链路。 +- 处理:后台 manifest mutation 收敛到共用 Runtime emitter;GUI 内进程用带 `manifestInvalidated` 的 Runtime update,External Runner 通过 GUI owner attach 登记的受令牌保护 loopback sink 转发专用失效事件。App 对当前项目做 single-flight manifest 重读,并以 mounted、项目路径和 scope version 丢弃迟到结果;WorkspaceLauncher 继续只消费完整 manifest 快照,不新增平行状态或轮询。 +- 验证:集成测试必须渲染真实 `App + WorkspaceLauncher`、捕获真实 Tauri listener,让 `get_local_game_manifest` 从旧快照切换到新快照,并由非 Supervisor Agent 事件驱动资产、completed 任务、运行入口和版本卡出现;另测项目切换时旧请求迟到。旧的直接 `onManifestChange` 测试只能标记为下游桥接证据。 + +## React 资源详情焦点不能依赖重建对象身份(2026-08-05) + +- 现象:音频 / 视频播放器、文档链接或收起按钮正在获得焦点时,后台 manifest 更新会把焦点突然移回详情 region;若当前资源被删除,详情虽然消失,stale focused ID 和焦点可能残留到 `body`。 +- 原因:资源投影每次生成新对象,`useLayoutEffect([focusedResource])` 把同一资源的内容更新误判为重新进入详情;删除路径没有显式恢复状态和可聚焦 fallback,项目 / 运行视图切换也可能沿用旧 trigger。 +- 处理:焦点状态机只比较稳定 `resourceId`:`null -> id` 与 `idA -> idB` 聚焦详情,`idA -> idA` 保持当前 active element。显式收起 / Escape 才恢复原卡片与滚动;后台删除清理 focused / matching selected ID 并聚焦搜索框;项目或运行视图切换清空 trigger / restore。媒体预览副作用依赖稳定 ID、路径和类别,不因同 ID 对象重建先卸载控件。 +- 验证:媒体控件获得焦点后用同 ID 新 manifest 重渲染并断言 active element 不变;删除资源后断言详情关闭、选中清理且搜索框获得焦点;既有收起、Escape、项目切换和运行切换测试继续通过。 + +## manifest 与 revision 必须作为同一一致快照发布(2026-08-05) + +- 现象:旧 manifest 的 React effect 在正式素材提交后才读取项目 revision,可能把“旧内容 + 新 revision”发给父级;若它先到,真正的 commit manifest 会被误判为同 revision 分叉并失败关闭。 +- 原因:manifest 和 mutation revision 分开读取,却把其中任意时刻的两个值拼成一个权威快照;单独比较 callback 到达顺序无法修复这种身份错配。 +- 处理:普通 Supervisor 投影固定执行“revision 前读 -> manifest -> revision 后读”,两次 revision 相同才发布,漂移时有界重试。素材 command/event 直接使用事务返回的完整 manifest 与对应 revision。父级按 `projectPath + projectId` 单调接受更高 revision,同 revision 只允许内容一致的重复,低 revision 和分叉都不覆盖。 +- 验证:分别覆盖 command/event 两种先后、成功后旧轮询和同 revision 不同 manifest;不能只用 eventId 去重而跳过 revision 防倒灌。 + +## 新资源自动聚焦不能把投影、布局和 DOM 当成同一时刻(2026-08-05) + +- 现象:保存回调已经带回 manifest,但新卡片可能尚无 dependency/type 坐标或尚未提交 DOM;立即选择会得到空画布、错误滚动,迟到回调还会抢走用户后来选择的资源。 +- 原因:把 durable commit、资源投影、关系图 ready、两份布局协调和 React DOM commit 压成一个“保存成功”布尔值,缺少保存尝试身份和用户意图 generation。 +- 处理:保存开始记录 `saveAttemptId + sessionId + draftId + commitId + focusGeneration`。自动定位依次等待资源投影存在、dependency/type 两份布局 settled 且都有位置、搜索条件可见和稳定 `data-resource-id` DOM 存在;按 commitId 只执行一次。切项目、切 mode、改选择/搜索、取消或开始新 flow 都推进 generation;迟到结果仍可合并权威 manifest,但不能改变选择。隐藏时保留搜索,只由显式“清除搜索并定位”建立新 generation。 +- 验证:覆盖 manifest 已更新但布局未完成、DOM 后只聚焦一次、搜索隐藏、保存中切项目/改选择和连续保存;测试不得用 reload 或重开项目绕过阶段边界。 + ## 不要用自然语言精确 `.replace()` 维护 Runtime Prompt - 现象:Prompt 文案稍作改写、增删空格或调整段落后,替换静默失效,代码中出现难以审阅的链式 `.replace()`。 @@ -4160,3 +4190,17 @@ - 风险:Provider 工具约束不是本地安全边界;特别是 `writes + readOnlyHint=true` 自动放行的工具,schema 外字段可能改变外部副作用而不进入预期确认路径。 - 处理:使用完整 JSON Schema validator 校验原始 catalog schema,不手写 required/type 子集;native parser、fingerprint enrichment 与实际 MCP 调用边界复用同一校验器。enrichment 错误必须映射回 classified `arguments-schema` repair,不能以普通字符串直接终止 run;执行点重验用于阻断升级前已经落盘的 schema 外 pending。关闭网络和文件 `$ref` 解析,schema 无法安全编译时不广告或不执行。`serde` 类型错误会包含实际字符串值,catalog miss 也会包含模型提交的 server/tool,因此这两类错误同样只能返回稳定类别,不能拼接原始错误、参数值或 schema 内容。 - 验证:覆盖 required、additionalProperties、type、enum、本地 `$defs/$ref`、HTTP/file 外部引用、无效 schema、错误脱敏,证明 legacy wrapper 在注入 fingerprint 前进入 repair,并证明带旧有效 fingerprint 的历史 pending 在实际调用前仍被 schema 拒绝。 + +## macOS 安全路径测试必须使用规范化临时目录(2026-08-05) + +- 现象:调用仓库上下文、Runtime context bundle 或 pending recovery 的 Rust 测试在 macOS 报“Repository root and its ancestors must not be symbolic links”,Linux CI 却可能通过;本地 HTTP 恢复夹具在完整串行测试中还可能偶发 `WouldBlock`。 +- 原因:`tempfile::tempdir()` 默认返回 `/var/folders/...`,而 macOS 的 `/var` 是指向 `/private/var` 的符号链接,生产安全校验会按设计拒绝该祖先;恢复测试的服务端读超时若仅为 2 秒,也会与完整测试负载下约 2 秒的首次请求形成窄竞态。 +- 处理:凡测试会进入仓库可信路径校验,统一使用 `crate::tests::canonical_test_tempdir(...)`,不得削弱生产符号链接拒绝规则;loopback 夹具保留有界超时,但为完整 CI 负载留足稳定裕量。 +- 验证:在 macOS 上定向运行 provider request、pending recovery、autonomous continuation 与 generation recovery 用例,再运行完整 `npm run check:native-shells`。 + +## Mach-O 文件头校验必须覆盖反字节序魔数(2026-08-05) + +- 现象:macOS arm64 的 Tauri release 已成功构建且 `file` 明确认定为 Mach-O,产物 staging 仍报“must be an executable Mach-O file”。 +- 原因:脚本用 `Buffer.readUInt32BE(0)` 读取文件头,却只比较 `0xfeedfacf` 等正序数值;arm64 常见头字节是 `cf fa ed fe`,读取结果为 `0xcffaedfe`。 +- 处理:文件头白名单同时覆盖 32/64 位与 fat Mach-O 的正序和反字节序合法魔数,并由桌面配置门禁同时反查 staging 脚本和根级产物检查,不能改成只按扩展名或构建退出码判断。 +- 验证:在 macOS 上构建真实 desktop-shell release,运行 `npm run desktop-shell:stage-release-binary`,再由 `npm run check:native-shells` 校验 staged 产物。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index b400889b5..50651e3f3 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -263,9 +263,9 @@ Agent Runtime 负责: - 2026-07-10 补充:后台任务工具箱已加入 `project.restore`。Agent 可在 diff 或自检发现本轮修改走偏后请求恢复到指定 checkpoint;Runtime 复用 `project.restore` 权限策略和项目写锁,observation 只返回 checkpoint id、恢复文件数和删除文件数,不返回本机绝对路径。默认确认策略下不会静默回滚用户项目。 - 2026-07-10 补充:单 Agent 聊天和后台 planning prompt 会读取同一个 Agent 的 Runtime 连续上下文,把本 Agent 最近 status / phase / runId / 当前任务 / 下一步、最近回复、计划、观察、最近 3 条工具动作、最近事件、最近 3 条任务记录和工具策略摘要带入下一轮推理;上下文按规范 taskId 隔离,不读取其他 Agent 的 runtime 文件,并在进入 prompt 前过滤密钥和本机绝对路径。新后台 run 启动时会继承本 Agent 上次 `recentToolCalls` 和 `lastResponse`,让多轮任务不丢失结构化行动证据。 - 2026-07-10 补充:后台任务工具箱已加入 `preview.start`。Agent 可在 loop 中自行请求启动当前项目的本地 HTTP 预览;Runtime 会复用 `preview.start` 策略、项目写锁、共享 `PreviewRegistry`、manifest 预览状态、`.agent/logs/preview.log` 和 run trace 追加逻辑,并把 `agent.runtime.preview.start` 写入 `.agent/agent.db`。该 observation 只向 LLM 返回 localhost URL 与端口,不返回用户项目绝对路径。 -- 2026-07-10 补充,2026-07-31 收紧,2026-08-03 增加发布窗口兼容与 durable 生成账本:后台任务工具箱提供 `canvas.asset_generate`。Agent 在 loop 中给出素材 prompt、`outputPath`、比例、尺寸、kind 与展示名;Runtime 通过 AppData / Tauri 配置里的 `editorApi` 调用 External Editor API。生成前按本地项目名称创建或复用同名画布项目和同名素材库目录,请求必须携带 `projectId + assetFolderId + canvasCompletion`,生成结果同时进入平台画布、素材库和本地项目。canonical 视觉 DAG 固定为:`art-director` 通过 `POST /api/external/v1/editor/images/generations` + `kind=spec` 生成 `assets/art-spec.png`;`design-foundation` 精确引用该 resourceId,通过同一路由 + `kind=ui-design` 生成 `assets/ui-prototype.png`;`art-asset-plan` 使用同一 resourceId 和具体 `iconDescriptions`,通过 `POST /api/external/v1/editor/icon-spritesheets/generations` 生成真实透明的 `assets/art-spritesheet.png`。Runtime 在 POST 前把精确请求体、SHA-256 与稳定 `Idempotency-Key` 原子写入 `.agent/runtime/canvas-generation-requests/` 私有账本并回读一致;正式新契约收到 HTTP `202` 后先原子追加 `operationId`,再按限制到 `250..=5000ms` 的 `pollAfterMs` 查询统一状态端点。重启时 `accepted` 账本只恢复 GET,`prepared` 代表提交结果未知并进入人工对账,绝不自动 POST。桌面客户端在滚动发布窗口内仍按 HTTP 状态兼容旧同步 `200` 完整结果;旧图集只在 `spritesheetImageSrc` 是有效下载引用时优先使用,否则回退 `objectKey`。生成 POST 使用独立三十五分钟等待预算;game-chat 的两次串行生成纳入父 run `4200` 秒软预算与 `4500` 秒总截止。截止时普通本地动作按失败清理;若 `canvas.asset_generate` 已进入 executing,则结束本轮并关闭预览和客户端,但保留 pending action、provider batch、生成账本与 `needs-reconciliation`。旧 `200` 结果损坏、`202` 缺 operationId、响应丢失、轮询超时、状态损坏、透明派生失败或外部完成后的本地提交失败均进入不可自动重生的对账边界。`postprocess-failed-source-preserved` 不得登记为透明图集或自动重试;其它 general warning 保持 completed 并与 `sliceWarning` 分别展示。本地 manifest 只在生成完成后持久化 generation route、kind、服务端 taskId 与精确参考 resourceId。UI extraction 只处理已有带标注 UI 图,不属于这条 DAG;图集不得回退到普通生图。UI 原型 prompt、`generationInputs.artSpec` 和 `ui-prototype.v2` 验收必须从当前项目玩法合同提取 HUD、可玩区域、关键实体、操作、失败/重开与移动布局,禁止预设塔防或补入合同中不存在的卡牌、波次、敌人入口。canonical UI 原型固定请求 `2K + 16:9`。旧正式图不合格时,普通原合同只能返回 `needs-repair`;Supervisor 认领后仅可签发一次完整继承原合同的 repair,由原 owner 使用 `replaceExisting=true` 原位替换,禁止先删除正式图。API Key 不进入项目文件;幂等键只进入受权限约束的私有生成账本,不进入 observation、manifest、agent.db 或日志。 -- 2026-08-03 durable 恢复补充:`accepted / legacy-completed` 恢复必须先从私有账本读取持久化的画布 ID、素材目录 ID、画布名、生成提示词、route、kind 与引用资源,再查询既有 operation;不得在读取账本前重建请求、重新列举或创建远端项目/目录,也不得让本地输出路径漂移挡住 operation GET。恢复执行和后续 continuation 使用独立 Tokio task 栈边界,同时继续持有原 Agent lock。终态清理固定先删 generation / parallel 附属 sidecar,最后删 pending 身份锚点;历史孤儿只有所属任务已明确 completed/cancelled 时可自动清理,活动、未知或 `needs-reconciliation` orphan 必须保留并失败关闭。 -- 2026-08-03 durable 恢复加固:生成账本还必须绑定归一化 base URL 与 API Key 哈希组成的配置指纹,当前 External Editor 服务或租户身份变更时禁止查询旧 operation。旧同步 `200` 结果只持久恢复必需的允许字段;绝对 signed URL、query/fragment 和未知扩展字段不得进入项目账本,只有安全相对路径或 objectKey 可作为 durable 下载引用。accepted operation 明确 failed 时也保留账本,直到 pending observation 和 Provider batch 成员终态持久化后再按统一清理链删除。生成提交只有契约明确的 `400 / 401 / 403` 可视为入队前拒绝并清理 prepared 账本;其它非成功状态保留账本进入对账。生成账本根目录、扫描与删除使用受控路径解析逐级拒绝符号链接,非法控制路径失败关闭。独立恢复任务异常必须落盘 task queue、state、event 和 agent.db 对账阻断,公共记录不得复制未脱敏 panic payload。 +- 2026-07-10 补充,2026-07-31 收紧,2026-08-03 增加发布窗口兼容与 durable 生成账本,2026-08-04 对齐 External v1 幂等恢复:后台任务工具箱提供 `canvas.asset_generate`。Agent 在 loop 中给出素材 prompt、`outputPath`、比例、尺寸、kind 与展示名;Runtime 通过 AppData / Tauri 配置里的 `editorApi` 调用 External Editor API。生成前按本地项目名称创建或复用同名画布项目和同名素材库目录,请求必须携带 `projectId + assetFolderId + canvasCompletion`,生成结果同时进入平台画布、素材库和本地项目。canonical 视觉 DAG 固定为:`art-director` 通过 `POST /api/external/v1/editor/images/generations` + `kind=spec` 生成 `assets/art-spec.png`;`design-foundation` 精确引用该 resourceId,通过同一路由 + `kind=ui-design` 生成 `assets/ui-prototype.png`;`art-asset-plan` 使用同一 resourceId 和具体 `iconDescriptions`,通过 `POST /api/external/v1/editor/icon-spritesheets/generations` 生成真实透明的 `assets/art-spritesheet.png`。Runtime 在 POST 前把 endpoint、精确请求体、SHA-256 与稳定 `Idempotency-Key` 原子写入 `.agent/runtime/canvas-generation-requests/` 私有账本并回读一致;正式新契约收到 HTTP `202` 后先原子追加 `operationId`,再按限制到 `250..=5000ms` 的 `pollAfterMs` 查询统一状态端点。重启时 `accepted` 账本只恢复 GET;`prepared` 表示首次提交结果未知,只允许在账本身份、配置指纹和请求 SHA 校验通过后,使用账本保存的原 endpoint、原始 JSON 字节与同一 `Idempotency-Key` 恢复 POST,不得重建远端项目/目录、重组正文或分配新键。恢复收到 `202` 后升级同一账本为 `accepted` 并继续 GET,旧 `200` 仍走既有兼容完成路径;恢复再次 transport 失败或返回不能证明首次请求未入队的响应时保留原 `prepared` 账本。桌面客户端在滚动发布窗口内仍按 HTTP 状态兼容旧同步 `200` 完整结果;旧图集只在 `spritesheetImageSrc` 是有效下载引用时优先使用,否则回退 `objectKey`。生成 POST 使用独立三十五分钟等待预算;game-chat 的两次串行生成纳入父 run `4200` 秒软预算与 `4500` 秒总截止。截止时普通本地动作按失败清理;若 `canvas.asset_generate` 已进入 executing,则结束本轮并关闭预览和客户端,但保留 pending action、provider batch、生成账本与 `needs-reconciliation`。轮询超时只保留 `accepted + operationId` 并在恢复时继续 GET;旧 `200` 结果损坏、`202` 缺 operationId、状态损坏、透明派生失败或外部完成后的本地提交失败仍进入对账边界。`postprocess-failed-source-preserved` 不得登记为透明图集或自动重试;其它 general warning 保持 completed 并与 `sliceWarning` 分别展示。本地 manifest 只在生成完成后持久化 generation route、kind、服务端 taskId 与精确参考 resourceId。UI extraction 只处理已有带标注 UI 图,不属于这条 DAG;图集不得回退到普通生图。UI 原型 prompt、`generationInputs.artSpec` 和 `ui-prototype.v2` 验收必须从当前项目玩法合同提取 HUD、可玩区域、关键实体、操作、失败/重开与移动布局,禁止预设塔防或补入合同中不存在的卡牌、波次、敌人入口。canonical UI 原型固定请求 `2K + 16:9`。旧正式图不合格时,普通原合同只能返回 `needs-repair`;Supervisor 认领后仅可签发一次完整继承原合同的 repair,由原 owner 使用 `replaceExisting=true` 原位替换,禁止先删除正式图。API Key 不进入项目文件;幂等键只进入受权限约束的私有生成账本,不进入 observation、manifest、agent.db 或日志。 +- 2026-08-03 durable 恢复补充,2026-08-04 扩展 `prepared`:`prepared / accepted / legacy-completed` 恢复必须先从私有账本读取持久化的画布 ID、素材目录 ID、画布名、生成提示词、route、kind 与引用资源;`prepared` 只原样恢复同一逻辑 POST,`accepted` 只查询既有 operation。不得在读取账本前重建请求、重新列举或创建远端项目/目录,也不得让本地输出路径漂移挡住恢复。显式“继续/恢复”复用原 action、pending、provider batch 与生成账本身份,不创建 successor 请求或新幂等键。恢复执行和后续 continuation 使用独立 Tokio task 栈边界,同时继续持有原 Agent lock。终态清理固定先删 generation / parallel 附属 sidecar,最后删 pending 身份锚点;历史孤儿只有所属任务已明确 completed/cancelled 时可自动清理,活动、未知或 `needs-reconciliation` orphan 必须保留并失败关闭。 +- 2026-08-03 durable 恢复加固,2026-08-04 收紧清理边界:生成账本还必须绑定归一化 base URL 与 API Key 哈希组成的配置指纹,当前 External Editor 服务或租户身份变更时禁止恢复 POST 或查询旧 operation。旧同步 `200` 结果只持久恢复必需的允许字段;绝对 signed URL、query/fragment 和未知扩展字段不得进入项目账本,只有安全相对路径或 objectKey 可作为 durable 下载引用。accepted operation 明确 failed 时也保留账本,直到 pending observation 和 Provider batch 成员终态持久化后再按统一清理链删除。只有首次提交直接取得契约明确的 `400 / 401 / 403` 才可视为入队前拒绝并清理 prepared 账本;首次结果已经未知后,恢复 POST 的临时鉴权或其它非成功响应不能证明原请求未入队,必须保留原账本。生成账本根目录、扫描与删除使用受控路径解析逐级拒绝符号链接,非法控制路径失败关闭。独立恢复任务异常必须落盘 task queue、state、event 和 agent.db 对账阻断,公共记录不得复制未脱敏 panic payload。 - 2026-07-10 补充:后台任务工具箱已加入 `task.list`。Agent 可在 loop 中读取 manifest 任务图、每个 seed task 的状态 / 依赖 / 产物交接,以及按依赖计算的 `readyTaskIds`;Runtime 复用 `task.list` 项目权限策略,策略要求确认或拒绝时只返回策略 observation,不向 LLM 暴露任务图细节。 - 2026-07-10 补充:后台任务工具箱已加入 `task.update`。Agent 可在 loop 中把 manifest 种子任务状态更新为 `pending / running / waiting-for-confirmation / completed / failed`,用于表达长期后台任务的当前进度;Runtime 复用 `task.update` 策略和项目写锁,实际只修改 `.agent/manifest.json` 中已有 taskId 的 `status`,并写入 `agent.runtime.task.update` 审计记录。策略要求确认或拒绝时不会修改 manifest,也不会创建新任务。 - 2026-07-10 补充:后台任务工具箱已加入 `file.list`。Agent 可在 loop 中自行列出项目文件摘要或某个相对目录下的文件摘要,再决定是否继续读取具体文件;Runtime 复用 `file.list` 项目权限策略,observation 只包含项目相对路径、类型和大小,不读取文件内容、不返回项目绝对路径。 @@ -905,9 +905,10 @@ game-project/ ## 2026-08-04 manifest 与工作台一致性收口 - `.agent/manifest.json` 的存储写边界使用同目录持久文件锁跨线程、跨进程串行化;锁必须覆盖旧 manifest 读取、不可变版本前缀校验、临时文件安装和安装后回读一致性校验。锁文件拒绝符号链接、非普通文件和异常所有权 / 硬链接;Windows 使用不共享写句柄,Unix 使用 `O_NOFOLLOW + flock`。旧快照在新版本安装后只能被拒绝,不能覆盖已追加版本。 -- 嵌入项目工作台的 Project Supervisor 在本地 manifest 状态变化时向启动器外传完整 manifest,并携带来源项目路径。启动器只更新仍为同一路径的活动项目上下文;资源列表、依赖图输入、任务状态、运行入口和正式版本卡必须在当前页面实时重投影,不要求关闭或重开项目。 -- `.agent/agent.db` 有界尾部读取报告截断时,审计 producer 映射失败关闭,不生成基于不完整审计的 producer 或 task flow。前端收到截断 DTO 时再次清空 producer、task flow、任务环和依赖深度派生结果;只依赖 manifest 唯一外部资源 ID 的精确引用关系继续保留。 -- 资源依赖 SVG 继续作为不可交互装饰层隐藏,但 dependency 画布通过 `aria-describedby` 提供当前可见精确引用和任务流的文本等价列表。中央资源聚焦关闭或按 Escape 退出后恢复触发卡片焦点;橙色引用线及箭头使用对 `#fffdfa` 画布达到至少 `3:1` 的颜色。 +- 后台 Agent 的 manifest 变化以共用 Runtime 状态投影 / 终态 emitter 作为失效因果点:`game-creator-agent-runtime-update` 的 Rust / TypeScript DTO 固定携带 `manifestInvalidated`,且 App 必须在 Supervisor、selected agent、session 和 run 身份的任何 early return 之前处理失效。GUI 进程内 Runtime 直接发该事件;External Runner 是独立进程、没有 GUI `AppHandle`,因此 Runner 协议 v5 的 `runner.attach_gui_owner` 必须登记 GUI 创建的随机 loopback 端口和 64 位随机令牌,Runner 的同一 emitter 通过受令牌保护的短连接转发 `game-creator-manifest-invalidated`。两条路径都只传项目路径与 Agent 身份,不复制 manifest,也不靠轮询补偿。 +- App 收到当前项目的 Runtime / relay 失效后重新调用 `get_local_game_manifest`。重读按项目 single-flight 合并事件风暴;读取中再到达失效只追加一轮串行重读,不并发提交同项目响应。应用结果同时校验组件仍挂载、当前项目路径和项目 scope version;项目切换、组件卸载或旧 scope 的迟到响应不得覆盖新项目。Project Supervisor 对外发布前以“revision 前读 -> manifest -> revision 后读”取得一致快照,再通过 `onManifestChange(projectPath, manifest, metadata)` 携带 `projectId + revision + source`;启动器按 `projectPath + projectId` 只接受更高 revision,同 revision 只接受内容一致的重复,旧轮询和同 revision 分叉都不得覆盖。资源列表、依赖图输入、任务状态、运行入口和正式版本卡必须在当前页面实时重投影,不要求关闭或重开项目。 +- `.agent/agent.db` 有界尾部读取报告截断时,审计 producer 映射失败关闭,不生成基于不完整审计的 producer、task flow 或对应任务环。前端收到截断 DTO 时只剔除 `producerAssignments`、`taskFlows` 与对应 `cyclicTaskIds`;Rust 根据当前 manifest、精确资源引用和仍可信任务深度下限返回的 `dependencyDepths` 继续保留,前端只校验资源仍存在且深度为非负安全整数,不得自行重算或压平权威深度。精确引用边、reference connection index、`cyclicResourceIds` 与 unresolved references 同样继续保留。 +- 资源依赖 SVG 继续作为不可交互装饰层隐藏,但 dependency 画布通过 `aria-describedby` 提供当前可见精确引用和任务流的文本等价列表。中央资源聚焦按稳定 `resourceId` 驱动焦点状态:仅 `null -> id` 或 `idA -> idB` 聚焦详情 region,同一 ID 的 manifest 重投影不得抢走音频、视频、链接或关闭按钮焦点;显式收起和 Escape 恢复画布滚动并优先聚焦原触发卡片。聚焦资源被删除时清理 stale focused / selected ID,关闭详情并把焦点落到资源搜索框;项目切换或运行视图切换清除旧恢复意图,不得恢复旧项目卡片。橙色引用线及箭头使用对 `#fffdfa` 画布达到至少 `3:1` 的颜色。 ## 2026-08-05 客户端素材创作无限画布阶段一合同 @@ -938,3 +939,12 @@ game-project/ - refine 只允许从当前 manifest 唯一登记的 PNG/JPEG/WebP 普通文件建立草稿,并复核签名、完整解码、大小、符号链接和硬链接。正式保存保留源 asset 与源文件,为缺失 resourceId 的源补 `local-asset:`,新建 `canvas-` 并以 `referenceResourceIds` 引用源资源。 - 页面以 project/draft/session epoch 丢弃项目切换、会话切换和卸载后的旧 Promise/事件,并以 eventId 去重 command 返回与至少一次事件。相同 scope 字段的父级重渲染不会重复打开草稿,选择变化也进入草稿 CAS;正式保存直接消费 CAS 返回的新 draft revision,不等待 React state 提交时序。 - 本阶段只提供独立 Surface/fixture 与 Tauri commands,不接 `project-development` 资源总览按钮、不做保存后的资源总览自动选中,也不接真实 External Editor API。定向证据为阶段三 Surface `8/8`、共享 core/React `7/7`、既有 AppSurface `351/351`、Rust 素材画布 `11/11` 与客户端 typecheck 通过;AppSurface 保留仓库既有 React `act(...)` 警告,不影响测试结果。 + +## 2026-08-05 客户端素材创作无限画布阶段四资源总览闭环 + +- `project-development` 已在资源总览提供“新增资源”,在 PNG/JPEG/WebP 聚焦态提供“精修资源”;两者只替换中央主视窗。取消先以草稿 CAS 标记 `cancelled` 再恢复原 dependency/type、搜索、滚动、选择与聚焦上下文。generation 仍为明确 mock,不请求真实 AI。 +- command 响应与 `game-creator-local-asset-committed` 统一携带 `projectId + draftId + commitId + assetId + projectRevision + committedProjectRevision + eventId`。启动器以 `projectPath + projectId + revision` 合并 manifest,并以 `eventId`、`projectId + commitId + revision` 记录重复身份;响应先到、事件先到、旧轮询和迟到重复均只改变一次当前权威快照。 +- dependency graph 不再依赖当前可见 mode 才读取;scope 包含 `projectPath + projectId +` 完整资源输入签名(含精确引用)。dependency 图未 ready 时不启动该 mode 布局读写;graph、dependency layout、type layout 都以签名化 epoch 丢弃旧请求,新 scope 不等待旧请求槽位。两份布局独立补齐新 ID,并保留全部仍有效历史坐标;图结构和布局继续不写 manifest。 +- 保存开始冻结 `saveAttemptId + sessionId + draftId + commitId + focusGeneration`。迟到提交始终可以更新当前项目的权威 manifest,但只有 flow、项目、保存尝试和 generation 仍匹配的最新意图可以继续定位。定位依次等待资源投影、dependency/type 两份布局 settled 且均有位置、当前筛选可见和 `data-resource-id` DOM 卡片存在;完成后按 commitId 只执行一次。搜索隐藏时保留条件并提供“清除搜索并定位”,该显式动作建立新的 focus generation。 +- 锁职责复核保持阶段三实现:正式提交/恢复固定为“项目 mutation write lock -> asset-canvas draft/transaction lock -> manifest store lock”;资源布局只取得独立 `.layout.lock`,不取得项目 mutation 或 asset-canvas 锁,布局协调只发生在提交释放锁并返回/发事件之后,不形成反向锁顺序。 +- 阶段四新增纯状态机与中央主视窗集成测试,覆盖响应/事件双顺序、旧 manifest、旧 graph/layout、切项目、改选择、连续保存、搜索隐藏、布局未完成、DOM 一次聚焦、refine 真实依赖以及无 reload/重开项目。最终通过项以本次任务验证记录为准。 diff --git a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md index f15ef8f9f..8fc01536e 100644 --- a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md +++ b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md @@ -524,7 +524,7 @@ GameBridge 禁止: 2026-06-19 追加:桌面壳 macOS 媒体权限说明进入门禁。Tauri 桌面壳仍不新增摄像头或麦克风 HostBridge method,不把系统媒体能力暴露成桌面命令;同源 H5 页面可继续使用浏览器标准 `getUserMedia` 承接儿童动作热身 Demo 的实时摄像头输入和汪汪声浪正式 runtime 的实时麦克风输入。macOS 分发包必须通过 `bundle.macOS.infoPlist="Info.plist"` 合并受控用途说明:`NSCameraUsageDescription` 只描述同源 H5 实时动作输入,`NSMicrophoneUsageDescription` 只描述同源 H5 实时声音玩法。`apps/desktop-shell/scripts/check-config.mjs` 会校验 plist 路径和两条文案,并把 `Info.plist` 纳入生产壳替身词扫描,防止桌面包缺少系统授权说明、把媒体权限扩写成通用采集能力,或在 macOS 分发配置里留下临时替身文本。 -2026-06-18 追加:桌面壳 release 构建烟测进入统一验收。`npm run check:native-shells` 会在 H5 HostBridge、Expo 壳和 Tauri 单测通过后执行 `npm run desktop-shell:build -- --no-bundle`,确认 Tauri release 入口指向共享公开主站、受控命令白名单、图标和 Rust release 编译可以共同产出桌面二进制;构建后 `desktop-shell:stage-release-binary` 会把当前平台二进制复制到根目录 `build/native/desktop/genarrative-desktop-shell` 或 `build/native/desktop/genarrative-desktop-shell.exe`,该目录沿用根 `build/` 的 gitignore,只作为本机或 CI 可收集产物目录。统一验收必须检查 staged 二进制存在、非空且符合当前平台可执行文件头。`apps/desktop-shell/scripts/check-config.mjs` 会反查根级门禁仍保留 release build smoke、staging 步骤、二进制路径、Linux ELF / macOS Mach-O / Windows PE 文件头和可执行位检查,避免桌面产物验收被改成只看命令退出码。该烟测不生成平台安装包,避免把 Linux 本机缺少的系统打包器误判为 HostBridge 回归。 +2026-06-18 追加:桌面壳 release 构建烟测进入统一验收。`npm run check:native-shells` 会在 H5 HostBridge、Expo 壳和 Tauri 单测通过后执行 `npm run desktop-shell:build -- --no-bundle`,确认 Tauri release 入口指向共享公开主站、受控命令白名单、图标和 Rust release 编译可以共同产出桌面二进制;构建后 `desktop-shell:stage-release-binary` 会把当前平台二进制复制到根目录 `build/native/desktop/genarrative-desktop-shell` 或 `build/native/desktop/genarrative-desktop-shell.exe`,该目录沿用根 `build/` 的 gitignore,只作为本机或 CI 可收集产物目录。统一验收必须检查 staged 二进制存在、非空且符合当前平台可执行文件头;macOS 校验同时接受 32/64 位与 fat Mach-O 的大端、反字节序合法魔数,不得把 arm64 常见的 `cf fa ed fe` 文件头误拒绝。`apps/desktop-shell/scripts/check-config.mjs` 会反查根级门禁仍保留 release build smoke、staging 步骤、二进制路径、Linux ELF / macOS Mach-O / Windows PE 文件头和可执行位检查,避免桌面产物验收被改成只看命令退出码。该烟测不生成平台安装包,避免把 Linux 本机缺少的系统打包器误判为 HostBridge 回归。 2026-06-18 追加:移动壳 Expo managed config 烟测进入统一验收。`npm run check:native-shells` 会执行 `npm run mobile-shell:config`,在 `apps/mobile-shell` 目录内调用 `expo config --type public --json`,校验 Expo CLI 实际解析结果中的包名、scheme、深链、ATS / cleartext / backup / 相机与麦克风权限、启动页、adaptive icon、插件配置和 HostBridge 版本没有漂移。`apps/mobile-shell/scripts/check-config.mjs` 会反查根级门禁仍保留 EAS build profile、Expo config 和 Metro export 三个移动分发烟测,避免移动壳验收退回到只看源码类型检查。 diff --git a/packages/image-canvas-core/src/ports.ts b/packages/image-canvas-core/src/ports.ts index 26e9ccdf5..031f4bbb3 100644 --- a/packages/image-canvas-core/src/ports.ts +++ b/packages/image-canvas-core/src/ports.ts @@ -212,6 +212,10 @@ export interface ImageCanvasCompletionPort { }): Promise< ImageCanvasHostResult<{ resourceId: string; + assetId?: string; + projectId?: string; + commitId?: string; + committedProjectRevision?: number; draftRevision: number; hostRevision: string; commitStatus?: 'committed' | 'already-committed'; diff --git a/scripts/check-native-shells.mjs b/scripts/check-native-shells.mjs index bbfd91361..c72290867 100644 --- a/scripts/check-native-shells.mjs +++ b/scripts/check-native-shells.mjs @@ -4915,9 +4915,13 @@ function assertDesktopReleaseBinaryArtifact() { const machMagic = header.readUInt32BE(0); const isMachO = machMagic === 0xcafebabe || - machMagic === 0xcafed00d || + machMagic === 0xbebafeca || + machMagic === 0xcafebabf || + machMagic === 0xbfbafeca || machMagic === 0xfeedface || - machMagic === 0xfeedfacf; + machMagic === 0xcefaedfe || + machMagic === 0xfeedfacf || + machMagic === 0xcffaedfe; if (!isMachO || (stat.mode & 0o111) === 0) { throw new Error( 'desktop macOS release binary must be an executable Mach-O file',