diff --git a/.gitea/workflows/project-ci.yml b/.gitea/workflows/project-ci.yml index 4bdbb49cd..e19b10e7e 100644 --- a/.gitea/workflows/project-ci.yml +++ b/.gitea/workflows/project-ci.yml @@ -161,9 +161,6 @@ jobs: - name: Install npm dependencies run: npm ci - - name: Check server-rs boundaries - run: npm run check:server-rs-ddd - - name: Prepare server-rs Rust dependencies shell: bash run: | @@ -181,6 +178,9 @@ jobs: sleep $((attempt * 2)) done + - name: Check server-rs boundaries + run: npm run check:server-rs-ddd + - name: Run server-rs workspace tests run: cargo test --locked --workspace --no-fail-fast --manifest-path server-rs/Cargo.toml diff --git a/apps/ai-game-creator-shell/scripts/smoke-agent-run-local-provider.mjs b/apps/ai-game-creator-shell/scripts/smoke-agent-run-local-provider.mjs index ad77d7f19..6b03ae39c 100644 --- a/apps/ai-game-creator-shell/scripts/smoke-agent-run-local-provider.mjs +++ b/apps/ai-game-creator-shell/scripts/smoke-agent-run-local-provider.mjs @@ -885,6 +885,10 @@ function readBrowserDom(url) { function resolveChromeBin() { for (const candidate of [ + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + '/Applications/Chromium.app/Contents/MacOS/Chromium', + '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge', + '/opt/google/chrome/chrome', '/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium', diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs index 6f7f7d55f..ca61da923 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 @@ -2057,7 +2057,7 @@ mod canvas_generation_tests { #[tokio::test] async fn recovery_scan_resumes_accepted_generation_on_default_worker_stack() { - let temporary = tempfile::tempdir().expect("create accepted scan project"); + let temporary = crate::tests::canonical_test_tempdir("accepted-generation-scan-"); let root = temporary.path(); init_local_game_project_at(root, "accepted-scan", "恢复扫描测试") .expect("init accepted scan project"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/external_generation_state.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/external_generation_state.rs index 16bcd0a62..490540b08 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 @@ -736,7 +736,7 @@ mod external_generation_state_tests { #[test] fn prepared_generation_state_reuses_identity_and_only_accepted_can_resume() { - let temporary = tempfile::tempdir().expect("create generation ledger project"); + let temporary = crate::tests::canonical_test_tempdir("external-generation-ledger-"); let root = temporary.path(); init_local_game_project_at(root, "generation-ledger", "生成账本测试") .expect("init project"); @@ -846,7 +846,7 @@ mod external_generation_state_tests { #[test] fn legacy_completed_generation_persists_only_allowlisted_safe_download_fields() { - let temporary = tempfile::tempdir().expect("create legacy generation ledger project"); + let temporary = crate::tests::canonical_test_tempdir("legacy-generation-ledger-"); let root = temporary.path(); init_local_game_project_at(root, "legacy-generation-ledger", "旧同步生成账本测试") .expect("init project"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/pending_confirmation_ledger.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/pending_confirmation_ledger.rs index fa5493585..6de6a816e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/pending_confirmation_ledger.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/pending_confirmation_ledger.rs @@ -790,7 +790,7 @@ mod tests { #[test] fn generation_cleanup_failure_preserves_pending_identity_anchor() { - let temporary = tempfile::tempdir().expect("create pending cleanup project"); + let temporary = crate::tests::canonical_test_tempdir("pending-generation-cleanup-"); let root = temporary.path(); let run_id = "generation-cleanup-order-run"; init_local_game_project_at(root, "generation-cleanup-order", "生成账本清理顺序测试") diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs index 383a7c415..71370aaf8 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 @@ -425,7 +425,7 @@ mod tests { const ORDINARY_NOTICE: &str = "除下方有界仓库启动上下文、当前 Session 未压缩对话尾部或历史压缩摘要外,项目记忆、资产和源码正文不会预加载"; const MEMORY_MARKER: &str = "supervisor-preloaded-context-marker"; - let directory = tempfile::tempdir().expect("temp project directory"); + let directory = crate::tests::canonical_test_tempdir("provider-request-project-"); let root = directory.path().join("project"); init_local_game_project_at(&root, "project-1", "项目总控预加载说明测试") .expect("project init"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_deadline_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_deadline_tests.rs index 843dc4c06..c7e1e211d 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 @@ -64,14 +64,8 @@ async fn game_chat_absolute_deadline_returns_an_in_flight_result_before_expiry() #[tokio::test] async fn game_chat_absolute_deadline_preserves_external_generation_reconciliation() { - let root = std::env::temp_dir().join(format!( - "genarrative-game-chat-deadline-reconciliation-{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("system clock") - .as_nanos() - )); + let temporary = crate::tests::canonical_test_tempdir("game-chat-deadline-reconciliation-"); + let root = temporary.path().join("project"); init_local_game_project_at(&root, "deadline-reconciliation", "硬截止收尾测试") .expect("project init"); bind_game_creator_agent_runtime_run_profile_at( @@ -271,14 +265,8 @@ async fn game_chat_absolute_deadline_preserves_external_generation_reconciliatio #[test] fn game_chat_absolute_deadline_still_cleans_local_action_recovery() { - let root = std::env::temp_dir().join(format!( - "genarrative-game-chat-deadline-local-cleanup-{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("system clock") - .as_nanos() - )); + let temporary = crate::tests::canonical_test_tempdir("game-chat-deadline-local-cleanup-"); + let root = temporary.path().join("project"); init_local_game_project_at(&root, "deadline-local-cleanup", "硬截止本地清理测试") .expect("project init"); let mut runtime = start_game_creator_agent_runtime_task_at( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs index 6981f58f4..6339a811e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs @@ -767,7 +767,7 @@ fn standard_specialist_empty_plan_has_no_deterministic_final_reply_fallback() { fn autonomous_manifest_waiting_context_persists_without_finishing_parent_run() { const RUN_ID: &str = "autonomous-manifest-waiting-parent"; const TASK: &str = "生成完整小游戏并完成项目任务图"; - let temporary = tempfile::tempdir().expect("create manifest waiting root"); + let temporary = crate::tests::canonical_test_tempdir("manifest-waiting-"); let root = temporary.path().join("project"); init_local_game_project_at(&root, "manifest-waiting-project", TASK) .expect("init manifest waiting project"); @@ -981,7 +981,7 @@ async fn autonomous_supervisor_converged_final_reply_deserialize_commits_fallbac const TASK: &str = "生成一个可完成静态检查和双视口试玩的塔防游戏"; const TEST_KEY: &str = "autonomous-final-reply-fallback-key"; - let temporary = tempfile::tempdir().expect("create autonomous fallback root"); + let temporary = crate::tests::canonical_test_tempdir("autonomous-fallback-"); let root = temporary.path().join("project"); init_local_game_project_at(&root, "autonomous-fallback-project", TASK) .expect("init autonomous fallback project"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_execution.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_execution.rs index cdbb3c0e6..6940911ec 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_execution.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_execution.rs @@ -13,7 +13,6 @@ where .await .expect("pending continuation task must exist") } - async fn run_after_pending_stack_boundary( future: std::pin::Pin + Send + 'static>>, ) -> T @@ -150,7 +149,6 @@ fn persist_game_creator_agent_runtime_continuation_reconciliation_emergency_at( ); emit_game_creator_agent_runtime_update(root, &runtime.agent_id); } - pub(crate) async fn continue_game_creator_agent_pending_tool_action( root: PathBuf, agent_id: String, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs index 974216689..364eca9cc 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs @@ -1221,7 +1221,7 @@ mod orphaned_external_generation_recovery_tests { #[test] fn recovery_scan_preserves_active_generation_orphan_then_cleans_terminal_legacy_orphan() { - let temporary = tempfile::tempdir().expect("create orphan generation recovery project"); + let temporary = crate::tests::canonical_test_tempdir("orphan-generation-recovery-"); let root = temporary.path(); let run_id = "orphan-generation-recovery-run"; init_local_game_project_at(root, "orphan-generation-recovery", "孤儿生成账本恢复测试") diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs index cfe56d75b..fddd0fbac 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs @@ -1249,6 +1249,88 @@ pub(in crate::agent) fn project_autonomous_manifest_ready_task_terminal_at_locke }), )?; } + if status == GameCreationAppTaskStatus::Completed && state.agent_id == "preview-playtest" { + let manifest_before_completion = read_manifest_for_project(root)?; + let required_tasks = autonomous_manifest_seed_tasks_for_source(&root_parent_binding.source); + let required_by_id = required_tasks + .iter() + .map(|task| (task.id.as_str(), task)) + .collect::>(); + let mut prerequisite_ids = std::collections::BTreeSet::new(); + let mut pending_ids = vec![state.agent_id.as_str()]; + while let Some(task_id) = pending_ids.pop() { + if !prerequisite_ids.insert(task_id) { + continue; + } + let task = required_by_id + .get(task_id) + .ok_or_else(|| format!("可运行版本项目完整性合同缺少任务:{task_id}"))?; + pending_ids.extend(task.dependencies.iter().map(String::as_str)); + } + let incomplete = required_tasks + .iter() + .filter(|required| prerequisite_ids.contains(required.id.as_str())) + .filter(|required| required.id != state.agent_id) + .filter(|required| { + manifest_before_completion + .tasks + .iter() + .find(|task| task.id == required.id) + .is_none_or(|task| task.status != GameCreationAppTaskStatus::Completed) + }) + .map(|task| task.id.as_str()) + .collect::>(); + if !incomplete.is_empty() { + return Err(format!( + "可运行版本项目完整性检查未通过:{}", + incomplete.join("、") + )); + } + let readiness_records = + latest_game_creator_agent_runtime_tasks(read_all_game_creator_agent_runtime_tasks( + &game_creator_agent_runtime_task_path(root, "preview-readiness"), + )?); + let readiness = readiness_records + .iter() + .rev() + .find(|record| { + record.parent_agent_id.as_deref() == Some(parent_agent_id.as_str()) + && record.parent_run_id.as_deref() == Some(parent_run_id.as_str()) + && record.status == "completed" + }) + .ok_or_else(|| "可运行版本缺少 preview-readiness 完成回执".to_string())?; + let current_revision = read_game_creator_agent_runtime_project_revision(root)?; + let readiness_gate = read_game_creator_agent_runtime_verification_gate( + root, + &readiness.agent_id, + &readiness.run_id, + )?; + if readiness_gate.last_verification_tool.as_deref() != Some("game.static_smoke") + || readiness_gate.last_verification_status.as_deref() + != Some(AGENT_RUNTIME_VERIFICATION_STATUS_PASSED) + || readiness_gate.verified_revision != Some(current_revision.revision) + { + return Err("可运行版本缺少当前 revision 的 game.static_smoke 通过凭证".to_string()); + } + let contract = autonomous_playtest_completion_contract_for_state_at(root, state)? + .ok_or_else(|| "可运行版本缺少 preview.validate 完成合同".to_string())?; + let receipt = read_autonomous_playtest_receipt(root, &contract)? + .ok_or_else(|| "可运行版本缺少 preview.validate 成功回执".to_string())?; + verify_autonomous_playtest_evidence_files_at(root, &receipt)?; + if receipt.revision != current_revision.revision { + return Err(format!( + "可运行版本 revision 不一致:receipt={} current={}", + receipt.revision, current_revision.revision + )); + } + register_current_runnable_game_version_at( + root, + current_revision.revision, + &receipt.agent_id, + &receipt.run_id, + &receipt.report.path, + )?; + } update_manifest_task_status_at(root, &state.agent_id, status.clone())?; append_agent_db_record( root, 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 c38d8e71e..725bfa711 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 @@ -698,6 +698,138 @@ fn autonomous_preview_manifest_tasks_accept_bound_current_revision_receipts() { assert!(autonomous_game_build_completion_blocker_at_locked(&root, &playtest_state).is_none()); } +#[test] +fn preview_playtest_terminal_registers_a_runnable_snapshot_before_downstream_publish_tasks_complete( +) { + let (_temporary, root, parent_state, contract) = + autonomous_fixture("做一个完整小游戏", "autonomous-runnable-version-parent"); + for task_id in ["publish-strategy", "publish-package"] { + update_manifest_task_status_at(&root, task_id, GameCreationAppTaskStatus::Pending) + .unwrap_or_else(|error| panic!("leave downstream {task_id} pending: {error}")); + } + let task_ids = read_manifest_for_project(&root) + .expect("read autonomous manifest") + .tasks + .into_iter() + .map(|task| task.id) + .collect::>(); + for task_id in task_ids { + if !matches!( + task_id.as_str(), + "preview-readiness" | "preview-playtest" | "publish-strategy" | "publish-package" + ) { + update_manifest_task_status_at(&root, &task_id, GameCreationAppTaskStatus::Completed) + .unwrap_or_else(|error| panic!("complete prerequisite {task_id}: {error}")); + } + } + + update_manifest_task_status_at( + &root, + "preview-readiness", + GameCreationAppTaskStatus::Running, + ) + .expect("mark preview readiness running"); + let readiness_child = + queue_autonomous_manifest_child_fixture(&root, &parent_state, "preview-readiness"); + let revision = advance_game_index_revision( + &root, + &parent_state, + "可运行版本", + ); + let readiness_state = agent_runtime_state_from_task_record(&readiness_child); + mark_verification_passed(&root, &readiness_state, "game.static_smoke"); + let readiness_terminal = AgentRuntimeTaskRecord { + status: "completed".to_string(), + phase: "completed".to_string(), + updated_at: unix_timestamp(), + ..readiness_child + }; + append_game_creator_agent_runtime_task_record(&root, &readiness_terminal) + .expect("persist completed preview readiness record"); + project_autonomous_manifest_ready_task_terminal_at( + &root, + &agent_runtime_state_from_task_record(&readiness_terminal), + ) + .expect("project preview readiness completion"); + + update_manifest_task_status_at( + &root, + "preview-playtest", + GameCreationAppTaskStatus::Running, + ) + .expect("mark preview playtest running"); + let playtest_child = + queue_autonomous_manifest_child_fixture(&root, &parent_state, "preview-playtest"); + let playtest_state = agent_runtime_state_from_task_record(&playtest_child); + let result = browser_result_fixture( + &root, + &parent_state, + revision, + BrowserPlaytestScenario::GenericV1, + ); + let action = AgentRuntimeToolAction { + tool: "preview.validate".to_string(), + reason: Some("验证可运行版本".to_string()), + input: serde_json::json!({}), + }; + let action_fingerprint = + agent_runtime_tool_action_fingerprint(&action, &playtest_state.current_task); + let action_id = + agent_runtime_tool_action_id(&playtest_state.run_id, 1, 0, 1, &action_fingerprint); + write_autonomous_playtest_receipt_at( + &root, + &contract, + &action_id, + &action_fingerprint, + revision, + &result, + ) + .expect("persist runnable playtest receipt"); + let playtest_terminal = AgentRuntimeTaskRecord { + status: "completed".to_string(), + phase: "completed".to_string(), + updated_at: unix_timestamp(), + ..playtest_child + }; + append_game_creator_agent_runtime_task_record(&root, &playtest_terminal) + .expect("persist completed preview playtest record"); + project_autonomous_manifest_ready_task_terminal_at( + &root, + &agent_runtime_state_from_task_record(&playtest_terminal), + ) + .expect("project preview playtest and register runnable version"); + + let manifest = read_manifest_for_project(&root).expect("read runnable manifest"); + assert_eq!(manifest.runnable_versions.len(), 1); + let version = &manifest.runnable_versions[0]; + assert_eq!(version.project_revision, revision); + assert_eq!( + version.created_reason, + RunnableGameVersionCreatedReason::Initial + ); + assert_eq!( + manifest.current_runnable_version_id.as_deref(), + Some(version.version_id.as_str()) + ); + assert!(root + .join(&version.artifact_path) + .join("game/index.html") + .is_file()); + for task_id in ["publish-strategy", "publish-package"] { + let status = manifest + .tasks + .iter() + .find(|task| task.id == task_id) + .map(|task| &task.status) + .unwrap_or_else(|| panic!("missing downstream task {task_id}")); + assert_ne!( + status, + &GameCreationAppTaskStatus::Completed, + "runnable registration must not wait for downstream {task_id} completion" + ); + } +} + #[test] fn autonomous_completion_rejects_formal_artifact_unchanged_from_run_baseline() { let baseline_bytes = diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/context_bundle.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/context_bundle.rs index 6c162f963..17973d262 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/context_bundle.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/context_bundle.rs @@ -827,10 +827,11 @@ mod tests { .duration_since(UNIX_EPOCH) .expect("system clock should be after epoch") .as_nanos(); - let root = std::env::temp_dir().join(format!( - "genarrative-context-window-boundary-{}-{unique}", + let temporary = crate::tests::canonical_test_tempdir(&format!( + "context-window-boundary-{}-{unique}-", std::process::id() )); + let root = temporary.path().join("project"); init_local_game_project_at(&root, "project-1", "上下文窗口边界恢复项目") .expect("project init"); let mut runtime = start_game_creator_agent_runtime_task_at( diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index 5c9f77367..bff274d4b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -204,6 +204,17 @@ pub(crate) fn read_local_project_resource_canvas_layout( read_project_resource_canvas_layout_at(Path::new(project_path.trim()), mode) } +#[tauri::command] +pub(crate) fn read_local_project_resource_graph( + project_path: String, + expected_project_id: String, + resources: Vec, +) -> Result { + let root = validated_local_project_directory_path(project_path.trim())?; + enforce_project_auto_permission_policy(&root, "asset.list")?; + read_project_resource_graph_at(&root, expected_project_id.trim(), resources) +} + #[tauri::command] pub(crate) fn update_local_project_resource_canvas_layout( project_path: String, @@ -1166,6 +1177,66 @@ pub(crate) fn read_local_project_image_preview( load_local_project_image_preview(root, &normalized_path) } +#[tauri::command] +pub(crate) fn read_local_project_text_preview( + project_path: String, + relative_path: String, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_auto_permission_policy(root, "file.read")?; + let normalized_path = normalize_relative_path(relative_path.trim())?; + let manifest = read_manifest(&root.join(".agent/manifest.json"))?; + let is_registered_document = manifest.assets.iter().any(|asset| { + asset.local_path == normalized_path + && is_supported_project_text_resource(&asset.local_path, &asset.media_type) + }) || manifest.tasks.iter().any(|task| { + task.status == GameCreationAppTaskStatus::Completed + && task.artifacts.iter().any(|path| path == &normalized_path) + && is_supported_project_text_resource(&normalized_path, "") + }); + if !is_registered_document { + return Err("只能读取当前项目已登记的文档资源".to_string()); + } + load_local_project_text_preview(root, &normalized_path) +} + +#[tauri::command] +pub(crate) fn read_local_project_media_preview( + project_path: String, + relative_path: String, + category: String, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_auto_permission_policy(root, "file.read")?; + let normalized_path = normalize_relative_path(relative_path.trim())?; + let manifest = read_manifest(&root.join(".agent/manifest.json"))?; + let kind = match category.trim() { + "art" => ProjectMediaPreviewKind::Art, + "audio" => ProjectMediaPreviewKind::Audio, + _ => return Err("媒体预览类别只支持 art 或 audio".to_string()), + }; + let is_registered_media = manifest.assets.iter().any(|asset| { + asset.local_path == normalized_path + && match kind { + ProjectMediaPreviewKind::Art => { + is_supported_project_art_media_resource(&asset.local_path, &asset.media_type) + } + ProjectMediaPreviewKind::Audio => { + is_supported_project_audio_resource(&asset.local_path, &asset.media_type) + } + } + }) || (kind == ProjectMediaPreviewKind::Art + && manifest.tasks.iter().any(|task| { + task.status == GameCreationAppTaskStatus::Completed + && task.artifacts.iter().any(|path| path == &normalized_path) + && is_supported_project_art_media_resource(&normalized_path, "") + })); + if !is_registered_media { + return Err("只能预览当前项目已登记的媒体资源".to_string()); + } + load_local_project_media_preview(root, &normalized_path, kind) +} + #[tauri::command] pub(crate) fn write_local_project_file( project_path: String, diff --git a/apps/ai-game-creator-shell/src-tauri/src/image_inspect.rs b/apps/ai-game-creator-shell/src-tauri/src/image_inspect.rs index 6ab5a5979..5d6362ba5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/image_inspect.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/image_inspect.rs @@ -204,7 +204,10 @@ fn validate_agent_runtime_inspection_path( Ok(()) } -fn validate_agent_runtime_inspection_ancestors(root: &Path, path: &Path) -> Result<(), String> { +pub(crate) fn validate_agent_runtime_inspection_ancestors( + root: &Path, + path: &Path, +) -> Result<(), String> { let relative = path .strip_prefix(root) .map_err(|_| "image.inspect 图片路径超出项目目录".to_string())?; @@ -450,7 +453,7 @@ fn metadata_is_windows_reparse_point(_metadata: &fs::Metadata) -> bool { } #[cfg(unix)] -fn same_open_file_snapshot(left: &fs::Metadata, right: &fs::Metadata) -> bool { +pub(crate) fn same_open_file_snapshot(left: &fs::Metadata, right: &fs::Metadata) -> bool { use std::os::unix::fs::MetadataExt; left.dev() == right.dev() && left.ino() == right.ino() @@ -463,12 +466,12 @@ fn same_open_file_snapshot(left: &fs::Metadata, right: &fs::Metadata) -> bool { } #[cfg(not(unix))] -fn same_open_file_snapshot(left: &fs::Metadata, right: &fs::Metadata) -> bool { +pub(crate) fn same_open_file_snapshot(left: &fs::Metadata, right: &fs::Metadata) -> bool { left.len() == right.len() && left.modified().ok() == right.modified().ok() } #[cfg(unix)] -fn same_open_file_identity( +pub(crate) fn same_open_file_identity( _left_file: &fs::File, left: &fs::Metadata, _right_file: &fs::File, @@ -479,7 +482,7 @@ fn same_open_file_identity( } #[cfg(windows)] -fn same_open_file_identity( +pub(crate) fn same_open_file_identity( left_file: &fs::File, _left: &fs::Metadata, right_file: &fs::File, @@ -489,7 +492,7 @@ fn same_open_file_identity( } #[cfg(not(any(unix, windows)))] -fn same_open_file_identity( +pub(crate) fn same_open_file_identity( _left_file: &fs::File, left: &fs::Metadata, _right_file: &fs::File, diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index ac768693f..f068b3b24 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -23,6 +23,7 @@ use reqwest::header; use serde::{Deserialize, Serialize}; use shared_contracts::game_creation_app::{ new_game_creation_app_manifest, new_game_creation_app_seed_tasks, + validate_game_iteration_versions, validate_runnable_game_versions, GameCreationAgentArtifactTrace, GameCreationAgentCapabilityDescriptor, GameCreationAgentPassPlanTrace, GameCreationAgentRepairRouteTrace, GameCreationAgentRunStep, GameCreationAgentRunTaskGraphTrace, GameCreationAgentRunTrace, GameCreationAgentToolCallTrace, @@ -31,11 +32,14 @@ use shared_contracts::game_creation_app::{ GameCreationAppCommandRunStatus, GameCreationAppLimitedRunCommandDescriptor, GameCreationAppManifest, GameCreationAppPermission, GameCreationAppPreviewState, GameCreationAppPreviewStatus, GameCreationAppTaskState, GameCreationAppTaskStatus, - ProjectResourceCanvasLayout, ProjectResourceCanvasLayoutMode, ProjectResourceCanvasPosition, + GameIterationVersionResourceBinding, ProjectResourceCanvasLayout, + ProjectResourceCanvasLayoutMode, ProjectResourceCanvasPosition, RunnableGameVersion, + RunnableGameVersionCreatedReason, RunnableGameVersionValidation, UpdateProjectResourceCanvasLayoutResult, UpdateProjectResourceCanvasLayoutStatus, GAME_CREATION_AGENT_CAPABILITIES, GAME_CREATION_AGENT_RUN_SCHEMA_VERSION, GAME_CREATION_AGENT_TOOL_CALL_MAX, GAME_CREATION_APP_COMMANDS, GAME_CREATION_APP_LIMITED_RUN_COMMANDS, GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION, + RUNNABLE_GAME_VERSION_SCHEMA_VERSION, }; use tauri::{Emitter, Manager}; use tauri_plugin_dialog::DialogExt; @@ -72,6 +76,7 @@ mod project; mod provider_handoff; mod provider_retry; mod repository_context; +mod resource_inspect; mod runner; mod swarm_cli; mod tool_plan_handoff; @@ -101,6 +106,7 @@ use preview::*; use process_session::*; use project::*; use repository_context::*; +use resource_inspect::*; use runner::*; use swarm_cli::*; use user_input::*; @@ -144,6 +150,14 @@ struct LocalPreviewStatus { root: Option, } +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +struct RunnableGameVersionLaunchResult { + manifest: GameCreationAppManifest, + version: RunnableGameVersion, + preview: LocalPreviewResult, +} + #[derive(Clone, Debug, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] struct LocalGameProjectRevisionStatus { @@ -2026,6 +2040,7 @@ mod game_chat_release_client_exit_tests { } } +#[cfg(not(test))] fn main() { let mut args = std::env::args().skip(1).collect::>(); #[cfg(target_os = "linux")] @@ -2348,6 +2363,8 @@ fn main() { list_local_project_files, read_local_project_file, read_local_project_image_preview, + read_local_project_text_preview, + read_local_project_media_preview, write_local_project_file, delete_local_project_file, read_local_game_memory, @@ -2374,11 +2391,13 @@ fn main() { open_game_creator_launcher_window, open_project_supervisor_chat_window, start_local_game_preview, + launch_local_game_runnable_version, activate_local_game_preview, stop_local_game_preview, stop_local_game_preview_if_matches, get_local_game_preview_status, read_local_project_resource_canvas_layout, + read_local_project_resource_graph, update_local_project_resource_canvas_layout, get_local_game_project_revision, get_local_game_manifest diff --git a/apps/ai-game-creator-shell/src-tauri/src/mcp.rs b/apps/ai-game-creator-shell/src-tauri/src/mcp.rs index 2c5f26c40..28cc2f1c6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/mcp.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/mcp.rs @@ -1931,7 +1931,10 @@ mod tests { } fn mcp_test_project(label: &str) -> PathBuf { - let root = std::env::temp_dir().join(format!( + let temp_root = std::env::temp_dir() + .canonicalize() + .expect("canonicalize MCP test temp root"); + let root = temp_root.join(format!( "game-creator-mcp-{label}-{}-{}", std::process::id(), MCP_TEST_PROJECT_COUNTER.fetch_add(1, Ordering::Relaxed) diff --git a/apps/ai-game-creator-shell/src-tauri/src/preview.rs b/apps/ai-game-creator-shell/src-tauri/src/preview.rs index 056d25ce3..717a5c3cf 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/preview.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/preview.rs @@ -382,6 +382,14 @@ pub(crate) fn activate_local_game_preview( pub(crate) fn start_local_game_preview_for_project( root: &Path, ) -> Result<(LocalPreviewResult, mpsc::Sender<()>), String> { + start_local_game_preview_for_served_root(root, root) +} + +pub(crate) fn start_local_game_preview_for_served_root( + project_root: &Path, + served_root: &Path, +) -> Result<(LocalPreviewResult, mpsc::Sender<()>), String> { + let root = project_root; if root.as_os_str().is_empty() { return Err("项目目录不能为空".to_string()); } @@ -389,7 +397,10 @@ pub(crate) fn start_local_game_preview_for_project( return Err("项目目录必须是绝对路径".to_string()); } - let game_root = root.join("game"); + if served_root.as_os_str().is_empty() || !served_root.is_absolute() { + return Err("预览产物目录无效".to_string()); + } + let game_root = served_root.join("game"); if !game_root.is_dir() { return Err(format!("游戏目录不存在:{}", game_root.display())); } @@ -409,7 +420,7 @@ pub(crate) fn start_local_game_preview_for_project( listener .set_nonblocking(true) .map_err(|error| format!("设置预览监听失败:{error}"))?; - let served_root = root.to_path_buf(); + let served_root = served_root.to_path_buf(); let (stop_sender, stop_receiver) = mpsc::channel(); thread::spawn(move || loop { @@ -443,6 +454,80 @@ pub(crate) fn start_local_game_preview_for_project( )) } +#[tauri::command] +pub(crate) fn launch_local_game_runnable_version( + project_path: String, + expected_project_id: String, + version_id: Option, + registry: tauri::State<'_, PreviewRegistry>, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "preview.start")?; + let _lock = acquire_project_write_lock(root, "preview.start")?; + let current_manifest = read_existing_manifest_for_project(root)?; + if current_manifest.project_id != expected_project_id.trim() { + return Err("可运行版本项目身份不一致".to_string()); + } + let version_id = version_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .or(current_manifest.current_runnable_version_id.clone()) + .ok_or_else(|| "当前无可运行版本".to_string())?; + + let _ = registry.stop_for_project(Some(root)); + let (_, version, artifact_root) = + resolve_runnable_game_version_at(root, expected_project_id.trim(), &version_id)?; + let (preview, stop) = match start_local_game_preview_for_served_root(root, &artifact_root) { + Ok(result) => result, + Err(error) => { + let _ = record_preview_state(root, GameCreationAppPreviewStatus::Failed, None, None); + return Err(format!("可运行版本预览启动失败:{error}")); + } + }; + if let Err(error) = record_preview_state( + root, + GameCreationAppPreviewStatus::Running, + Some(preview.url.clone()), + Some(preview.port), + ) { + let _ = stop.send(()); + return Err(error); + } + if let Err(error) = append_preview_log(root, "running", Some(&preview.url)) { + let _ = stop.send(()); + let _ = record_preview_state(root, GameCreationAppPreviewStatus::Failed, None, None); + return Err(error); + } + let (preview, previous_preview) = registry.set_running(preview, stop); + if let Some(previous_preview) = previous_preview.as_ref() { + record_replaced_preview_stop(previous_preview); + } + if let Err(error) = append_preview_start_trace_step(root, &preview) { + let _ = registry.stop(); + let _ = record_preview_state(root, GameCreationAppPreviewStatus::Failed, None, None); + return Err(error); + } + let (manifest, selected_version, _) = match select_current_runnable_game_version_at( + root, + expected_project_id.trim(), + &version.version_id, + ) { + Ok(selected) => selected, + Err(error) => { + let _ = registry.stop(); + let _ = record_preview_state(root, GameCreationAppPreviewStatus::Failed, None, None); + return Err(error); + } + }; + Ok(RunnableGameVersionLaunchResult { + manifest, + version: selected_version, + preview, + }) +} + fn handle_preview_stream(mut stream: TcpStream, root: &Path) { // The listener is nonblocking so its accept loop can observe the stop channel. Windows may // inherit that mode on accepted sockets; switch each connection back to blocking mode before diff --git a/apps/ai-game-creator-shell/src-tauri/src/project.rs b/apps/ai-game-creator-shell/src-tauri/src/project.rs index aee2ea42a..fb877c095 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project.rs @@ -10,7 +10,9 @@ mod export; mod filesystem; mod manifest; mod memory; +mod resource_dependency_graph; mod resource_layout; +mod runnable_versions; mod verification; pub(crate) use agent_db::*; @@ -20,5 +22,7 @@ pub(crate) use export::*; pub(crate) use filesystem::*; pub(crate) use manifest::*; pub(crate) use memory::*; +pub(crate) use resource_dependency_graph::*; pub(crate) use resource_layout::*; +pub(crate) use runnable_versions::*; pub(crate) use verification::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs index 91fb6a9ff..704e02cd2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs @@ -631,8 +631,17 @@ pub(crate) fn read_manifest(path: &Path) -> Result( @@ -709,6 +718,35 @@ pub(crate) fn write_manifest( path: &Path, manifest: &GameCreationAppManifest, ) -> Result<(), String> { + validate_game_iteration_versions(&manifest.versions) + .map_err(|error| format!("校验 manifest 项目版本失败:{error}"))?; + validate_runnable_game_versions( + &manifest.project_id, + &manifest.runnable_versions, + manifest.current_runnable_version_id.as_deref(), + ) + .map_err(|error| format!("校验 manifest 可运行版本失败:{error}"))?; + if manifest_storage_exists(path)? { + let existing = read_manifest(path)?; + if existing.versions.len() > manifest.versions.len() + || existing + .versions + .iter() + .zip(&manifest.versions) + .any(|(existing, candidate)| existing != candidate) + { + return Err("项目版本记录写入后不可修改、删除或重排".to_string()); + } + if existing.runnable_versions.len() > manifest.runnable_versions.len() + || existing + .runnable_versions + .iter() + .zip(&manifest.runnable_versions) + .any(|(existing, candidate)| existing != candidate) + { + return Err("可运行版本记录写入后不可修改、删除或重排".to_string()); + } + } let payload = serde_json::to_string_pretty(manifest) .map_err(|error| format!("序列化 manifest 失败:{error}"))?; if let Some(parent) = path.parent() { diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest/recovery_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest/recovery_tests.rs index c82b0144d..1388bfe92 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/manifest/recovery_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest/recovery_tests.rs @@ -1,4 +1,7 @@ use super::*; +use shared_contracts::game_creation_app::{ + GameIterationVersion, GameIterationVersionCreatedReason, GameIterationVersionResourceBinding, +}; fn unique_manifest_test_root(test_name: &str) -> PathBuf { std::env::temp_dir().join(format!( @@ -40,6 +43,59 @@ fn manifest_read_and_project_write_recover_previous_file() { fs::remove_dir_all(root).ok(); } +fn version_fixture( + version_id: &str, + parent_version_id: Option<&str>, + project_revision: u64, + created_reason: GameIterationVersionCreatedReason, +) -> GameIterationVersion { + GameIterationVersion { + version_id: version_id.to_string(), + parent_version_id: parent_version_id.map(str::to_string), + project_revision, + resource_bindings: vec![GameIterationVersionResourceBinding { + slot_id: "player".to_string(), + resource_id: "asset-player".to_string(), + }], + created_reason, + created_at: project_revision, + } +} + +#[test] +fn manifest_versions_are_append_only_at_the_storage_boundary() { + let root = unique_manifest_test_root("versions-append-only"); + let manifest_path = root.join(".agent/manifest.json"); + let mut manifest = new_game_creation_app_manifest("project-versioned", "版本项目"); + manifest.versions.push(version_fixture( + "version-root", + None, + 1, + GameIterationVersionCreatedReason::Initial, + )); + write_manifest(&manifest_path, &manifest).expect("write initial version"); + + manifest.versions.push(version_fixture( + "version-child", + Some("version-root"), + 2, + GameIterationVersionCreatedReason::AgentRevision, + )); + write_manifest(&manifest_path, &manifest).expect("append child version"); + + let stable_payload = fs::read(&manifest_path).expect("read stable manifest bytes"); + manifest.versions[0].resource_bindings[0].resource_id = "asset-mutated".to_string(); + let error = + write_manifest(&manifest_path, &manifest).expect_err("reject mutation of existing version"); + assert!(error.contains("不可修改、删除或重排"), "{error}"); + assert_eq!( + fs::read(&manifest_path).expect("read untouched manifest bytes"), + stable_payload + ); + + fs::remove_dir_all(root).ok(); +} + #[test] fn manifest_install_uses_previous_when_direct_replace_fails() { let root = unique_manifest_test_root("replace-fallback"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/resource_dependency_graph.rs b/apps/ai-game-creator-shell/src-tauri/src/project/resource_dependency_graph.rs new file mode 100644 index 000000000..917071ea2 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/project/resource_dependency_graph.rs @@ -0,0 +1,965 @@ +use super::*; +use std::collections::{BTreeMap, BTreeSet}; + +const RESOURCE_GRAPH_AGENT_DB_READ_BYTES: u64 = 32 * 1024 * 1024; + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ProjectResourceGraphNodeInput { + pub resource_id: String, + #[serde(default)] + pub manifest_asset_id: Option, + #[serde(default)] + pub producer_task_id: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ProjectResourceReferenceEdge { + pub id: String, + pub kind: String, + pub source_resource_id: String, + pub target_resource_id: String, + pub cyclic: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ProjectResourceTaskFlow { + pub id: String, + pub kind: String, + pub source_task_id: String, + pub target_task_id: String, + pub source_resource_ids: Vec, + pub target_resource_ids: Vec, + pub cyclic: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ProjectResourceConnectionIndex { + pub resource_id: String, + pub upstream_reference_resource_ids: Vec, + pub downstream_reference_resource_ids: Vec, + pub reference_edge_ids: Vec, + pub task_flow_ids: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ProjectResourceProducerAssignment { + pub resource_id: String, + pub task_id: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ProjectResourceDependencyDepth { + pub resource_id: String, + pub dependency_depth: u32, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ProjectResourceGraphReadModel { + pub resource_ids: Vec, + pub reference_edges: Vec, + pub task_flows: Vec, + pub connection_index: Vec, + pub producer_assignments: Vec, + pub dependency_depths: Vec, + pub unresolved_reference_resource_ids: Vec, + pub cyclic_resource_ids: Vec, + pub cyclic_task_ids: Vec, + pub producer_mapping_truncated: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct DirectedEdge { + id: String, + source_id: String, + target_id: String, +} + +#[derive(Debug, Default)] +struct CycleAnalysis { + cyclic_node_ids: BTreeSet, + cyclic_edge_ids: BTreeSet, + component_by_node: BTreeMap, +} + +#[derive(Debug, Default)] +struct MutableConnectionIndex { + upstream_reference_resource_ids: BTreeSet, + downstream_reference_resource_ids: BTreeSet, + reference_edge_ids: BTreeSet, + task_flow_ids: BTreeSet, +} + +fn stable_edge_id(kind: &str, source_id: &str, target_id: &str) -> String { + let pair = serde_json::to_string(&(source_id, target_id)) + .expect("serializing two resource graph identifiers cannot fail"); + format!("{kind}:{pair}") +} + +fn analyze_directed_cycles<'a>( + node_ids: impl IntoIterator, + edges: &[DirectedEdge], +) -> CycleAnalysis { + let mut nodes = node_ids.into_iter().cloned().collect::>(); + for edge in edges { + nodes.insert(edge.source_id.clone()); + nodes.insert(edge.target_id.clone()); + } + + let mut adjacency = nodes + .iter() + .map(|node_id| (node_id.clone(), Vec::::new())) + .collect::>(); + let mut reverse_adjacency = adjacency.clone(); + for edge in edges { + adjacency + .entry(edge.source_id.clone()) + .or_default() + .push(edge.target_id.clone()); + reverse_adjacency + .entry(edge.target_id.clone()) + .or_default() + .push(edge.source_id.clone()); + } + + let mut visited = BTreeSet::new(); + let mut finish_order = Vec::with_capacity(nodes.len()); + for root in &nodes { + if !visited.insert(root.clone()) { + continue; + } + let mut stack = vec![(root.clone(), 0usize)]; + while let Some((node_id, next_index)) = stack.last_mut() { + let neighbors = adjacency.get(node_id).map(Vec::as_slice).unwrap_or(&[]); + if let Some(next) = neighbors.get(*next_index) { + *next_index += 1; + if visited.insert(next.clone()) { + stack.push((next.clone(), 0)); + } + } else { + let completed = node_id.clone(); + stack.pop(); + finish_order.push(completed); + } + } + } + + let mut component_by_node = BTreeMap::::new(); + let mut component_sizes = Vec::::new(); + for root in finish_order.into_iter().rev() { + if component_by_node.contains_key(&root) { + continue; + } + let component_id = component_sizes.len(); + let mut size = 0usize; + let mut stack = vec![root.clone()]; + component_by_node.insert(root, component_id); + while let Some(node_id) = stack.pop() { + size += 1; + for neighbor in reverse_adjacency + .get(&node_id) + .map(Vec::as_slice) + .unwrap_or(&[]) + { + if !component_by_node.contains_key(neighbor) { + component_by_node.insert(neighbor.clone(), component_id); + stack.push(neighbor.clone()); + } + } + } + component_sizes.push(size); + } + + let mut result = CycleAnalysis::default(); + for edge in edges { + let source_component = component_by_node.get(&edge.source_id); + let target_component = component_by_node.get(&edge.target_id); + if source_component.is_some() + && source_component == target_component + && (component_sizes + .get(source_component.copied().unwrap_or_default()) + .copied() + .unwrap_or_default() + > 1 + || edge.source_id == edge.target_id) + { + result.cyclic_node_ids.insert(edge.source_id.clone()); + result.cyclic_node_ids.insert(edge.target_id.clone()); + result.cyclic_edge_ids.insert(edge.id.clone()); + } + } + result.component_by_node = component_by_node; + result +} + +fn dependency_depth_by_node( + analysis: &CycleAnalysis, + edges: &[DirectedEdge], + minimum_depth_by_node: &BTreeMap, +) -> BTreeMap { + let component_count = analysis + .component_by_node + .values() + .copied() + .max() + .map_or(0, |max_component| max_component + 1); + let mut outgoing = vec![BTreeSet::::new(); component_count]; + let mut indegree = vec![0usize; component_count]; + for edge in edges { + let Some(&source_component) = analysis.component_by_node.get(&edge.source_id) else { + continue; + }; + let Some(&target_component) = analysis.component_by_node.get(&edge.target_id) else { + continue; + }; + if source_component != target_component + && outgoing[source_component].insert(target_component) + { + indegree[target_component] += 1; + } + } + + let mut ready = indegree + .iter() + .enumerate() + .filter_map(|(component, degree)| (*degree == 0).then_some(component)) + .collect::>(); + let mut depth_by_component = vec![0u32; component_count]; + for (node_id, minimum_depth) in minimum_depth_by_node { + let Some(component) = analysis.component_by_node.get(node_id) else { + continue; + }; + depth_by_component[*component] = depth_by_component[*component].max(*minimum_depth); + } + while let Some(component) = ready.pop_first() { + for &target in &outgoing[component] { + depth_by_component[target] = + depth_by_component[target].max(depth_by_component[component].saturating_add(1)); + indegree[target] -= 1; + if indegree[target] == 0 { + ready.insert(target); + } + } + } + + analysis + .component_by_node + .iter() + .map(|(node_id, component)| { + ( + node_id.clone(), + depth_by_component + .get(*component) + .copied() + .unwrap_or_default(), + ) + }) + .collect() +} + +fn audit_asset_producers( + records: &[serde_json::Value], + task_ids: &BTreeSet, +) -> BTreeMap { + let mut candidates = BTreeMap::>::new(); + for record in records { + if record.get("recordType").and_then(serde_json::Value::as_str) + != Some("agent.runtime.canvas.asset_generate") + { + continue; + } + let Some(asset_id) = record + .get("assetId") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + continue; + }; + let Some(agent_id) = record + .get("agentId") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| task_ids.contains(*value)) + else { + continue; + }; + candidates + .entry(asset_id.to_string()) + .or_default() + .insert(agent_id.to_string()); + } + candidates + .into_iter() + .filter_map(|(asset_id, agents)| { + (agents.len() == 1).then(|| (asset_id, agents.into_iter().next().unwrap_or_default())) + }) + .collect() +} + +pub(crate) fn build_project_resource_graph( + manifest: &GameCreationAppManifest, + resources: Vec, + agent_db_records: &[serde_json::Value], + producer_mapping_truncated: bool, +) -> ProjectResourceGraphReadModel { + let resource_by_id = resources + .into_iter() + .filter_map(|mut resource| { + resource.resource_id = resource.resource_id.trim().to_string(); + (!resource.resource_id.is_empty()).then_some((resource.resource_id.clone(), resource)) + }) + .collect::>(); + let task_by_id = manifest + .tasks + .iter() + .map(|task| (task.id.clone(), task)) + .collect::>(); + let task_ids = task_by_id.keys().cloned().collect::>(); + let manifest_asset_by_id = manifest + .assets + .iter() + .map(|asset| (asset.id.clone(), asset)) + .collect::>(); + let audit_producer_by_asset_id = audit_asset_producers(agent_db_records, &task_ids); + + let mut resource_ids_by_manifest_asset = BTreeMap::>::new(); + for resource in resource_by_id.values() { + if let Some(asset_id) = resource + .manifest_asset_id + .as_deref() + .map(str::trim) + .filter(|asset_id| manifest_asset_by_id.contains_key(*asset_id)) + { + resource_ids_by_manifest_asset + .entry(asset_id.to_string()) + .or_default() + .push(resource.resource_id.clone()); + } + } + + let mut producer_by_resource_id = BTreeMap::::new(); + for resource in resource_by_id.values() { + let producer = if let Some(asset_id) = resource + .manifest_asset_id + .as_deref() + .map(str::trim) + .filter(|asset_id| { + resource_ids_by_manifest_asset + .get(*asset_id) + .is_some_and(|resource_ids| resource_ids.len() == 1) + }) { + audit_producer_by_asset_id.get(asset_id).cloned() + } else { + resource + .producer_task_id + .as_deref() + .map(str::trim) + .filter(|task_id| task_ids.contains(*task_id)) + .map(ToOwned::to_owned) + }; + if let Some(producer) = producer { + producer_by_resource_id.insert(resource.resource_id.clone(), producer); + } + } + + let mut resources_by_task = BTreeMap::>::new(); + for (resource_id, task_id) in &producer_by_resource_id { + resources_by_task + .entry(task_id.clone()) + .or_default() + .push(resource_id.clone()); + } + + let mut resources_by_external_id = BTreeMap::>::new(); + for (asset_id, resource_ids) in &resource_ids_by_manifest_asset { + if resource_ids.len() != 1 { + continue; + } + let Some(external_resource_id) = manifest_asset_by_id + .get(asset_id) + .and_then(|asset| asset.source.resource_id.as_deref()) + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + continue; + }; + resources_by_external_id + .entry(external_resource_id.to_string()) + .or_default() + .push(resource_ids[0].clone()); + } + + let mut unresolved_reference_resource_ids = BTreeSet::new(); + let mut reference_edge_by_id = BTreeMap::::new(); + for (asset_id, target_resource_ids) in &resource_ids_by_manifest_asset { + if target_resource_ids.len() != 1 { + continue; + } + let Some(asset) = manifest_asset_by_id.get(asset_id) else { + continue; + }; + let target_resource_id = &target_resource_ids[0]; + for external_reference_id in asset + .source + .reference_resource_ids + .iter() + .map(|value| value.trim()) + .filter(|value| !value.is_empty()) + .collect::>() + { + let source_candidates = resources_by_external_id + .get(external_reference_id) + .map(Vec::as_slice) + .unwrap_or(&[]); + if source_candidates.len() != 1 { + unresolved_reference_resource_ids.insert(external_reference_id.to_string()); + continue; + } + let source_resource_id = &source_candidates[0]; + if !resource_by_id.contains_key(source_resource_id) + || !resource_by_id.contains_key(target_resource_id) + { + continue; + } + let id = stable_edge_id("asset-reference", source_resource_id, target_resource_id); + reference_edge_by_id.insert( + id.clone(), + ProjectResourceReferenceEdge { + id, + kind: "asset-reference".to_string(), + source_resource_id: source_resource_id.clone(), + target_resource_id: target_resource_id.clone(), + cyclic: false, + }, + ); + } + } + let reference_directed_edges = reference_edge_by_id + .values() + .map(|edge| DirectedEdge { + id: edge.id.clone(), + source_id: edge.source_resource_id.clone(), + target_id: edge.target_resource_id.clone(), + }) + .collect::>(); + let reference_cycles = + analyze_directed_cycles(resource_by_id.keys(), &reference_directed_edges); + let reference_edges = reference_edge_by_id + .into_values() + .map(|mut edge| { + edge.cyclic = reference_cycles.cyclic_edge_ids.contains(&edge.id); + edge + }) + .collect::>(); + + let task_dependency_edges = manifest + .tasks + .iter() + .flat_map(|target_task| { + target_task + .dependencies + .iter() + .collect::>() + .into_iter() + .filter(|source_task_id| task_by_id.contains_key(*source_task_id)) + .map(|source_task_id| DirectedEdge { + id: stable_edge_id("task-flow", source_task_id, &target_task.id), + source_id: source_task_id.clone(), + target_id: target_task.id.clone(), + }) + .collect::>() + }) + .collect::>(); + let task_cycles = analyze_directed_cycles(task_by_id.keys(), &task_dependency_edges); + let task_dependency_depths = + dependency_depth_by_node(&task_cycles, &task_dependency_edges, &BTreeMap::new()); + let minimum_resource_dependency_depths = producer_by_resource_id + .iter() + .filter_map(|(resource_id, task_id)| { + task_dependency_depths + .get(task_id) + .copied() + .map(|depth| (resource_id.clone(), depth)) + }) + .collect::>(); + let resource_dependency_depths = dependency_depth_by_node( + &reference_cycles, + &reference_directed_edges, + &minimum_resource_dependency_depths, + ); + let task_flows = task_dependency_edges + .iter() + .filter_map(|edge| { + let source_resource_ids = resources_by_task.get(&edge.source_id)?; + let target_resource_ids = resources_by_task.get(&edge.target_id)?; + (!source_resource_ids.is_empty() && !target_resource_ids.is_empty()).then(|| { + ProjectResourceTaskFlow { + id: edge.id.clone(), + kind: "task-flow".to_string(), + source_task_id: edge.source_id.clone(), + target_task_id: edge.target_id.clone(), + source_resource_ids: source_resource_ids.clone(), + target_resource_ids: target_resource_ids.clone(), + cyclic: task_cycles.cyclic_edge_ids.contains(&edge.id), + } + }) + }) + .collect::>(); + + let mut connection_by_resource_id = resource_by_id + .keys() + .map(|resource_id| (resource_id.clone(), MutableConnectionIndex::default())) + .collect::>(); + for edge in &reference_edges { + if let Some(target) = connection_by_resource_id.get_mut(&edge.target_resource_id) { + target + .upstream_reference_resource_ids + .insert(edge.source_resource_id.clone()); + target.reference_edge_ids.insert(edge.id.clone()); + } + if let Some(source) = connection_by_resource_id.get_mut(&edge.source_resource_id) { + source + .downstream_reference_resource_ids + .insert(edge.target_resource_id.clone()); + source.reference_edge_ids.insert(edge.id.clone()); + } + } + for flow in &task_flows { + for resource_id in flow + .source_resource_ids + .iter() + .chain(flow.target_resource_ids.iter()) + { + if let Some(index) = connection_by_resource_id.get_mut(resource_id) { + index.task_flow_ids.insert(flow.id.clone()); + } + } + } + + ProjectResourceGraphReadModel { + resource_ids: resource_by_id.keys().cloned().collect(), + reference_edges, + task_flows, + connection_index: connection_by_resource_id + .into_iter() + .map(|(resource_id, index)| ProjectResourceConnectionIndex { + resource_id, + upstream_reference_resource_ids: index + .upstream_reference_resource_ids + .into_iter() + .collect(), + downstream_reference_resource_ids: index + .downstream_reference_resource_ids + .into_iter() + .collect(), + reference_edge_ids: index.reference_edge_ids.into_iter().collect(), + task_flow_ids: index.task_flow_ids.into_iter().collect(), + }) + .collect(), + producer_assignments: producer_by_resource_id + .into_iter() + .map(|(resource_id, task_id)| ProjectResourceProducerAssignment { + resource_id, + task_id, + }) + .collect(), + dependency_depths: resource_dependency_depths + .into_iter() + .map( + |(resource_id, dependency_depth)| ProjectResourceDependencyDepth { + resource_id, + dependency_depth, + }, + ) + .collect(), + unresolved_reference_resource_ids: unresolved_reference_resource_ids.into_iter().collect(), + cyclic_resource_ids: reference_cycles.cyclic_node_ids.into_iter().collect(), + cyclic_task_ids: task_cycles.cyclic_node_ids.into_iter().collect(), + producer_mapping_truncated, + } +} + +pub(crate) fn read_project_resource_graph_at( + root: &Path, + expected_project_id: &str, + resources: Vec, +) -> Result { + let manifest = read_existing_manifest_for_project(root)?; + if manifest.project_id != expected_project_id { + return Err("资源依赖图项目身份不匹配".to_string()); + } + let (records, truncated) = + read_agent_db_records_bounded(root, RESOURCE_GRAPH_AGENT_DB_READ_BYTES)?; + Ok(build_project_resource_graph( + &manifest, resources, &records, truncated, + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn resource( + resource_id: &str, + manifest_asset_id: Option<&str>, + producer_task_id: Option<&str>, + ) -> ProjectResourceGraphNodeInput { + ProjectResourceGraphNodeInput { + resource_id: resource_id.to_string(), + manifest_asset_id: manifest_asset_id.map(ToOwned::to_owned), + producer_task_id: producer_task_id.map(ToOwned::to_owned), + } + } + + fn asset( + id: &str, + external_resource_id: Option<&str>, + references: &[&str], + external_task_id: Option<&str>, + ) -> GameCreationAppAssetManifestEntry { + GameCreationAppAssetManifestEntry { + id: id.to_string(), + kind: "test".to_string(), + media_type: "image/png".to_string(), + local_path: format!("assets/{id}.png"), + source: GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Canvas, + canvas_project_id: None, + resource_id: external_resource_id.map(ToOwned::to_owned), + asset_object_id: None, + task_id: external_task_id.map(ToOwned::to_owned), + prompt: None, + model: None, + generation_route: None, + generation_kind: None, + reference_resource_ids: references.iter().map(|value| value.to_string()).collect(), + }, + } + } + + fn task(id: &str, dependencies: &[&str]) -> GameCreationAppTaskState { + GameCreationAppTaskState { + id: id.to_string(), + title: id.to_string(), + group: GameCreationAppAgentGroup::Art, + role: "test".to_string(), + dependencies: dependencies.iter().map(|value| value.to_string()).collect(), + artifacts: Vec::new(), + acceptance_criteria: Vec::new(), + status: GameCreationAppTaskStatus::Completed, + } + } + + fn manifest( + tasks: Vec, + assets: Vec, + ) -> GameCreationAppManifest { + let mut manifest = new_game_creation_app_manifest("graph-project", "Graph project"); + manifest.tasks = tasks; + manifest.assets = assets; + manifest + } + + #[test] + fn graph_uses_runtime_agent_identity_instead_of_external_task_id() { + let manifest = manifest( + vec![ + task("art-director", &[]), + task("design-foundation", &["art-director"]), + ], + vec![ + asset("spec", Some("external-spec"), &[], Some("task-1")), + asset( + "ui", + Some("external-ui"), + &["external-spec"], + Some("task-2"), + ), + ], + ); + let records = vec![ + serde_json::json!({ + "recordType": "agent.runtime.canvas.asset_generate", + "assetId": "spec", + "agentId": "art-director" + }), + serde_json::json!({ + "recordType": "agent.runtime.canvas.asset_generate", + "assetId": "ui", + "agentId": "design-foundation" + }), + ]; + let graph = build_project_resource_graph( + &manifest, + vec![ + resource("asset:spec", Some("spec"), None), + resource("asset:ui", Some("ui"), None), + ], + &records, + false, + ); + + assert_eq!(graph.reference_edges.len(), 1); + assert_eq!(graph.task_flows.len(), 1); + assert_eq!(graph.task_flows[0].source_task_id, "art-director"); + assert_eq!(graph.task_flows[0].target_task_id, "design-foundation"); + assert_eq!( + graph + .producer_assignments + .iter() + .map(|assignment| (assignment.resource_id.as_str(), assignment.task_id.as_str())) + .collect::>(), + BTreeMap::from([ + ("asset:spec", "art-director"), + ("asset:ui", "design-foundation"), + ]), + ); + assert_eq!( + graph + .dependency_depths + .iter() + .map(|depth| (depth.resource_id.as_str(), depth.dependency_depth)) + .collect::>(), + BTreeMap::from([("asset:spec", 0), ("asset:ui", 1)]), + ); + assert!(graph + .producer_assignments + .iter() + .all(|assignment| assignment.task_id != "task-1" && assignment.task_id != "task-2")); + } + + #[test] + fn graph_omits_task_flow_without_reliable_runtime_producer_evidence() { + let manifest = manifest( + vec![ + task("art-director", &[]), + task("design-foundation", &["art-director"]), + ], + vec![ + asset("spec", Some("external-spec"), &[], Some("art-director")), + asset( + "ui", + Some("external-ui"), + &["external-spec"], + Some("design-foundation"), + ), + ], + ); + let graph = build_project_resource_graph( + &manifest, + vec![ + resource("asset:spec", Some("spec"), None), + resource("asset:ui", Some("ui"), None), + ], + &[], + false, + ); + + assert_eq!(graph.reference_edges.len(), 1); + assert!(graph.task_flows.is_empty()); + assert!(graph.producer_assignments.is_empty()); + assert_eq!( + graph + .dependency_depths + .iter() + .map(|depth| (depth.resource_id.as_str(), depth.dependency_depth)) + .collect::>(), + BTreeMap::from([("asset:spec", 0), ("asset:ui", 1)]), + ); + } + + #[test] + fn graph_aggregates_flows_filters_missing_resources_and_detects_cycles_iteratively() { + let manifest = manifest( + vec![task("task-a", &["task-b"]), task("task-b", &["task-a"])], + vec![ + asset( + "a", + Some("external-a"), + &["external-b", "missing"], + Some("task-1"), + ), + asset("b", Some("external-b"), &["external-a"], Some("task-2")), + ], + ); + let records = vec![ + serde_json::json!({"recordType": "agent.runtime.canvas.asset_generate", "assetId": "a", "agentId": "task-a"}), + serde_json::json!({"recordType": "agent.runtime.canvas.asset_generate", "assetId": "b", "agentId": "task-b"}), + ]; + let graph = build_project_resource_graph( + &manifest, + vec![ + resource("asset:a", Some("a"), None), + resource("asset:b", Some("b"), None), + resource("task-a:artifact", None, Some("task-a")), + resource("task-b:artifact", None, Some("task-b")), + ], + &records, + false, + ); + + assert_eq!(graph.reference_edges.len(), 2); + assert!(graph.reference_edges.iter().all(|edge| edge.cyclic)); + assert_eq!(graph.task_flows.len(), 2); + assert!(graph.task_flows.iter().all(|flow| flow.cyclic)); + assert_eq!(graph.unresolved_reference_resource_ids, vec!["missing"]); + assert_eq!(graph.cyclic_resource_ids, vec!["asset:a", "asset:b"]); + assert_eq!(graph.cyclic_task_ids, vec!["task-a", "task-b"]); + assert!( + graph + .task_flows + .iter() + .all(|flow| flow.source_resource_ids.len() == 2 + && flow.target_resource_ids.len() == 2) + ); + } + + #[test] + fn graph_handles_4096_task_chain_without_recursive_traversal_or_cartesian_edges() { + let tasks = (0..4096) + .map(|index| { + let id = format!("task:{index}"); + let dependencies = if index == 0 { + Vec::new() + } else { + vec![format!("task:{}", index - 1)] + }; + GameCreationAppTaskState { + id: id.clone(), + title: id, + group: GameCreationAppAgentGroup::Code, + role: "test".to_string(), + dependencies, + artifacts: Vec::new(), + acceptance_criteria: Vec::new(), + status: GameCreationAppTaskStatus::Completed, + } + }) + .collect::>(); + let resources = (0..4096) + .map(|index| { + resource( + &format!("resource:{index}"), + None, + Some(&format!("task:{index}")), + ) + }) + .collect::>(); + let graph = + build_project_resource_graph(&manifest(tasks, Vec::new()), resources, &[], false); + + assert_eq!(graph.task_flows.len(), 4095); + assert_eq!(graph.connection_index.len(), 4096); + assert!(graph + .connection_index + .iter() + .all(|index| index.task_flow_ids.len() <= 2)); + assert_eq!( + graph + .dependency_depths + .iter() + .find(|depth| depth.resource_id == "resource:4095") + .map(|depth| depth.dependency_depth), + Some(4095), + ); + } + + #[test] + fn dependency_depth_collapses_cycles_before_following_downstream_tasks() { + let graph = build_project_resource_graph( + &manifest( + vec![ + task("source", &[]), + task("cycle-a", &["source", "cycle-b"]), + task("cycle-b", &["cycle-a"]), + task("target", &["cycle-b"]), + ], + Vec::new(), + ), + vec![ + resource("source-resource", None, Some("source")), + resource("cycle-a-resource", None, Some("cycle-a")), + resource("cycle-b-resource", None, Some("cycle-b")), + resource("target-resource", None, Some("target")), + ], + &[], + false, + ); + + let depths = graph + .dependency_depths + .iter() + .map(|depth| (depth.resource_id.as_str(), depth.dependency_depth)) + .collect::>(); + assert_eq!(depths["source-resource"], 0); + assert_eq!(depths["cycle-a-resource"], 1); + assert_eq!(depths["cycle-b-resource"], 1); + assert_eq!(depths["target-resource"], 2); + } + + #[test] + fn dependency_depth_uses_reference_sccs_after_task_depth_floors() { + let graph = build_project_resource_graph( + &manifest( + vec![ + task("source-task", &[]), + task("late-task", &["source-task"]), + ], + vec![ + asset("base", Some("external-base"), &[], None), + asset( + "cycle-a", + Some("external-cycle-a"), + &["external-base", "external-cycle-b"], + None, + ), + asset( + "cycle-b", + Some("external-cycle-b"), + &["external-cycle-a"], + None, + ), + asset( + "target", + Some("external-target"), + &["external-cycle-b"], + None, + ), + ], + ), + vec![ + resource("asset:base", Some("base"), None), + resource("asset:cycle-a", Some("cycle-a"), None), + resource("asset:cycle-b", Some("cycle-b"), None), + resource("asset:target", Some("target"), None), + ], + &[ + serde_json::json!({"recordType": "agent.runtime.canvas.asset_generate", "assetId": "base", "agentId": "source-task"}), + serde_json::json!({"recordType": "agent.runtime.canvas.asset_generate", "assetId": "cycle-a", "agentId": "late-task"}), + ], + false, + ); + + let depths = graph + .dependency_depths + .iter() + .map(|depth| (depth.resource_id.as_str(), depth.dependency_depth)) + .collect::>(); + assert_eq!(depths["asset:base"], 0); + assert_eq!(depths["asset:cycle-a"], 1); + assert_eq!(depths["asset:cycle-b"], 1); + assert_eq!(depths["asset:target"], 2); + assert_eq!( + graph.cyclic_resource_ids, + vec!["asset:cycle-a", "asset:cycle-b"] + ); + assert_eq!(graph.task_flows.len(), 1); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/runnable_versions.rs b/apps/ai-game-creator-shell/src-tauri/src/project/runnable_versions.rs new file mode 100644 index 000000000..16820415c --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/project/runnable_versions.rs @@ -0,0 +1,587 @@ +use super::*; + +const RUNNABLE_ARTIFACT_DESCRIPTOR_SCHEMA_VERSION: &str = "game-creator-runnable-artifact.v1"; +const RUNNABLE_ARTIFACT_MAX_FILES: usize = 4_096; +const RUNNABLE_ARTIFACT_MAX_FILE_BYTES: u64 = 64 * 1024 * 1024; +const RUNNABLE_ARTIFACT_MAX_TOTAL_BYTES: u64 = 512 * 1024 * 1024; + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct RunnableArtifactDescriptor { + schema_version: String, + project_id: String, + version_id: String, + project_revision: u64, + artifact_sha256: String, + created_at: u64, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct WrittenRunnableArtifactSnapshot { + artifact_sha256: String, + created_at: u64, +} + +fn runnable_version_id(revision: u64) -> String { + format!("runnable-r{revision}") +} + +fn runnable_version_root(root: &Path, version_id: &str) -> PathBuf { + root.join(".agent/runnable-versions").join(version_id) +} + +fn collect_runnable_artifact_files( + base: &Path, + directory: &Path, + output: &mut Vec<(String, PathBuf, u64)>, + total_bytes: &mut u64, +) -> Result<(), String> { + let metadata = + fs::symlink_metadata(directory).map_err(|_| "可运行版本源目录不存在".to_string())?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err("可运行版本源目录必须是普通目录".to_string()); + } + let mut entries = fs::read_dir(directory) + .map_err(|_| "读取可运行版本源目录失败".to_string())? + .collect::, _>>() + .map_err(|_| "读取可运行版本源目录失败".to_string())?; + entries.sort_by_key(|entry| entry.file_name()); + for entry in entries { + let path = entry.path(); + let metadata = + fs::symlink_metadata(&path).map_err(|_| "读取可运行版本源文件失败".to_string())?; + if metadata.file_type().is_symlink() { + return Err("可运行版本不允许包含符号链接".to_string()); + } + if metadata.is_dir() { + collect_runnable_artifact_files(base, &path, output, total_bytes)?; + continue; + } + if !metadata.is_file() { + return Err("可运行版本只允许包含普通文件".to_string()); + } + if metadata.len() > RUNNABLE_ARTIFACT_MAX_FILE_BYTES { + return Err("可运行版本包含超限文件".to_string()); + } + *total_bytes = total_bytes + .checked_add(metadata.len()) + .ok_or_else(|| "可运行版本总大小已溢出".to_string())?; + if *total_bytes > RUNNABLE_ARTIFACT_MAX_TOTAL_BYTES { + return Err("可运行版本总大小超过 512 MiB".to_string()); + } + let relative = path + .strip_prefix(base) + .map_err(|_| "可运行版本源路径越界".to_string())? + .components() + .map(|component| component.as_os_str().to_string_lossy()) + .collect::>() + .join("/"); + if relative.is_empty() || relative.chars().any(char::is_control) { + return Err("可运行版本源路径无效".to_string()); + } + output.push((relative, path, metadata.len())); + if output.len() > RUNNABLE_ARTIFACT_MAX_FILES { + return Err("可运行版本文件数量超过 4096".to_string()); + } + } + Ok(()) +} + +fn read_runnable_source_file(path: &Path, expected_len: u64) -> Result, String> { + let mut options = fs::OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_NOFOLLOW); + } + let mut file = options + .open(path) + .map_err(|_| "读取可运行版本源文件失败".to_string())?; + let metadata = file + .metadata() + .map_err(|_| "读取可运行版本源文件元数据失败".to_string())?; + if !metadata.is_file() || metadata.len() != expected_len { + return Err("可运行版本源文件在登记期间发生变化".to_string()); + } + let mut bytes = Vec::with_capacity(usize::try_from(expected_len).unwrap_or(0)); + file.read_to_end(&mut bytes) + .map_err(|_| "读取可运行版本源文件失败".to_string())?; + if u64::try_from(bytes.len()).ok() != Some(expected_len) { + return Err("可运行版本源文件在登记期间发生变化".to_string()); + } + Ok(bytes) +} + +fn hash_runnable_artifact_files(files: &[(String, Vec)]) -> String { + let mut hasher = Sha256::new(); + for (relative, bytes) in files { + hasher.update((relative.len() as u64).to_be_bytes()); + hasher.update(relative.as_bytes()); + hasher.update((bytes.len() as u64).to_be_bytes()); + hasher.update(bytes); + } + format!("{:x}", hasher.finalize()) +} + +fn read_runnable_artifact_files(root: &Path) -> Result)>, String> { + let mut sources = Vec::new(); + let mut total_bytes = 0; + collect_runnable_artifact_files(root, &root.join("game"), &mut sources, &mut total_bytes)?; + let assets = root.join("assets"); + if assets.exists() { + collect_runnable_artifact_files(root, &assets, &mut sources, &mut total_bytes)?; + } + sources.sort_by(|left, right| left.0.cmp(&right.0)); + sources + .into_iter() + .map(|(relative, path, expected_len)| { + read_runnable_source_file(&path, expected_len).map(|bytes| (relative, bytes)) + }) + .collect() +} + +fn write_runnable_artifact_snapshot( + root: &Path, + project_id: &str, + version_id: &str, + revision: u64, + created_at: u64, +) -> Result { + let files = read_runnable_artifact_files(root)?; + if !files.iter().any(|(path, _)| path == "game/index.html") { + return Err("项目完整性检查失败:缺少 game/index.html".to_string()); + } + let artifact_sha256 = hash_runnable_artifact_files(&files); + let versions_root = root.join(".agent/runnable-versions"); + fs::create_dir_all(&versions_root).map_err(|_| "创建可运行版本目录失败".to_string())?; + let final_root = runnable_version_root(root, version_id); + if final_root.exists() { + let final_metadata = fs::symlink_metadata(&final_root) + .map_err(|_| "可运行版本快照冲突:无法读取已有版本目录".to_string())?; + if final_metadata.file_type().is_symlink() || !final_metadata.is_dir() { + return Err("可运行版本快照冲突:已有版本目录无效".to_string()); + } + let descriptor_path = final_root.join("version.json"); + let descriptor_metadata = fs::symlink_metadata(&descriptor_path) + .map_err(|_| "可运行版本快照冲突:无法读取已有版本描述".to_string())?; + if descriptor_metadata.file_type().is_symlink() || !descriptor_metadata.is_file() { + return Err("可运行版本快照冲突:已有版本描述不是普通文件".to_string()); + } + let descriptor = serde_json::from_str::( + &fs::read_to_string(&descriptor_path) + .map_err(|_| "可运行版本快照冲突:无法读取已有版本描述".to_string())?, + ) + .map_err(|_| "可运行版本快照冲突:已有版本描述无效".to_string())?; + if descriptor.schema_version != RUNNABLE_ARTIFACT_DESCRIPTOR_SCHEMA_VERSION + || descriptor.project_id != project_id + || descriptor.version_id != version_id + || descriptor.project_revision != revision + { + return Err("可运行版本快照冲突:已有版本身份或 revision 不一致".to_string()); + } + let existing_files = read_runnable_artifact_files(&final_root.join("artifact")) + .map_err(|error| format!("可运行版本快照冲突:{error}"))?; + let existing_sha256 = hash_runnable_artifact_files(&existing_files); + if existing_sha256 != descriptor.artifact_sha256 + || descriptor.artifact_sha256 != artifact_sha256 + { + return Err("可运行版本快照冲突:已有版本产物摘要不一致".to_string()); + } + return Ok(WrittenRunnableArtifactSnapshot { + artifact_sha256: descriptor.artifact_sha256, + created_at: descriptor.created_at, + }); + } + let stage_root = versions_root.join(format!( + ".tmp-{version_id}-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + )); + let stage_artifact = stage_root.join("artifact"); + let install_result = (|| { + for (relative, bytes) in &files { + let target = stage_artifact.join(relative); + if let Some(parent) = target.parent() { + fs::create_dir_all(parent).map_err(|_| "创建可运行版本快照目录失败".to_string())?; + } + fs::write(&target, bytes).map_err(|_| "写入可运行版本快照失败".to_string())?; + } + let descriptor = RunnableArtifactDescriptor { + schema_version: RUNNABLE_ARTIFACT_DESCRIPTOR_SCHEMA_VERSION.to_string(), + project_id: project_id.to_string(), + version_id: version_id.to_string(), + project_revision: revision, + artifact_sha256: artifact_sha256.clone(), + created_at, + }; + let payload = serde_json::to_string_pretty(&descriptor) + .map_err(|_| "序列化可运行版本描述失败".to_string())?; + fs::write(stage_root.join("version.json"), format!("{payload}\n")) + .map_err(|_| "写入可运行版本描述失败".to_string())?; + fs::rename(&stage_root, &final_root).map_err(|_| "安装可运行版本快照失败".to_string())?; + Ok(()) + })(); + if install_result.is_err() { + let _ = fs::remove_dir_all(&stage_root); + } + install_result.map(|()| WrittenRunnableArtifactSnapshot { + artifact_sha256, + created_at, + }) +} + +pub(crate) fn validate_runnable_game_version_artifact_at( + root: &Path, + version: &RunnableGameVersion, +) -> Result { + let version_root = runnable_version_root(root, &version.version_id); + let version_root_metadata = fs::symlink_metadata(&version_root) + .map_err(|_| "可运行版本已损坏:版本目录不存在".to_string())?; + if version_root_metadata.file_type().is_symlink() || !version_root_metadata.is_dir() { + return Err("可运行版本已损坏:版本目录无效".to_string()); + } + let descriptor_path = version_root.join("version.json"); + let metadata = fs::symlink_metadata(&descriptor_path) + .map_err(|_| "可运行版本已损坏:版本描述不存在".to_string())?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err("可运行版本已损坏:版本描述不是普通文件".to_string()); + } + let descriptor = serde_json::from_str::( + &fs::read_to_string(&descriptor_path) + .map_err(|_| "可运行版本已损坏:无法读取版本描述".to_string())?, + ) + .map_err(|_| "可运行版本已损坏:版本描述无效".to_string())?; + if descriptor.schema_version != RUNNABLE_ARTIFACT_DESCRIPTOR_SCHEMA_VERSION + || descriptor.project_id != version.project_id + || descriptor.version_id != version.version_id + { + return Err("可运行版本已损坏:版本身份不一致".to_string()); + } + if descriptor.project_revision != version.project_revision { + return Err("可运行版本 revision 不一致".to_string()); + } + if descriptor.artifact_sha256 != version.artifact_sha256 + || descriptor.created_at != version.created_at + { + return Err("可运行版本已损坏:版本描述与 manifest 不一致".to_string()); + } + let artifact_root = version_root.join("artifact"); + let files = read_runnable_artifact_files(&artifact_root) + .map_err(|error| format!("可运行版本已损坏:{error}"))?; + if hash_runnable_artifact_files(&files) != version.artifact_sha256 { + return Err("可运行版本已损坏:产物摘要不一致".to_string()); + } + Ok(artifact_root) +} + +pub(crate) fn register_current_runnable_game_version_at( + root: &Path, + revision: u64, + validation_agent_id: &str, + validation_run_id: &str, + report_path: &str, +) -> Result { + let manifest_path = root.join(".agent/manifest.json"); + let mut manifest = read_manifest(&manifest_path)?; + if let Some(existing) = manifest + .runnable_versions + .iter() + .find(|version| version.project_revision == revision) + { + validate_runnable_game_version_artifact_at(root, existing)?; + return Ok(existing.clone()); + } + if manifest + .runnable_versions + .last() + .is_some_and(|version| version.project_revision >= revision) + { + return Err("可运行版本 revision 必须严格递增".to_string()); + } + let version_id = runnable_version_id(revision); + let created_at = unix_timestamp_millis(); + let snapshot = write_runnable_artifact_snapshot( + root, + &manifest.project_id, + &version_id, + revision, + created_at, + )?; + let created_at = snapshot.created_at; + let parent_version_id = manifest + .runnable_versions + .last() + .map(|version| version.version_id.clone()); + let created_reason = if parent_version_id.is_some() { + RunnableGameVersionCreatedReason::AgentRevision + } else { + RunnableGameVersionCreatedReason::Initial + }; + let mut resource_bindings = manifest + .assets + .iter() + .map(|asset| GameIterationVersionResourceBinding { + slot_id: asset.id.clone(), + resource_id: asset.id.clone(), + }) + .collect::>(); + resource_bindings.sort_by(|left, right| left.slot_id.cmp(&right.slot_id)); + let version = RunnableGameVersion { + schema_version: RUNNABLE_GAME_VERSION_SCHEMA_VERSION.to_string(), + version_id: version_id.clone(), + project_id: manifest.project_id.clone(), + parent_version_id, + project_revision: revision, + artifact_path: format!(".agent/runnable-versions/{version_id}/artifact"), + artifact_sha256: snapshot.artifact_sha256, + entry_path: "game/index.html".to_string(), + resource_bindings, + created_reason, + validation: RunnableGameVersionValidation { + static_smoke_passed: true, + preview_validate_passed: true, + playtest_passed: true, + agent_id: validation_agent_id.to_string(), + run_id: validation_run_id.to_string(), + report_path: report_path.to_string(), + }, + created_at, + }; + manifest.runnable_versions.push(version.clone()); + manifest.current_runnable_version_id = Some(version.version_id.clone()); + write_manifest(&manifest_path, &manifest)?; + // manifest 是可运行版本的唯一事实源;快照和 manifest 已提交后,审计写入失败 + // 不能再把一次成功登记伪装成失败,否则重试会看到版本存在却缺少首轮成功结果。 + let _ = append_agent_db_record( + root, + serde_json::json!({ + "recordType": "project.runnable_version.registered", + "versionId": version.version_id, + "projectRevision": version.project_revision, + "parentVersionId": version.parent_version_id, + "artifactSha256": version.artifact_sha256, + "validationAgentId": validation_agent_id, + "validationRunId": validation_run_id, + }), + ); + Ok(version) +} + +pub(crate) fn select_current_runnable_game_version_at( + root: &Path, + expected_project_id: &str, + version_id: &str, +) -> Result<(GameCreationAppManifest, RunnableGameVersion, PathBuf), String> { + let (mut manifest, version, artifact_root) = + resolve_runnable_game_version_at(root, expected_project_id, version_id)?; + let manifest_path = root.join(".agent/manifest.json"); + manifest.current_runnable_version_id = Some(version.version_id.clone()); + write_manifest(&manifest_path, &manifest)?; + Ok((manifest, version, artifact_root)) +} + +pub(crate) fn resolve_runnable_game_version_at( + root: &Path, + expected_project_id: &str, + version_id: &str, +) -> Result<(GameCreationAppManifest, RunnableGameVersion, PathBuf), String> { + let manifest_path = root.join(".agent/manifest.json"); + let manifest = read_manifest(&manifest_path)?; + if manifest.project_id != expected_project_id { + return Err("可运行版本项目身份不一致".to_string()); + } + let version = manifest + .runnable_versions + .iter() + .find(|version| version.version_id == version_id) + .cloned() + .ok_or_else(|| "当前无可运行版本".to_string())?; + let artifact_root = validate_runnable_game_version_artifact_at(root, &version)?; + Ok((manifest, version, artifact_root)) +} + +fn unix_timestamp_millis() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + .try_into() + .unwrap_or(u64::MAX) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn runnable_version_test_root(label: &str) -> PathBuf { + std::env::temp_dir().join(format!( + "genarrative-runnable-version-{label}-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + )) + } + + fn init_runnable_version_test_project(label: &str) -> PathBuf { + let root = runnable_version_test_root(label); + fs::create_dir_all(root.join("game")).expect("create game directory"); + fs::create_dir_all(root.join("assets")).expect("create assets directory"); + fs::write(root.join("game/index.html"), "
revision one
") + .expect("write game entry"); + fs::write(root.join("assets/player.txt"), "player one").expect("write game asset"); + let manifest = new_game_creation_app_manifest( + format!("project-{label}"), + format!("可运行版本测试 {label}"), + ); + write_manifest(&root.join(".agent/manifest.json"), &manifest) + .expect("write project manifest"); + root + } + + fn register_test_version(root: &Path, revision: u64) -> RunnableGameVersion { + register_current_runnable_game_version_at( + root, + revision, + "program-agent", + &format!("run-{revision}"), + &format!( + ".agent/runtime/browser-validations/program-agent/run-{revision}/1/validation.json" + ), + ) + .expect("register runnable version") + } + + #[test] + fn runnable_versions_register_idempotently_and_keep_immutable_snapshots() { + let root = init_runnable_version_test_project("register"); + let first = register_test_version(&root, 1); + assert_eq!(first.version_id, "runnable-r1"); + assert_eq!(first.parent_version_id, None); + assert_eq!( + first.created_reason, + RunnableGameVersionCreatedReason::Initial + ); + assert_eq!( + fs::read_to_string( + root.join(".agent/runnable-versions/runnable-r1/artifact/game/index.html") + ) + .expect("read first snapshot"), + "
revision one
" + ); + + fs::write(root.join("game/index.html"), "
revision two
") + .expect("update working tree"); + fs::write(root.join("assets/player.txt"), "player two").expect("update working tree asset"); + assert_eq!(register_test_version(&root, 1), first); + + let second = register_test_version(&root, 2); + assert_eq!(second.parent_version_id.as_deref(), Some("runnable-r1")); + assert_eq!( + second.created_reason, + RunnableGameVersionCreatedReason::AgentRevision + ); + assert_ne!(second.artifact_sha256, first.artifact_sha256); + assert_eq!( + fs::read_to_string( + root.join(".agent/runnable-versions/runnable-r1/artifact/game/index.html") + ) + .expect("read unchanged first snapshot"), + "
revision one
" + ); + + let manifest = + read_manifest(&root.join(".agent/manifest.json")).expect("read registered manifest"); + assert_eq!(manifest.runnable_versions, vec![first, second.clone()]); + assert_eq!( + manifest.current_runnable_version_id.as_deref(), + Some(second.version_id.as_str()) + ); + fs::remove_dir_all(root).ok(); + } + + #[test] + fn runnable_snapshot_retry_reuses_original_descriptor_timestamp() { + let root = init_runnable_version_test_project("orphan-retry"); + let project_id = "project-orphan-retry"; + let first = write_runnable_artifact_snapshot(&root, project_id, "runnable-r1", 1, 100) + .expect("write orphan snapshot"); + let retried = write_runnable_artifact_snapshot(&root, project_id, "runnable-r1", 1, 200) + .expect("reuse orphan snapshot"); + assert_eq!(retried, first); + assert_eq!(retried.created_at, 100); + fs::remove_dir_all(root).ok(); + } + + #[test] + fn runnable_version_validation_rejects_digest_and_revision_tampering() { + let root = init_runnable_version_test_project("tamper"); + let version = register_test_version(&root, 1); + let snapshot_entry = + root.join(".agent/runnable-versions/runnable-r1/artifact/game/index.html"); + fs::write(&snapshot_entry, "
tampered
").expect("tamper snapshot"); + let error = validate_runnable_game_version_artifact_at(&root, &version) + .expect_err("reject digest tampering"); + assert!(error.contains("产物摘要不一致"), "{error}"); + + fs::write(&snapshot_entry, "
revision one
").expect("restore snapshot"); + let descriptor_path = root.join(".agent/runnable-versions/runnable-r1/version.json"); + let mut descriptor = serde_json::from_str::( + &fs::read_to_string(&descriptor_path).expect("read descriptor"), + ) + .expect("parse descriptor"); + descriptor.project_revision = 2; + fs::write( + &descriptor_path, + format!( + "{}\n", + serde_json::to_string_pretty(&descriptor).expect("serialize descriptor") + ), + ) + .expect("tamper descriptor revision"); + let error = validate_runnable_game_version_artifact_at(&root, &version) + .expect_err("reject revision tampering"); + assert_eq!(error, "可运行版本 revision 不一致"); + fs::remove_dir_all(root).ok(); + } + + #[test] + fn runnable_version_selection_serves_snapshot_and_manifest_history_is_append_only() { + let root = init_runnable_version_test_project("select"); + let version = register_test_version(&root, 1); + fs::write(root.join("game/index.html"), "
unverified edit
") + .expect("edit working tree after registration"); + + let (selected_manifest, selected, artifact_root) = + select_current_runnable_game_version_at(&root, "project-select", &version.version_id) + .expect("select registered version"); + assert_eq!(selected, version); + assert_eq!( + fs::read_to_string(artifact_root.join("game/index.html")) + .expect("read selected snapshot"), + "
revision one
" + ); + assert_eq!( + selected_manifest.current_runnable_version_id.as_deref(), + Some("runnable-r1") + ); + + let manifest_path = root.join(".agent/manifest.json"); + let stable_payload = fs::read(&manifest_path).expect("read stable manifest"); + let mut mutated = selected_manifest; + mutated.runnable_versions[0].artifact_sha256 = "b".repeat(64); + let error = write_manifest(&manifest_path, &mutated) + .expect_err("reject mutation of registered version"); + assert!(error.contains("不可修改、删除或重排"), "{error}"); + assert_eq!( + fs::read(&manifest_path).expect("read unchanged manifest"), + stable_payload + ); + fs::remove_dir_all(root).ok(); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/resource_inspect.rs b/apps/ai-game-creator-shell/src-tauri/src/resource_inspect.rs new file mode 100644 index 000000000..129b01f75 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/resource_inspect.rs @@ -0,0 +1,441 @@ +use crate::image_inspect::{ + same_open_file_identity, same_open_file_snapshot, validate_agent_runtime_inspection_ancestors, +}; +use crate::project::{ + normalize_relative_path, open_project_snapshot_regular_file, + reject_sensitive_project_file_read, resolve_local_project_path, +}; +use base64::Engine as _; +use serde::Serialize; +use std::io::Read; +use std::path::Path; + +const PROJECT_TEXT_PREVIEW_MAX_FILE_BYTES: u64 = 2 * 1024 * 1024; +const PROJECT_MEDIA_PREVIEW_MAX_FILE_BYTES: u64 = 32 * 1024 * 1024; + +#[derive(Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct LocalProjectTextPreview { + pub(crate) path: String, + pub(crate) media_type: String, + pub(crate) byte_len: u64, + pub(crate) content: String, +} + +#[derive(Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct LocalProjectMediaPreview { + pub(crate) path: String, + pub(crate) media_type: String, + pub(crate) byte_len: u64, + pub(crate) data_url: String, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum ProjectMediaPreviewKind { + Art, + Audio, +} + +pub(crate) fn is_supported_project_text_resource(path: &str, media_type: &str) -> bool { + let media_type = media_type.trim().to_ascii_lowercase(); + matches!( + path_extension(path).as_deref(), + Some("md" | "markdown" | "mdx" | "txt" | "json" | "yaml" | "yml" | "toml") + ) && (media_type.is_empty() + || media_type.starts_with("text/") + || media_type.contains("json") + || media_type.contains("yaml") + || matches!( + media_type.as_str(), + "项目文档" | "application/toml" | "application/mdx" + )) +} + +pub(crate) fn is_supported_project_art_media_resource(path: &str, media_type: &str) -> bool { + let media_type = media_type.trim().to_ascii_lowercase(); + matches!( + path_extension(path).as_deref(), + Some("gif" | "svg" | "avif" | "bmp" | "mp4" | "webm" | "mov") + ) || media_type.starts_with("video/") + || media_type == "image/svg+xml" +} + +pub(crate) fn is_supported_project_audio_resource(path: &str, media_type: &str) -> bool { + let media_type = media_type.trim().to_ascii_lowercase(); + matches!( + path_extension(path).as_deref(), + Some("mp3" | "wav" | "ogg" | "m4a" | "aac" | "flac" | "opus") + ) || media_type.starts_with("audio/") +} + +pub(crate) fn load_local_project_text_preview( + root: &Path, + relative_path: &str, +) -> Result { + let normalized = normalize_relative_path(relative_path.trim())?; + reject_sensitive_project_file_read(&normalized)?; + let media_type = project_text_media_type(&normalized) + .ok_or_else(|| "文档预览只支持 Markdown、文本、JSON、YAML 和 TOML".to_string())?; + let bytes = read_stable_project_resource( + root, + &normalized, + PROJECT_TEXT_PREVIEW_MAX_FILE_BYTES, + "项目文档", + )?; + let content = + String::from_utf8(bytes).map_err(|_| "文档预览只支持 UTF-8 编码的文本文件".to_string())?; + Ok(LocalProjectTextPreview { + path: normalized, + media_type: media_type.to_string(), + byte_len: content.len() as u64, + content, + }) +} + +pub(crate) fn load_local_project_media_preview( + root: &Path, + relative_path: &str, + kind: ProjectMediaPreviewKind, +) -> Result { + let normalized = normalize_relative_path(relative_path.trim())?; + reject_sensitive_project_file_read(&normalized)?; + let bytes = read_stable_project_resource( + root, + &normalized, + PROJECT_MEDIA_PREVIEW_MAX_FILE_BYTES, + "项目媒体资源", + )?; + if bytes.is_empty() { + return Err("媒体文件为空,无法预览".to_string()); + } + let media_type = detect_project_media_type(&normalized, &bytes, kind)?; + Ok(LocalProjectMediaPreview { + path: normalized, + media_type: media_type.to_string(), + byte_len: bytes.len() as u64, + data_url: format!( + "data:{media_type};base64,{}", + base64::engine::general_purpose::STANDARD.encode(bytes) + ), + }) +} + +fn read_stable_project_resource( + root: &Path, + normalized: &str, + max_bytes: u64, + label: &str, +) -> Result, String> { + let absolute = resolve_local_project_path(root, normalized)?; + validate_agent_runtime_inspection_ancestors(root, &absolute)?; + let (mut file, initial_metadata) = open_project_snapshot_regular_file(&absolute, label)?; + if initial_metadata.len() > max_bytes { + return Err(format!("{label}不能超过 {} MiB", max_bytes / 1024 / 1024)); + } + let mut bytes = Vec::with_capacity(initial_metadata.len() as usize); + file.by_ref() + .take(max_bytes + 1) + .read_to_end(&mut bytes) + .map_err(|error| format!("读取{label}失败:{normalized}: {error}"))?; + if bytes.len() as u64 > max_bytes { + return Err(format!("{label}不能超过 {} MiB", max_bytes / 1024 / 1024)); + } + let final_metadata = file + .metadata() + .map_err(|error| format!("复核{label}失败:{normalized}: {error}"))?; + if initial_metadata.len() != bytes.len() as u64 + || final_metadata.len() != bytes.len() as u64 + || !same_open_file_snapshot(&initial_metadata, &final_metadata) + { + return Err(format!("{label}读取期间发生漂移:{normalized}")); + } + let (reopened, reopened_metadata) = open_project_snapshot_regular_file(&absolute, label)?; + if !same_open_file_identity(&file, &initial_metadata, &reopened, &reopened_metadata)? { + return Err(format!("{label}路径读取期间发生替换:{normalized}")); + } + Ok(bytes) +} + +fn project_text_media_type(path: &str) -> Option<&'static str> { + match path_extension(path).as_deref()? { + "md" | "markdown" | "mdx" => Some("text/markdown"), + "txt" => Some("text/plain"), + "json" => Some("application/json"), + "yaml" | "yml" => Some("application/yaml"), + "toml" => Some("application/toml"), + _ => None, + } +} + +fn detect_project_media_type( + path: &str, + bytes: &[u8], + kind: ProjectMediaPreviewKind, +) -> Result<&'static str, String> { + if kind == ProjectMediaPreviewKind::Art && path_extension(path).as_deref() == Some("svg") { + validate_safe_svg(bytes)?; + return Ok("image/svg+xml"); + } + if kind == ProjectMediaPreviewKind::Art { + if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") { + return Ok("image/gif"); + } + if bytes.starts_with(b"BM") { + return Ok("image/bmp"); + } + if is_avif(bytes) { + return Ok("image/avif"); + } + if is_iso_base_media(bytes) { + return Ok(if path_extension(path).as_deref() == Some("mov") { + "video/quicktime" + } else { + "video/mp4" + }); + } + if bytes.starts_with(&[0x1a, 0x45, 0xdf, 0xa3]) { + return Ok("video/webm"); + } + return Err("美术媒体预览只支持 GIF、安全 SVG、AVIF、BMP、MP4、WebM 或 MOV".to_string()); + } + + if looks_like_id3(bytes) || looks_like_mp3_frame(bytes) { + Ok("audio/mpeg") + } else if bytes.len() >= 12 && bytes.starts_with(b"RIFF") && &bytes[8..12] == b"WAVE" { + Ok("audio/wav") + } else if bytes.starts_with(b"OggS") { + Ok("audio/ogg") + } else if bytes.starts_with(b"fLaC") { + Ok("audio/flac") + } else if is_avif(bytes) { + Err("音乐音效文件签名与登记类型不一致".to_string()) + } else if is_iso_base_media(bytes) { + Ok("audio/mp4") + } else if looks_like_aac_adts(bytes) { + Ok("audio/aac") + } else { + Err("音乐音效预览只支持 MP3、WAV、OGG、M4A、AAC、FLAC 或 Opus".to_string()) + } +} + +fn validate_safe_svg(bytes: &[u8]) -> Result<(), String> { + let text = std::str::from_utf8(bytes).map_err(|_| "SVG 必须使用 UTF-8 编码".to_string())?; + let lower = text.to_ascii_lowercase(); + if !lower.contains(" bool { + let mut remaining = text; + while let Some(index) = remaining.find("href") { + let after_name = &remaining[index + 4..]; + let Some(after_equals) = after_name.trim_start().strip_prefix('=') else { + remaining = after_name; + continue; + }; + let value = after_equals.trim_start(); + let value = value + .strip_prefix('\'') + .or_else(|| value.strip_prefix('"')) + .unwrap_or(value) + .trim_start(); + if !value.starts_with('#') { + return true; + } + remaining = after_name; + } + false +} + +fn contains_unsafe_svg_url(text: &str) -> bool { + let mut remaining = text; + while let Some(index) = remaining.find("url(") { + let value = remaining[index + 4..].trim_start(); + let value = value + .strip_prefix('\'') + .or_else(|| value.strip_prefix('"')) + .unwrap_or(value) + .trim_start(); + if !value.starts_with('#') { + return true; + } + remaining = &remaining[index + 4..]; + } + false +} + +fn contains_svg_event_handler(text: &str) -> bool { + let bytes = text.as_bytes(); + let mut index = 0usize; + while index + 3 < bytes.len() { + if bytes[index].is_ascii_whitespace() && bytes[index + 1..].starts_with(b"on") { + let mut cursor = index + 3; + while cursor < bytes.len() && bytes[cursor].is_ascii_alphabetic() { + cursor += 1; + } + while cursor < bytes.len() && bytes[cursor].is_ascii_whitespace() { + cursor += 1; + } + if cursor < bytes.len() && bytes[cursor] == b'=' { + return true; + } + } + index += 1; + } + false +} + +fn is_iso_base_media(bytes: &[u8]) -> bool { + bytes.len() >= 12 && &bytes[4..8] == b"ftyp" +} + +fn is_avif(bytes: &[u8]) -> bool { + is_iso_base_media(bytes) + && (&bytes[8..12] == b"avif" + || &bytes[8..12] == b"avis" + || bytes[8..].windows(4).any(|brand| brand == b"avif")) +} + +fn looks_like_mp3_frame(bytes: &[u8]) -> bool { + bytes.len() >= 4 + && bytes[0] == 0xff + && bytes[1] & 0xe0 == 0xe0 + && bytes[1] & 0x06 != 0 + && bytes[2] & 0xf0 != 0xf0 + && bytes[2] & 0x0c != 0x0c +} + +fn looks_like_id3(bytes: &[u8]) -> bool { + if bytes.len() < 10 || !bytes.starts_with(b"ID3") || bytes[3] == 0xff || bytes[4] == 0xff { + return false; + } + let size_bytes = &bytes[6..10]; + if size_bytes.iter().any(|byte| byte & 0x80 != 0) { + return false; + } + let tag_size = size_bytes + .iter() + .fold(0usize, |size, byte| (size << 7) | usize::from(*byte)); + 10usize + .checked_add(tag_size) + .is_some_and(|required| required <= bytes.len()) +} + +fn looks_like_aac_adts(bytes: &[u8]) -> bool { + bytes.len() >= 2 && bytes[0] == 0xff && bytes[1] & 0xf6 == 0xf0 +} + +fn path_extension(path: &str) -> Option { + Path::new(path) + .extension() + .and_then(|extension| extension.to_str()) + .map(str::to_ascii_lowercase) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + #[test] + fn text_preview_requires_utf8_and_a_supported_extension() { + let root = tempfile::tempdir().expect("temp root"); + fs::create_dir_all(root.path().join("docs")).expect("docs dir"); + fs::write(root.path().join("docs/design.md"), "# 设计\n\n正文").expect("markdown"); + fs::write(root.path().join("docs/legacy.txt"), [0xff, 0xfe]).expect("legacy text"); + fs::write(root.path().join("docs/page.html"), "

unsafe

").expect("html"); + + let preview = + load_local_project_text_preview(root.path(), "docs/design.md").expect("load markdown"); + assert_eq!(preview.media_type, "text/markdown"); + assert!(preview.content.contains("正文")); + assert!(load_local_project_text_preview(root.path(), "docs/legacy.txt").is_err()); + assert!(load_local_project_text_preview(root.path(), "docs/page.html").is_err()); + } + + #[test] + fn media_preview_accepts_safe_svg_and_rejects_active_svg() { + let root = tempfile::tempdir().expect("temp root"); + fs::create_dir_all(root.path().join("assets")).expect("assets dir"); + fs::write( + root.path().join("assets/icon.svg"), + "", + ) + .expect("svg"); + fs::write( + root.path().join("assets/active.svg"), + "", + ) + .expect("active svg"); + fs::write( + root.path().join("assets/external.svg"), + "", + ) + .expect("external svg"); + + let preview = load_local_project_media_preview( + root.path(), + "assets/icon.svg", + ProjectMediaPreviewKind::Art, + ) + .expect("safe svg"); + assert_eq!(preview.media_type, "image/svg+xml"); + assert!(preview.data_url.starts_with("data:image/svg+xml;base64,")); + assert!(load_local_project_media_preview( + root.path(), + "assets/active.svg", + ProjectMediaPreviewKind::Art, + ) + .is_err()); + assert!(load_local_project_media_preview( + root.path(), + "assets/external.svg", + ProjectMediaPreviewKind::Art, + ) + .is_err()); + } + + #[cfg(unix)] + #[test] + fn resource_preview_rejects_symlink_and_hardlink_files() { + use std::os::unix::fs::symlink; + + let root = tempfile::tempdir().expect("temp root"); + let outside = tempfile::tempdir().expect("outside"); + fs::create_dir_all(root.path().join("docs")).expect("docs dir"); + let source = outside.path().join("source.md"); + fs::write(&source, "secret").expect("source"); + symlink(&source, root.path().join("docs/link.md")).expect("symlink"); + fs::hard_link(&source, root.path().join("docs/hard.md")).expect("hardlink"); + + assert!(load_local_project_text_preview(root.path(), "docs/link.md").is_err()); + assert!(load_local_project_text_preview(root.path(), "docs/hard.md").is_err()); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/recovery.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/recovery.rs index a255f8748..8365f3873 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/recovery.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/recovery.rs @@ -1606,5 +1606,12 @@ async fn project_supervisor_resume_rechecks_delegate_policy_after_delivery_reser .expect("read barrier after rejecting reserved delivery") .is_clear()); + let released = wait_for_agent_runtime_lane_release_async( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + ) + .await; + assert_eq!(released.state.run_id, parent_run_id); + fs::remove_dir_all(root).ok(); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs index 7eb2310e6..83488c15f 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 @@ -120,6 +120,16 @@ fn unique_project_path() -> PathBuf { )) } +pub(crate) fn canonical_test_tempdir(prefix: &str) -> tempfile::TempDir { + let temp_root = std::env::temp_dir() + .canonicalize() + .expect("canonicalize test temp root"); + tempfile::Builder::new() + .prefix(prefix) + .tempdir_in(temp_root) + .expect("create test temp directory under canonical root") +} + fn agent_goal_sidecar_path_for_test(root: &Path, agent_id: &str, session_id: &str) -> PathBuf { let path_key = |value: &str| { format!("{:x}", Sha256::digest(value.as_bytes())) diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs index 48305afff..2f638d6bb 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs @@ -3221,6 +3221,39 @@ fn local_preview_server_serves_game_index() { fs::remove_dir_all(root).ok(); } +#[test] +fn local_preview_can_serve_an_immutable_version_snapshot_instead_of_the_working_tree() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-snapshot", "快照预览测试").expect("project init"); + fs::write(root.join("game/index.html"), "
working tree
") + .expect("write working tree game"); + let snapshot_root = root.join(".agent/runnable-versions/runnable-r1/artifact"); + fs::create_dir_all(snapshot_root.join("game")).expect("create snapshot game directory"); + fs::create_dir_all(snapshot_root.join("assets")).expect("create snapshot assets directory"); + fs::write( + snapshot_root.join("game/index.html"), + "
immutable snapshot
", + ) + .expect("write snapshot game"); + + let (preview, stop) = start_local_game_preview_for_served_root(&root, &snapshot_root) + .expect("snapshot preview start"); + let mut stream = TcpStream::connect(("127.0.0.1", preview.port)).expect("preview connect"); + stream + .write_all(b"GET / HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n") + .expect("request"); + let mut response = String::new(); + stream.read_to_string(&mut response).expect("response"); + + assert!(response.contains("200 OK"), "{response}"); + assert!(response.contains("immutable snapshot"), "{response}"); + assert!(!response.contains("working tree"), "{response}"); + assert_eq!(preview.root, root.to_string_lossy()); + + let _ = stop.send(()); + fs::remove_dir_all(root).ok(); +} + #[test] fn local_preview_server_drains_split_browser_headers_before_response() { let root = unique_project_path(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project_tools.rs index 4ff135ddf..141878dab 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/project_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project_tools.rs @@ -6090,3 +6090,107 @@ fn local_project_image_preview_obeys_auto_file_read_policy() { fs::remove_dir_all(root).ok(); } + +#[test] +fn local_project_resource_previews_require_registered_safe_resources() { + let root = unique_project_path(); + init_local_game_project_at(&root, "resource-preview-policy", "资源预览策略项目") + .expect("project init"); + fs::create_dir_all(root.join("assets")).expect("asset dir"); + fs::create_dir_all(root.join("game")).expect("game dir"); + fs::write(root.join("game/design.md"), "# 玩法设计\n\n安全正文").expect("project document"); + fs::write( + root.join("assets/icon.svg"), + "", + ) + .expect("svg resource"); + fs::write( + root.join("assets/bgm.mp3"), + [b'I', b'D', b'3', 4, 0, 0, 0, 0, 0, 0], + ) + .expect("audio resource"); + fs::write(root.join("game/unregistered.md"), "不应读取").expect("unregistered document"); + + let source = || GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Generated, + canvas_project_id: None, + resource_id: None, + asset_object_id: None, + task_id: None, + prompt: None, + model: None, + generation_route: None, + generation_kind: None, + reference_resource_ids: Vec::new(), + }; + register_local_asset_at( + &root, + "game/design.md", + "design-document", + "text/markdown", + "generated", + source(), + ) + .expect("register document"); + register_local_asset_at( + &root, + "assets/icon.svg", + "icon", + "image/svg+xml", + "generated", + source(), + ) + .expect("register svg"); + register_local_asset_at( + &root, + "assets/bgm.mp3", + "bgm", + "audio/mpeg", + "generated", + source(), + ) + .expect("register audio"); + + let document = read_local_project_text_preview( + root.to_string_lossy().into_owned(), + "game/design.md".to_string(), + ) + .expect("read registered document"); + assert_eq!(document.media_type, "text/markdown"); + assert!(document.content.contains("安全正文")); + + let svg = read_local_project_media_preview( + root.to_string_lossy().into_owned(), + "assets/icon.svg".to_string(), + "art".to_string(), + ) + .expect("read registered svg"); + assert_eq!(svg.media_type, "image/svg+xml"); + let audio = read_local_project_media_preview( + root.to_string_lossy().into_owned(), + "assets/bgm.mp3".to_string(), + "audio".to_string(), + ) + .expect("read registered audio"); + assert_eq!(audio.media_type, "audio/mpeg"); + + let unregistered_error = read_local_project_text_preview( + root.to_string_lossy().into_owned(), + "game/unregistered.md".to_string(), + ) + .expect_err("unregistered document rejected"); + assert!(unregistered_error.contains("已登记的文档资源")); + assert!(read_local_project_text_preview( + root.to_string_lossy().into_owned(), + "../outside.md".to_string(), + ) + .is_err()); + assert!(read_local_project_media_preview( + root.to_string_lossy().into_owned(), + "assets/bgm.mp3".to_string(), + "art".to_string(), + ) + .is_err()); + + fs::remove_dir_all(root).ok(); +} diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 7d2652110..82c57a8a6 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -566,6 +566,7 @@ type AppProps = { gameChatOnly?: boolean; initialSupervisorMessage?: string; onPreviewChange?: (preview: GameCreationAppPreviewState | null) => void; + onManifestChange?: (manifest: GameCreationAppManifest) => void; onAgentRuntimeSummariesChange?: ( summaries: ProjectAgentRuntimeSummary[], ) => void; @@ -580,6 +581,7 @@ export function App({ gameChatOnly = false, initialSupervisorMessage = '', onPreviewChange, + onManifestChange, onAgentRuntimeSummariesChange, onAgentResultsChange, }: AppProps = {}) { @@ -609,6 +611,11 @@ export function App({ const [manifest, setManifest] = useState( initialProjectManifest ?? seedManifest, ); + useEffect(() => { + if (projectSupervisorOnly) { + onManifestChange?.(manifest); + } + }, [manifest, onManifestChange, projectSupervisorOnly]); const [projectStatus, setProjectStatus] = useState( eagerSupervisorProject ? '已初始化' : '未初始化', ); diff --git a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx index e9a84d703..f4f88dc1b 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 @@ -51,6 +51,7 @@ export function WorkspaceLauncherShell({ setAgentRuntimeSummaries: setActiveProjectAgentRuntimeSummaries, activeProjectAgentResults, setAgentResults: setActiveProjectAgentResults, + updateCurrentProjectManifest, resetLauncherHomeDraft, createHomeDraft, openProject, @@ -157,6 +158,8 @@ export function WorkspaceLauncherShell({ agentResults={activeProjectAgentResults} onHomeOpen={() => setLauncherView('home')} onProjectsOpen={() => setLauncherView('projects')} + onManifestChange={updateCurrentProjectManifest} + onPreviewChange={setActiveProjectPreview} supervisor={ void; + onManifestChange?: (manifest: GameCreationAppManifest) => void; onAgentRuntimeSummariesChange?: ( summaries: ProjectAgentRuntimeSummary[], ) => void; diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts b/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts index 9d15570cb..af7ce9a9a 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 @@ -2,6 +2,7 @@ import { type Dispatch, type FormEvent, type SetStateAction, + useCallback, useState, } from 'react'; @@ -96,6 +97,16 @@ export function useHomeProjectCreation({ rememberRecentWorkspace(context.projectPath); } + const updateCurrentProjectManifest = useCallback((manifest: GameCreationAppManifest) => { + setCurrentProjectContext((current) => + current && current.manifest.projectId === manifest.projectId + ? current.manifest === manifest + ? current + : { ...current, manifest } + : current, + ); + }, []); + async function importHomeAttachments( invoke: TauriInvoke, nextProjectPath: string, @@ -436,6 +447,7 @@ export function useHomeProjectCreation({ setAgentRuntimeSummaries, activeProjectAgentResults, setAgentResults, + updateCurrentProjectManifest, pendingNonEmptyProject, resetLauncherHomeDraft, createHomeDraft, diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index cafeb314c..3d311811c 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -3871,8 +3871,6 @@ iframe.preview-frame { .game-resource-canvas { position: relative; - display: grid; - align-content: start; flex: 1; min-height: 0; padding: 12px; @@ -3882,7 +3880,72 @@ iframe.preview-frame { background-size: 18px 18px; } +.game-resource-canvas-content { + position: relative; + display: grid; + align-content: start; + width: max-content; + min-width: 100%; + min-height: 100%; +} + +.game-resource-dependency-overlay { + position: absolute; + inset: 0; + z-index: 0; + width: 100%; + height: 100%; + overflow: visible; + pointer-events: none; +} + +.game-resource-dependency-edge, +.game-resource-dependency-edge path { + fill: none; + stroke-linecap: round; + stroke-linejoin: round; + vector-effect: non-scaling-stroke; +} + +.game-resource-dependency-edge--reference { + stroke: #f28a52; + stroke-width: 2.4px; + opacity: 1; +} + +.game-resource-dependency-edge--task path { + stroke: #918b87; + stroke-width: 1.4px; + stroke-dasharray: 4 7; +} + +.game-resource-dependency-edge--task .game-resource-dependency-trunk { + stroke-width: 1.7px; + opacity: 0.88; +} + +.game-resource-dependency-edge--task .game-resource-dependency-branch { + opacity: 0.72; +} + +.game-resource-dependency-edge.is-cyclic, +.game-resource-dependency-edge.is-cyclic path { + stroke-dashoffset: 4; +} + +.game-resource-dependency-marker--reference path { + fill: #f28a52; + stroke-linejoin: round; +} + +.game-resource-dependency-marker--task path { + fill: #918b87; + stroke-linejoin: round; +} + .game-resource-section { + position: relative; + z-index: 1; display: grid; gap: 10px; min-width: 620px; @@ -3954,11 +4017,10 @@ iframe.preview-frame { color: #4e382f; text-align: left; box-shadow: 0 6px 18px rgb(96 62 47 / 6%); - cursor: grab; - touch-action: none; + cursor: pointer; + touch-action: manipulation; user-select: none; transform: translate3d(var(--resource-x, 0), var(--resource-y, 0), 0); - will-change: transform; } .game-resource-card:hover, @@ -3969,13 +4031,19 @@ iframe.preview-frame { box-shadow: 0 8px 22px rgb(195 105 62 / 15%); } -.game-resource-card.is-dragging { - z-index: 2; - opacity: 0.72; - cursor: grabbing; +.game-resource-card.is-relation-version-binding { + border-color: #d87342; box-shadow: - 0 12px 28px rgb(195 105 62 / 24%), - 0 0 0 2px rgb(213 123 81 / 18%); + 0 8px 22px rgb(195 105 62 / 18%), + 0 0 0 2px rgb(216 115 66 / 14%); +} + +.game-resource-card.is-current-version { + border-color: #c85f31; + background: #fff7f1; + box-shadow: + 0 8px 22px rgb(195 105 62 / 18%), + inset 0 0 0 2px rgb(216 115 66 / 16%); } .game-resource-card-icon { @@ -4006,38 +4074,16 @@ iframe.preview-frame { font-size: 9px; } -.game-resource-focus-layer { - position: fixed; - inset: 0; - z-index: 240; - pointer-events: none; -} - .game-resource-focus { - position: fixed; - left: 50%; - top: 50%; display: grid; grid-template-rows: auto minmax(0, 1fr); - width: min(520px, calc(100vw - 108px)); - max-height: min(620px, calc(100dvh - 190px)); + width: 100%; + min-width: 0; + min-height: 0; overflow: hidden; - border: 1px solid #dda07e; - border-radius: 16px; - background: rgb(255 251 247 / 97%); + background: #fffdfa; color: #563b31; - box-shadow: 0 20px 42px rgb(84 49 34 / 18%); outline: 0; - pointer-events: auto; - transform: translate(-50%, -50%); -} - -.game-resource-focus[style] { - transform: none; -} - -.game-resource-focus--art { - width: min(760px, calc(100vw - 140px)); } .game-resource-focus-titlebar { @@ -4046,16 +4092,9 @@ iframe.preview-frame { justify-content: space-between; gap: 12px; min-height: 58px; - padding: 10px 12px 10px 14px; + padding: 10px 14px 10px 16px; border-bottom: 1px solid #ead8cf; background: #fff8f3; - cursor: grab; - touch-action: none; - user-select: none; -} - -.game-resource-focus-titlebar:active { - cursor: grabbing; } .game-resource-focus-heading { @@ -4065,6 +4104,17 @@ iframe.preview-frame { min-width: 0; } +.game-resource-focus-heading > span:last-child { + display: grid; + min-width: 0; +} + +.game-resource-focus-heading small { + color: #a27764; + font-size: 9px; + font-weight: 700; +} + .game-resource-focus-icon { display: grid; width: 36px; @@ -4090,24 +4140,20 @@ iframe.preview-frame { align-content: start; gap: 6px; min-height: 0; - padding: 14px 16px 16px; - overflow: auto; + padding: 18px 20px 24px; + overflow: hidden; overscroll-behavior: contain; scrollbar-gutter: stable; } -.game-resource-focus span, -.game-resource-focus small { - overflow-wrap: anywhere; +.game-resource-focus--document .game-resource-focus-body, +.game-resource-focus--art .game-resource-focus-body, +.game-resource-focus--audio .game-resource-focus-body { + grid-template-rows: minmax(0, 1fr) auto; } -.game-resource-focus span { - font-size: 11px; -} - -.game-resource-focus small { - color: #92776c; - font-size: 10px; +.game-resource-focus--version .game-resource-focus-body { + overflow: auto; } .game-resource-focus-close { @@ -4124,10 +4170,43 @@ iframe.preview-frame { cursor: pointer; } +.game-resource-focus-metadata { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px 18px; + margin: 0 0 8px; + padding: 12px 14px; + border: 1px solid #eaded8; + border-radius: 12px; + background: #fff; +} + +.game-resource-focus-metadata > div { + display: grid; + grid-template-columns: 72px minmax(0, 1fr); + gap: 8px; + min-width: 0; +} + +.game-resource-focus-metadata dt, +.game-resource-focus-metadata dd { + margin: 0; + overflow-wrap: anywhere; + font-size: 10px; +} + +.game-resource-focus-metadata dt { + color: #a08377; +} + +.game-resource-focus-metadata dd { + color: #5d4339; +} + .game-resource-image-preview { position: relative; display: grid; - height: min(420px, calc(100dvh - 360px)); + height: min(460px, calc(100dvh - 330px)); min-height: 260px; margin-bottom: 8px; overflow: hidden; @@ -4146,6 +4225,66 @@ iframe.preview-frame { place-items: center; } +.game-resource-media-preview, +.game-resource-audio-preview { + position: relative; + display: grid; + min-width: 0; + min-height: 0; + margin-bottom: 8px; + overflow: hidden; + border: 1px solid #ead8cf; + border-radius: 12px; + background: #faf7f5; + place-items: center; +} + +.game-resource-media-preview { + background: linear-gradient(45deg, #f1ebe7 25%, transparent 25%), + linear-gradient(-45deg, #f1ebe7 25%, transparent 25%), + linear-gradient(45deg, transparent 75%, #f1ebe7 75%), + linear-gradient(-45deg, transparent 75%, #f1ebe7 75%), #faf7f5; + background-position: + 0 0, + 0 8px, + 8px -8px, + -8px 0; + background-size: 16px 16px; +} + +.game-resource-media-preview img, +.game-resource-media-preview video { + display: block; + max-width: 100%; + max-height: 100%; + object-fit: contain; +} + +.game-resource-media-preview video { + width: 100%; + height: 100%; + background: #211d1b; +} + +.game-resource-audio-preview { + align-content: center; + padding: 28px; + background: linear-gradient(145deg, #fffaf6, #f5e7df); +} + +.game-resource-audio-preview audio { + width: min(620px, 100%); +} + +.game-resource-media-preview p, +.game-resource-audio-preview p { + margin: 0; + padding: 20px; + color: #92776c; + font-size: 12px; + text-align: center; +} + .game-resource-image-preview img { position: absolute; inset: 0; @@ -4164,17 +4303,19 @@ iframe.preview-frame { } .game-resource-document-body { - height: max-content; - min-height: 120px; + min-width: 0; + min-height: 0; margin-top: 6px; padding: 14px; border-radius: 10px; background: #f8f2ee; - color: #9e8579; - font-size: 11px; + color: #765c51; + font-size: 12px; line-height: 1.6; - overflow: hidden; + overflow: auto; overflow-wrap: anywhere; + overscroll-behavior: contain; + scrollbar-gutter: stable; user-select: text; } @@ -4256,6 +4397,19 @@ iframe.preview-frame { text-decoration: underline; } +.game-resource-document-link-text { + color: #8f5e49; + text-decoration: underline dotted; +} + +.game-resource-document-image-placeholder { + display: inline-block; + padding: 0.2em 0.45em; + border-radius: 5px; + background: #eaded7; + color: #806559; +} + .game-resource-document-body table { width: 100%; border-collapse: collapse; @@ -4277,6 +4431,25 @@ iframe.preview-frame { background: #fffdfa; } +.game-run-version-picker { + display: inline-flex; + align-items: center; + gap: 6px; + color: #76574a; + font-size: 10px; + font-weight: 700; +} + +.game-run-version-picker select { + min-width: 190px; + height: 30px; + border: 1px solid #e4cfc4; + border-radius: 9px; + background: #fff; + color: #65483d; + font-size: 10px; +} + .game-run-preview { position: relative; display: grid; @@ -4351,10 +4524,17 @@ iframe.preview-frame { .game-run-panels { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-columns: minmax(0, 1fr); gap: 10px; } +.game-run-status { + margin: 0; + color: #8b634f; + font-size: 10px; + text-align: center; +} + .game-run-panels > section { display: grid; align-content: start; diff --git a/apps/ai-game-creator-shell/src/view/project-development/ResourceDependencyOverlay.tsx b/apps/ai-game-creator-shell/src/view/project-development/ResourceDependencyOverlay.tsx new file mode 100644 index 000000000..38a51a13c --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/ResourceDependencyOverlay.tsx @@ -0,0 +1,676 @@ +import { + forwardRef, + useCallback, + useId, + useImperativeHandle, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; + +import type { + ProjectResourceCanvasPosition, + ProjectResourceCanvasSection, +} from '../../../../../packages/shared/src/contracts/gameCreationApp'; +import { + RESOURCE_CANVAS_CARD_HEIGHT, + RESOURCE_CANVAS_CARD_WIDTH, +} from './resourceCanvasLayoutModel'; +import { + type ProjectResourceGraph, + type ProjectResourceReferenceEdge, + type ProjectResourceTaskFlow, +} from './resourceDependencyGraphModel'; + +type Point = { + x: number; + y: number; +}; + +type Rect = Point & { + width: number; + height: number; +}; + +type SectionOrigins = Partial>; + +type RectLookup = { + get(resourceId: string): Rect | undefined; +}; + +export type ResourceDependencyOverlayProps = { + graph: ProjectResourceGraph; + positions: readonly ProjectResourceCanvasPosition[]; + visibleResourceIds: ReadonlySet; +}; + +export type ResourceDependencyOverlayHandle = { + updateDragPreview: (preview: Point & { resourceId: string }) => void; + clearDragPreview: () => void; +}; + +type TaskFlowPathRefs = { + sourceBranches: Map; + targetBranches: Map; + trunk: SVGPathElement | null; +}; + +type TaskFlowSectionGeometry = NonNullable< + ReturnType +> & { + section: ProjectResourceCanvasSection; +}; + +const SECTION_SELECTOR = '[data-resource-section-plane]'; +const TASK_FLOW_HUB_GAP = 20; +const CONNECTION_MAX_HANDLE = 180; +const TASK_FLOW_BRANCH_MAX_HANDLE = 96; +const SELF_REFERENCE_LOOP_WIDTH = 56; +const SELF_REFERENCE_LOOP_ANCHOR_OFFSET = 18; +const RESOURCE_SECTIONS: readonly ProjectResourceCanvasSection[] = [ + 'document', + 'version', + 'art', + 'audio', +]; + +function pointsEqual(left: SectionOrigins, right: SectionOrigins) { + return RESOURCE_SECTIONS.every( + (section) => + left[section]?.x === right[section]?.x && + left[section]?.y === right[section]?.y, + ); +} + +function connectionPath(source: Point, target: Point) { + if (source.x === target.x && source.y === target.y) { + return `M ${source.x} ${source.y} C ${source.x + 48} ${source.y - 48}, ${ + source.x + 48 + } ${source.y + 48}, ${source.x} ${source.y + 1}`; + } + const direction = target.x >= source.x ? 1 : -1; + const bend = Math.min( + CONNECTION_MAX_HANDLE, + Math.max( + 32, + Math.abs(target.x - source.x) * 0.42 + + Math.abs(target.y - source.y) * 0.08, + ), + ); + return `M ${source.x} ${source.y} C ${source.x + direction * bend} ${ + source.y + }, ${target.x - direction * bend} ${target.y}, ${target.x} ${target.y}`; +} + +function taskFlowBranchPath(source: Point, target: Point) { + const horizontalDistance = Math.abs(target.x - source.x); + if (horizontalDistance < 1) { + const direction = target.y >= source.y ? 1 : -1; + const handle = Math.min( + TASK_FLOW_BRANCH_MAX_HANDLE, + Math.abs(target.y - source.y) * 0.5, + ); + return `M ${source.x} ${source.y} C ${source.x} ${ + source.y + direction * handle + }, ${target.x} ${target.y - direction * handle}, ${target.x} ${target.y}`; + } + const direction = target.x >= source.x ? 1 : -1; + const handle = Math.min( + TASK_FLOW_BRANCH_MAX_HANDLE, + horizontalDistance * 0.5, + ); + return `M ${source.x} ${source.y} C ${source.x + direction * handle} ${ + source.y + }, ${target.x - direction * handle} ${target.y}, ${target.x} ${target.y}`; +} + +function rectCenter(rect: Rect): Point { + return { + x: rect.x + rect.width / 2, + y: rect.y + rect.height / 2, + }; +} + +function average(values: readonly number[]) { + return values.reduce((sum, value) => sum + value, 0) / values.length; +} + +function rectAnchor(rect: Rect, direction: 1 | -1): Point { + return { + x: direction === 1 ? rect.x + rect.width : rect.x, + y: rect.y + rect.height / 2, + }; +} + +function referenceGeometry( + edge: ProjectResourceReferenceEdge, + rectByResourceId: RectLookup, +) { + const sourceRect = rectByResourceId.get(edge.sourceResourceId); + const targetRect = rectByResourceId.get(edge.targetResourceId); + if (!sourceRect || !targetRect) { + return null; + } + if (edge.sourceResourceId === edge.targetResourceId) { + const anchorX = sourceRect.x + sourceRect.width; + const centerY = sourceRect.y + sourceRect.height / 2; + const sourceY = centerY + SELF_REFERENCE_LOOP_ANCHOR_OFFSET; + const targetY = centerY - SELF_REFERENCE_LOOP_ANCHOR_OFFSET; + const loopX = anchorX + SELF_REFERENCE_LOOP_WIDTH; + return { + path: `M ${anchorX} ${sourceY} C ${loopX} ${sourceY}, ${loopX} ${targetY}, ${anchorX} ${targetY}`, + selfLoop: true, + }; + } + const sourceCenter = rectCenter(sourceRect); + const targetCenter = rectCenter(targetRect); + const direction: 1 | -1 = targetCenter.x >= sourceCenter.x ? 1 : -1; + const source = rectAnchor(sourceRect, direction); + const target = rectAnchor(targetRect, direction === 1 ? -1 : 1); + return { + path: connectionPath(source, target), + selfLoop: false, + }; +} + +function taskFlowGeometry( + flow: ProjectResourceTaskFlow, + rectByResourceId: RectLookup, +) { + const sourceRects = flow.sourceResourceIds.flatMap((resourceId) => { + const rect = rectByResourceId.get(resourceId); + return rect ? [{ resourceId, rect }] : []; + }); + const targetRects = flow.targetResourceIds.flatMap((resourceId) => { + const rect = rectByResourceId.get(resourceId); + return rect ? [{ resourceId, rect }] : []; + }); + if (sourceRects.length === 0 || targetRects.length === 0) { + return null; + } + const sourceCenterX = average( + sourceRects.map(({ rect }) => rectCenter(rect).x), + ); + const targetCenterX = average( + targetRects.map(({ rect }) => rectCenter(rect).x), + ); + const direction: 1 | -1 = targetCenterX >= sourceCenterX ? 1 : -1; + const sourceAnchors = sourceRects.map(({ resourceId, rect }) => ({ + resourceId, + point: rectAnchor(rect, direction), + })); + const targetAnchors = targetRects.map(({ resourceId, rect }) => ({ + resourceId, + point: rectAnchor(rect, direction === 1 ? -1 : 1), + })); + const sourceHub: Point = { + x: + (direction === 1 + ? Math.max(...sourceAnchors.map(({ point }) => point.x)) + : Math.min(...sourceAnchors.map(({ point }) => point.x))) + + direction * TASK_FLOW_HUB_GAP, + y: average(sourceAnchors.map(({ point }) => point.y)), + }; + const targetHub: Point = { + x: + (direction === 1 + ? Math.min(...targetAnchors.map(({ point }) => point.x)) + : Math.max(...targetAnchors.map(({ point }) => point.x))) - + direction * TASK_FLOW_HUB_GAP, + y: average(targetAnchors.map(({ point }) => point.y)), + }; + return { sourceAnchors, targetAnchors, sourceHub, targetHub }; +} + +function taskFlowSectionGeometries( + flow: ProjectResourceTaskFlow, + rectByResourceId: RectLookup, + sectionByResourceId: ReadonlyMap, +): TaskFlowSectionGeometry[] { + return RESOURCE_SECTIONS.flatMap((section) => { + const geometry = taskFlowGeometry( + { + ...flow, + sourceResourceIds: flow.sourceResourceIds.filter( + (resourceId) => sectionByResourceId.get(resourceId) === section, + ), + targetResourceIds: flow.targetResourceIds.filter( + (resourceId) => sectionByResourceId.get(resourceId) === section, + ), + }, + rectByResourceId, + ); + return geometry ? [{ ...geometry, section }] : []; + }); +} + +function taskFlowRenderKey( + flowId: string, + section: ProjectResourceCanvasSection, +) { + return `${flowId}\n${section}`; +} + +export const ResourceDependencyOverlay = forwardRef< + ResourceDependencyOverlayHandle, + ResourceDependencyOverlayProps +>(function ResourceDependencyOverlay( + { graph, positions, visibleResourceIds }, + ref, +) { + const markerPrefix = useId().replace(/[^a-zA-Z0-9_-]/gu, ''); + const overlayRef = useRef(null); + const referencePathRefs = useRef(new Map()); + const taskFlowPathRefs = useRef(new Map()); + const activeDragPreviewRef = useRef<(Point & { resourceId: string }) | null>( + null, + ); + const graphRef = useRef(graph); + const positionByResourceIdRef = useRef( + new Map(positions.map((position) => [position.resourceId, position])), + ); + const rectByResourceIdRef = useRef>(new Map()); + const [sectionOrigins, setSectionOrigins] = useState({}); + + useLayoutEffect(() => { + const canvas = overlayRef.current?.parentElement; + if (!canvas) { + return undefined; + } + let frameId: number | null = null; + const measure = () => { + frameId = null; + const canvasRect = canvas.getBoundingClientRect(); + const next: SectionOrigins = {}; + canvas + .querySelectorAll(SECTION_SELECTOR) + .forEach((plane) => { + const section = plane.dataset.resourceSectionPlane as + | ProjectResourceCanvasSection + | undefined; + if (!section) { + return; + } + const planeRect = plane.getBoundingClientRect(); + next[section] = { + x: planeRect.left - canvasRect.left, + y: planeRect.top - canvasRect.top, + }; + }); + setSectionOrigins((current) => + pointsEqual(current, next) ? current : next, + ); + }; + const scheduleMeasure = () => { + if (frameId !== null) { + return; + } + frameId = window.requestAnimationFrame(measure); + }; + measure(); + const ResizeObserverClass = window.ResizeObserver; + const observer = ResizeObserverClass + ? new ResizeObserverClass(scheduleMeasure) + : null; + observer?.observe(canvas); + canvas + .querySelectorAll(SECTION_SELECTOR) + .forEach((plane) => observer?.observe(plane)); + window.addEventListener('resize', scheduleMeasure); + return () => { + if (frameId !== null) { + window.cancelAnimationFrame(frameId); + } + observer?.disconnect(); + window.removeEventListener('resize', scheduleMeasure); + }; + }, []); + + const rectByResourceId = useMemo(() => { + const result = new Map(); + for (const position of positions) { + if ( + !graph.resourceIds.has(position.resourceId) || + !visibleResourceIds.has(position.resourceId) + ) { + continue; + } + const origin = sectionOrigins[position.section]; + if (!origin) { + continue; + } + result.set(position.resourceId, { + x: origin.x + position.x, + y: origin.y + position.y, + width: RESOURCE_CANVAS_CARD_WIDTH, + height: RESOURCE_CANVAS_CARD_HEIGHT, + }); + } + return result; + }, [graph.resourceIds, positions, sectionOrigins, visibleResourceIds]); + const sectionByResourceId = useMemo( + () => + new Map( + positions.map((position) => [position.resourceId, position.section]), + ), + [positions], + ); + graphRef.current = graph; + positionByResourceIdRef.current = new Map( + positions.map((position) => [position.resourceId, position]), + ); + rectByResourceIdRef.current = rectByResourceId; + + const taskFlowRenderEntries = useMemo( + () => + graph.taskFlows.flatMap((flow) => + taskFlowSectionGeometries( + flow, + rectByResourceId, + sectionByResourceId, + ).map((geometry) => ({ + flow, + geometry, + renderKey: taskFlowRenderKey(flow.id, geometry.section), + })), + ), + [graph.taskFlows, rectByResourceId, sectionByResourceId], + ); + + const renderTaskFlows = useMemo( + () => + taskFlowRenderEntries.map(({ flow, geometry, renderKey }) => { + const className = `game-resource-dependency-edge game-resource-dependency-edge--task${ + flow.cyclic ? ' is-cyclic' : '' + }`; + return ( + + {`任务流转:${flow.sourceTaskId} → ${flow.targetTaskId}${ + flow.cyclic ? '(检测到依赖环)' : '' + }`} + {geometry.sourceAnchors.map(({ resourceId, point }) => ( + { + let refs = taskFlowPathRefs.current.get(renderKey); + if (!refs) { + refs = { + sourceBranches: new Map(), + targetBranches: new Map(), + trunk: null, + }; + taskFlowPathRefs.current.set(renderKey, refs); + } + if (node) { + refs.sourceBranches.set(resourceId, node); + } else { + refs.sourceBranches.delete(resourceId); + } + }} + key={`source:${resourceId}`} + className="game-resource-dependency-branch" + data-branch-side="source" + data-resource-id={resourceId} + d={taskFlowBranchPath(point, geometry.sourceHub)} + /> + ))} + { + let refs = taskFlowPathRefs.current.get(renderKey); + if (!refs) { + refs = { + sourceBranches: new Map(), + targetBranches: new Map(), + trunk: null, + }; + taskFlowPathRefs.current.set(renderKey, refs); + } + refs.trunk = node; + }} + className="game-resource-dependency-trunk" + d={connectionPath(geometry.sourceHub, geometry.targetHub)} + /> + {geometry.targetAnchors.map(({ resourceId, point }) => ( + { + let refs = taskFlowPathRefs.current.get(renderKey); + if (!refs) { + refs = { + sourceBranches: new Map(), + targetBranches: new Map(), + trunk: null, + }; + taskFlowPathRefs.current.set(renderKey, refs); + } + if (node) { + refs.targetBranches.set(resourceId, node); + } else { + refs.targetBranches.delete(resourceId); + } + }} + key={`target:${resourceId}`} + className="game-resource-dependency-branch" + data-branch-side="target" + data-resource-id={resourceId} + d={taskFlowBranchPath(geometry.targetHub, point)} + markerEnd={`url(#${markerPrefix}-task-flow-arrow)`} + /> + ))} + + ); + }), + [markerPrefix, taskFlowRenderEntries], + ); + + const renderReferenceEdges = useMemo( + () => + graph.referenceEdges.map((edge) => { + const geometry = referenceGeometry(edge, rectByResourceId); + if (!geometry) { + return null; + } + const className = `game-resource-dependency-edge game-resource-dependency-edge--reference${ + edge.cyclic ? ' is-cyclic' : '' + }`; + return ( + { + if (node) { + referencePathRefs.current.set(edge.id, node); + } else { + referencePathRefs.current.delete(edge.id); + } + }} + key={edge.id} + className={className} + data-edge-kind="asset-reference" + data-edge-id={edge.id} + data-source-resource-id={edge.sourceResourceId} + data-target-resource-id={edge.targetResourceId} + data-cyclic={edge.cyclic || undefined} + data-self-loop={geometry.selfLoop || undefined} + d={geometry.path} + markerEnd={`url(#${markerPrefix}-asset-reference-arrow)`} + > + {`资源引用${edge.cyclic ? '(检测到依赖环)' : ''}`} + + ); + }), + [graph.referenceEdges, markerPrefix, rectByResourceId], + ); + + useLayoutEffect(() => { + const activeRenderKeys = new Set( + taskFlowRenderEntries.map((entry) => entry.renderKey), + ); + for (const renderKey of taskFlowPathRefs.current.keys()) { + if (!activeRenderKeys.has(renderKey)) { + taskFlowPathRefs.current.delete(renderKey); + } + } + }, [taskFlowRenderEntries]); + + const updateAffectedGeometry = useCallback( + ( + affectedResourceIds: ReadonlySet, + dragPreview: (Point & { resourceId: string }) | null, + ) => { + const currentGraph = graphRef.current; + const currentRects = rectByResourceIdRef.current; + const dragBasePosition = dragPreview + ? positionByResourceIdRef.current.get(dragPreview.resourceId) + : undefined; + const rectLookup = { + get(resourceId: string) { + const rect = currentRects.get(resourceId); + if (!rect) { + return undefined; + } + return dragPreview?.resourceId === resourceId + ? { + ...rect, + x: rect.x - (dragBasePosition?.x ?? 0) + dragPreview.x, + y: rect.y - (dragBasePosition?.y ?? 0) + dragPreview.y, + } + : rect; + }, + }; + const affectedEdgeIds = new Set(); + for (const resourceId of affectedResourceIds) { + const index = currentGraph.connectionIndex.get(resourceId); + index?.referenceEdgeIds.forEach((edgeId) => + affectedEdgeIds.add(edgeId), + ); + index?.taskFlowIds.forEach((flowId) => affectedEdgeIds.add(flowId)); + } + for (const edgeId of affectedEdgeIds) { + const referenceEdge = currentGraph.referenceEdgeById.get(edgeId); + if (referenceEdge) { + const geometry = referenceGeometry(referenceEdge, rectLookup); + const path = referencePathRefs.current.get(edgeId); + if (geometry && path) { + path.setAttribute('d', geometry.path); + } + continue; + } + const flow = currentGraph.taskFlowById.get(edgeId); + if (!flow) { + continue; + } + const sectionByResourceId = new Map( + Array.from( + positionByResourceIdRef.current.values(), + (position) => [position.resourceId, position.section] as const, + ), + ); + for (const geometry of taskFlowSectionGeometries( + flow, + rectLookup, + sectionByResourceId, + )) { + const paths = taskFlowPathRefs.current.get( + taskFlowRenderKey(flow.id, geometry.section), + ); + if (!paths) { + continue; + } + geometry.sourceAnchors.forEach(({ resourceId, point }) => { + paths.sourceBranches + .get(resourceId) + ?.setAttribute( + 'd', + taskFlowBranchPath(point, geometry.sourceHub), + ); + }); + paths.trunk?.setAttribute( + 'd', + connectionPath(geometry.sourceHub, geometry.targetHub), + ); + geometry.targetAnchors.forEach(({ resourceId, point }) => { + paths.targetBranches + .get(resourceId) + ?.setAttribute( + 'd', + taskFlowBranchPath(geometry.targetHub, point), + ); + }); + } + } + }, + [], + ); + + useImperativeHandle( + ref, + () => ({ + updateDragPreview(preview) { + const affectedResourceIds = new Set(); + if (activeDragPreviewRef.current) { + affectedResourceIds.add(activeDragPreviewRef.current.resourceId); + } + affectedResourceIds.add(preview.resourceId); + activeDragPreviewRef.current = preview; + updateAffectedGeometry(affectedResourceIds, preview); + }, + clearDragPreview() { + const active = activeDragPreviewRef.current; + activeDragPreviewRef.current = null; + if (active) { + updateAffectedGeometry(new Set([active.resourceId]), null); + } + }, + }), + [updateAffectedGeometry], + ); + + return ( + + ); +}); diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index dd5e99492..f2ae126da 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,6 +1,4 @@ import { - ChevronLeft, - ChevronRight, FileText, FolderTree, Gamepad2, @@ -8,17 +6,14 @@ import { Info, ListFilter, Music2, - Pause, - Play, Search, Settings2, - SlidersHorizontal, Sparkles, X, } from 'lucide-react'; import { type CSSProperties, - type PointerEvent as ReactPointerEvent, + memo, type ReactNode, useCallback, useEffect, @@ -34,64 +29,57 @@ import type { GameCreationAppAgentGroup, GameCreationAppManifest, GameCreationAppPreviewState, - GameCreationAppTaskState, ProjectResourceCanvasLayoutMode, - ProjectResourceCanvasSection, + RunnableGameVersion, } from '../../../../../packages/shared/src/contracts/gameCreationApp'; +import { resolveTauriInvoke } from '../../app/tauri'; import { LocalGamePreviewFrame, resolveEmbeddedPreviewUrl, } from '../../features/project-workspace/LocalGamePreviewFrame'; +import { resourceCanvasSectionExtent } from './resourceCanvasLayoutModel'; import { - RESOURCE_CANVAS_DRAG_THRESHOLD, - resourceCanvasSectionExtent, -} from './resourceCanvasLayoutModel'; + EMPTY_PROJECT_RESOURCE_GRAPH, + normalizeProjectResourceGraph, + type ProjectResourceGraph, + type ProjectResourceGraphNodeInput, + type ProjectResourceGraphReadModel, +} from './resourceDependencyGraphModel'; +import { ResourceDependencyOverlay } from './ResourceDependencyOverlay'; +import { + type ProjectAgentResultSummary, + type ProjectAttachmentResult, + type ProjectResource, + type ProjectResourceCategory, + projectResourcesFromReadModels, +} from './resourceProjectionModel'; import { useProjectResourceCanvasLayout } from './useProjectResourceCanvasLayout'; -type AttachmentResult = { - fileName: string; - mediaType: string; - localPath?: string; - status: 'imported' | 'failed'; - error?: string; -}; +export type { + ProjectAgentResultSummary, + ProjectVersionResourceSummary, +} from './resourceProjectionModel'; -type ResourceCategory = ProjectResourceCanvasSection; +type ResourceCategory = ProjectResourceCategory; type ResourceSortMode = ProjectResourceCanvasLayoutMode; type WorkbenchMode = 'resources' | 'run'; type ApprovalMode = 'strict' | 'risk' | 'none'; -type Point = { - x: number; - y: number; -}; - -type ResourceCardDrag = { - pointerId: number; - resourceId: string; - section: ResourceCategory; - startClientX: number; - startClientY: number; - startX: number; - startY: number; - moved: boolean; -}; - -type ProjectResource = { - id: string; - category: ResourceCategory; - subtype: string; - label: string; +type LocalProjectImagePreview = { path: string; mediaType: string; - sourceLabel: string; - taskTitle: string | null; - dependencies: string[]; - dependencyDepth: number; - content?: string; + byteLen: number; + dataUrl: string; }; -type LocalProjectImagePreview = { +type LocalProjectTextPreview = { + path: string; + mediaType: string; + byteLen: number; + content: string; +}; + +type LocalProjectMediaPreview = { path: string; mediaType: string; byteLen: number; @@ -108,14 +96,25 @@ type ImagePreviewState = } | { status: 'failed'; resourceId: string; error: string }; -export type ProjectAgentResultSummary = { - agentId: string; - runId: string; - label: string; - title: string; - content: string; - updatedAt: number; -}; +type TextPreviewState = + | { status: 'idle'; resourceId: null } + | { status: 'loading'; resourceId: string } + | { + status: 'loaded'; + resourceId: string; + preview: LocalProjectTextPreview; + } + | { status: 'failed'; resourceId: string; error: string }; + +type MediaPreviewState = + | { status: 'idle'; resourceId: null } + | { status: 'loading'; resourceId: string } + | { + status: 'loaded'; + resourceId: string; + preview: LocalProjectMediaPreview; + } + | { status: 'failed'; resourceId: string; error: string }; export type ProjectAgentRuntimeSummary = { group: GameCreationAppAgentGroup; @@ -131,6 +130,7 @@ export type ProjectAgentRuntimeSummary = { const emptyProjectAgentRuntimeSummaries: ProjectAgentRuntimeSummary[] = []; const emptyProjectAgentResults: ProjectAgentResultSummary[] = []; +const RESOURCE_DEPENDENCY_VISUAL_GUTTER = 64; type AgentSummary = ProjectAgentRuntimeSummary; @@ -138,7 +138,7 @@ export type ProjectDevelopmentViewProps = { projectName: string; projectPath: string; manifest: GameCreationAppManifest; - attachments: AttachmentResult[]; + attachments: ProjectAttachmentResult[]; recentRunStatus: string | null; recentRunStopReason: string | null; preview?: GameCreationAppPreviewState | null; @@ -147,6 +147,18 @@ export type ProjectDevelopmentViewProps = { supervisor: ReactNode; onHomeOpen: () => void; onProjectsOpen: () => void; + onManifestChange?: (manifest: GameCreationAppManifest) => void; + onPreviewChange?: (preview: GameCreationAppPreviewState | null) => void; +}; + +type RunnableGameVersionLaunchResult = { + manifest: GameCreationAppManifest; + version: RunnableGameVersion; + preview: { + url: string; + port: number; + root: string; + }; }; const categoryOrder: ResourceCategory[] = [ @@ -196,36 +208,6 @@ const approvalOptions: Array<{ }, ]; -function fileName(path: string) { - return path.split(/[\\/]/).filter(Boolean).pop() || path; -} - -function categoryFromResource(path: string, mediaType: string) { - const normalizedPath = path.toLowerCase(); - const normalizedMediaType = mediaType.toLowerCase(); - if ( - normalizedMediaType.startsWith('audio/') || - /\.(mp3|wav|ogg|m4a|aac|flac)$/u.test(normalizedPath) - ) { - return 'audio' as const; - } - if ( - normalizedMediaType.startsWith('image/') || - normalizedMediaType.startsWith('video/') || - /\.(png|jpe?g|webp|gif|svg|mp4|webm)$/u.test(normalizedPath) - ) { - return 'art' as const; - } - if ( - normalizedMediaType.includes('json') || - normalizedMediaType.startsWith('text/') || - /\.(md|txt|json|ya?ml|toml)$/u.test(normalizedPath) - ) { - return 'document' as const; - } - return 'version' as const; -} - function isRasterImageResource(resource: ProjectResource) { const mediaType = resource.mediaType.toLowerCase(); return ( @@ -235,6 +217,77 @@ function isRasterImageResource(resource: ProjectResource) { ); } +function isExtendedArtMediaResource(resource: ProjectResource) { + const mediaType = resource.mediaType.toLowerCase(); + return ( + resource.category === 'art' && + (mediaType === 'image/svg+xml' || + mediaType.startsWith('video/') || + /\.(gif|svg|avif|bmp|mp4|webm|mov)$/iu.test(resource.path)) + ); +} + +function mediaPreviewErrorMessage(error: unknown) { + const message = error instanceof Error ? error.message : String(error); + if (message.includes('项目权限策略要求用户确认')) { + return '当前项目策略要求先确认读取资源'; + } + if (message.includes('项目权限策略拒绝执行')) { + return '当前项目策略不允许读取资源'; + } + if (message.includes('不能超过')) { + return message; + } + if (message.includes('UTF-8') || message.includes('只支持')) { + return message; + } + if (message.includes('脚本或外部资源引用')) { + return message; + } + if (message.includes('发生漂移') || message.includes('发生替换')) { + return '资源读取期间发生变化,请关闭后重试'; + } + return '资源暂时无法读取,请关闭后重试'; +} + +function formatMediaDuration(duration: number | null) { + if (duration === null || !Number.isFinite(duration) || duration < 0) { + return '载入后显示'; + } + const totalSeconds = Math.floor(duration); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return `${minutes}:${seconds.toString().padStart(2, '0')}`; +} + +function formatVersionCreatedAt(createdAt: number) { + const value = new Date(createdAt); + return Number.isFinite(value.getTime()) + ? value.toLocaleString('zh-CN') + : String(createdAt); +} + +function SafeProjectMarkdown({ content }: { content: string }) { + return ( + ( +
{children} + ), + img: ({ alt }) => ( + + {alt ? `图片:${alt}` : '文档图片已省略'} + + ), + }} + > + {content} + + ); +} + function imagePreviewErrorMessage(error: unknown) { const message = error instanceof Error ? error.message : String(error); if (message.includes('项目权限策略要求用户确认')) { @@ -264,135 +317,6 @@ function imagePreviewErrorMessage(error: unknown) { return '图片暂时无法读取,请关闭后重试'; } -function taskDependencyDepth( - task: GameCreationAppTaskState, - taskById: Map, - seen = new Set(), -): number { - if (seen.has(task.id) || task.dependencies.length === 0) { - return 0; - } - const nextSeen = new Set(seen).add(task.id); - return ( - 1 + - Math.max( - 0, - ...task.dependencies.map((dependency) => { - const dependencyTask = taskById.get(dependency); - return dependencyTask - ? taskDependencyDepth(dependencyTask, taskById, nextSeen) - : 0; - }), - ) - ); -} - -function resourcesFromProject( - manifest: GameCreationAppManifest, - attachments: AttachmentResult[], - agentResults: ProjectAgentResultSummary[], -) { - const taskById = new Map(manifest.tasks.map((task) => [task.id, task])); - const resources: ProjectResource[] = []; - - for (const task of manifest.tasks) { - if (task.status !== 'completed') { - continue; - } - for (const path of task.artifacts) { - const category = categoryFromResource(path, ''); - resources.push({ - id: `task:${task.id}:${path}`, - category, - subtype: 'task-artifact', - label: fileName(path), - path, - mediaType: category === 'document' ? '项目文档' : '项目产物', - sourceLabel: '任务产物', - taskTitle: task.title, - dependencies: task.dependencies, - dependencyDepth: taskDependencyDepth(task, taskById), - }); - } - } - - for (const asset of manifest.assets) { - const task = asset.source.taskId - ? taskById.get(asset.source.taskId) - : undefined; - const isPendingUiPrototype = - asset.kind === 'ui-prototype' && - taskById.get('design-foundation')?.status !== 'completed'; - resources.push({ - id: `asset:${asset.id}`, - category: categoryFromResource(asset.localPath, asset.mediaType), - subtype: asset.kind, - label: `${fileName(asset.localPath)}${ - isPendingUiPrototype ? '(待视觉验收)' : '' - }`, - path: asset.localPath, - mediaType: asset.mediaType, - sourceLabel: - isPendingUiPrototype && asset.source.kind === 'canvas' - ? '画板 · 候选界面图' - : asset.source.kind === 'canvas' - ? '画板' - : asset.source.kind === 'generated' - ? 'Agent 生成' - : '用户上传', - taskTitle: task?.title ?? null, - dependencies: task?.dependencies ?? [], - dependencyDepth: task ? taskDependencyDepth(task, taskById) : 0, - }); - } - - for (const attachment of attachments) { - if (attachment.status !== 'imported' || !attachment.localPath) { - continue; - } - resources.push({ - id: `attachment:${attachment.localPath}`, - category: categoryFromResource( - attachment.localPath, - attachment.mediaType, - ), - subtype: 'attachment', - label: attachment.fileName, - path: attachment.localPath, - mediaType: attachment.mediaType || '未知媒体类型', - sourceLabel: '用户上传', - taskTitle: null, - dependencies: [], - dependencyDepth: 0, - }); - } - - for (const result of agentResults) { - resources.push({ - id: `agent-result:${result.agentId}:${result.runId}`, - category: 'document', - subtype: 'agent-result', - label: result.title, - path: `专业 Agent 文本回执 · ${result.label}`, - mediaType: 'Agent 历史文本回执', - sourceLabel: `历史成果 · ${result.label}`, - taskTitle: null, - dependencies: [], - dependencyDepth: 0, - content: result.content, - }); - } - - const uniqueByPath = new Map(); - for (const resource of resources) { - const existing = uniqueByPath.get(resource.path); - if (!existing || resource.id.startsWith('asset:')) { - uniqueByPath.set(resource.path, resource); - } - } - return Array.from(uniqueByPath.values()); -} - function summarizeAgent( manifest: GameCreationAppManifest, group: AgentSummary['group'], @@ -436,60 +360,58 @@ function summarizeAgent( }; } -function ResourceCard({ +const ResourceCard = memo(function ResourceCard({ resource, selected, - dragging, + currentVersion, + relationState, x, y, onSelect, - onPointerDown, - onPointerMove, - onPointerUp, - onPointerCancel, }: { resource: ProjectResource; selected: boolean; - dragging: boolean; + currentVersion: boolean; + relationState: 'version-binding' | null; x: number; y: number; - onSelect: () => void; - onPointerDown: (event: ReactPointerEvent) => void; - onPointerMove: (event: ReactPointerEvent) => void; - onPointerUp: (event: ReactPointerEvent) => void; - onPointerCancel: (event: ReactPointerEvent) => void; + onSelect: (resourceId: string) => void; }) { const Icon = categoryIcons[resource.category]; return ( ); -} +}); export default function ProjectDevelopmentView({ projectName, @@ -500,6 +422,8 @@ export default function ProjectDevelopmentView({ agentRuntimeSummaries = emptyProjectAgentRuntimeSummaries, agentResults = emptyProjectAgentResults, supervisor, + onManifestChange, + onPreviewChange, }: ProjectDevelopmentViewProps) { const [mode, setMode] = useState('resources'); const [sortMode, setSortMode] = useState('dependency'); @@ -507,77 +431,288 @@ export default function ProjectDevelopmentView({ const [selectedResourceId, setSelectedResourceId] = useState( null, ); + const [focusedResourceId, setFocusedResourceId] = useState( + null, + ); const [approvalMode, setApprovalMode] = useState('strict'); const [approvalDialogOpen, setApprovalDialogOpen] = useState(false); const [approvalNotice, setApprovalNotice] = useState(''); const [showAllAgentGroups, setShowAllAgentGroups] = useState(false); - const [runPlaying, setRunPlaying] = useState(false); - const [activeSlice, setActiveSlice] = useState(0); - const [draggedResourceId, setDraggedResourceId] = useState( - null, + const [runStatus, setRunStatus] = useState(''); + const [runSwitching, setRunSwitching] = useState(false); + const [selectedRunnableVersionId, setSelectedRunnableVersionId] = useState( + manifest.currentRunnableVersionId ?? '', ); - const [resourceDragPreview, setResourceDragPreview] = useState< - (Point & { resourceId: string }) | null - >(null); - const [resourceDialogPosition, setResourceDialogPosition] = - useState(null); + const [activeRunnablePreviewVersionId, setActiveRunnablePreviewVersionId] = + useState(null); const [imagePreview, setImagePreview] = useState({ status: 'idle', resourceId: null, }); - const workbenchRef = useRef(null); - const stageRef = useRef(null); - const dockRef = useRef(null); - const resourceDialogRef = useRef(null); - const resourceCardDragRef = useRef(null); - const suppressResourceClickRef = useRef(null); - const resourceDialogDragRef = useRef<{ - pointerId: number; - offsetX: number; - offsetY: number; - } | null>(null); + const [textPreview, setTextPreview] = useState({ + status: 'idle', + resourceId: null, + }); + const [mediaPreview, setMediaPreview] = useState({ + status: 'idle', + resourceId: null, + }); + const [mediaDuration, setMediaDuration] = useState(null); + const resourceCanvasRef = useRef(null); + const resourceFocusRef = useRef(null); + const resourceListScrollRef = useRef({ left: 0, top: 0 }); + const restoreResourceListScrollRef = useRef(false); - const preview = previewOverride ?? manifest.preview ?? null; - const embeddedPreviewUrl = resolveEmbeddedPreviewUrl(preview); - const runAvailable = - embeddedPreviewUrl !== null || - manifest.tasks.some( - (task) => task.id === 'code-prototype' && task.status === 'completed', - ); - const resources = useMemo( - () => resourcesFromProject(manifest, attachments, agentResults), + const preview = + previewOverride !== undefined + ? previewOverride + : (manifest.preview ?? null); + const runnableVersions = manifest.runnableVersions ?? []; + const currentRunnableVersionId = + selectedRunnableVersionId || manifest.currentRunnableVersionId || ''; + const currentRunnableVersion = + runnableVersions.find( + (version) => version.versionId === currentRunnableVersionId, + ) ?? null; + const embeddedPreviewUrl = + activeRunnablePreviewVersionId === currentRunnableVersionId + ? resolveEmbeddedPreviewUrl(preview) + : null; + const runAvailable = runnableVersions.length > 0 && currentRunnableVersion !== null; + const projectedResources = useMemo( + () => projectResourcesFromReadModels(manifest, attachments, agentResults), [agentResults, attachments, manifest], ); + const resourceGraphInputs = useMemo( + () => + projectedResources.map((resource) => ({ + resourceId: resource.id, + manifestAssetId: resource.manifestAssetId, + producerTaskId: resource.producerTaskId, + })), + [projectedResources], + ); + const resourceGraphScopeKey = useMemo( + () => + JSON.stringify([projectPath, manifest.projectId, resourceGraphInputs]), + [manifest.projectId, projectPath, resourceGraphInputs], + ); + const [resourceGraphState, setResourceGraphState] = useState<{ + scopeKey: string; + status: 'idle' | 'loading' | 'ready' | 'failed'; + graph: ProjectResourceGraph; + }>({ + scopeKey: '', + status: 'idle', + graph: EMPTY_PROJECT_RESOURCE_GRAPH, + }); + + useEffect(() => { + let cancelled = false; + if (sortMode !== 'dependency') { + setResourceGraphState({ + scopeKey: '', + status: 'idle', + graph: EMPTY_PROJECT_RESOURCE_GRAPH, + }); + return () => { + cancelled = true; + }; + } + const invoke = window.__TAURI__?.core?.invoke; + if (!invoke) { + setResourceGraphState({ + scopeKey: resourceGraphScopeKey, + status: 'failed', + graph: EMPTY_PROJECT_RESOURCE_GRAPH, + }); + return () => { + cancelled = true; + }; + } + setResourceGraphState({ + scopeKey: resourceGraphScopeKey, + status: 'loading', + graph: EMPTY_PROJECT_RESOURCE_GRAPH, + }); + void invoke( + 'read_local_project_resource_graph', + { + projectPath, + expectedProjectId: manifest.projectId, + resources: resourceGraphInputs, + }, + ) + .then((readModel) => { + if (!cancelled) { + setResourceGraphState({ + scopeKey: resourceGraphScopeKey, + status: 'ready', + graph: normalizeProjectResourceGraph(readModel), + }); + } + }) + .catch(() => { + if (!cancelled) { + setResourceGraphState({ + scopeKey: resourceGraphScopeKey, + status: 'failed', + graph: EMPTY_PROJECT_RESOURCE_GRAPH, + }); + } + }); + return () => { + cancelled = true; + }; + }, [ + manifest.projectId, + projectPath, + resourceGraphInputs, + resourceGraphScopeKey, + sortMode, + ]); + + const resourceGraphScopeMatches = + resourceGraphState.scopeKey === resourceGraphScopeKey; + const resourceGraphReady = + resourceGraphScopeMatches && resourceGraphState.status === 'ready'; + const resourceGraph = resourceGraphReady + ? resourceGraphState.graph + : EMPTY_PROJECT_RESOURCE_GRAPH; + const resourceGraphInitializationReady = + sortMode !== 'dependency' || + (resourceGraphScopeMatches && + (resourceGraphState.status === 'ready' || + resourceGraphState.status === 'failed')); + const manifestTaskById = useMemo( + () => new Map(manifest.tasks.map((task) => [task.id, task])), + [manifest.tasks], + ); + const resources = useMemo( + () => + projectedResources.map((resource) => { + const producerTaskId = + resourceGraph.producerTaskIdByResourceId.get(resource.id) ?? + resource.producerTaskId; + const producerTask = producerTaskId + ? manifestTaskById.get(producerTaskId) + : undefined; + return { + ...resource, + taskTitle: producerTask?.title ?? resource.taskTitle, + producerTaskId, + dependencies: producerTask?.dependencies ?? resource.dependencies, + dependencyDepth: + resourceGraph.dependencyDepthByResourceId.get(resource.id) ?? 0, + }; + }), + [manifestTaskById, projectedResources, resourceGraph], + ); const { layout: resourceLayout, notice: resourceLayoutNotice, saving: resourceLayoutSaving, - commitPosition: commitResourcePosition, } = useProjectResourceCanvasLayout({ projectPath, projectId: manifest.projectId, mode: sortMode, resources, + initializationReady: resourceGraphInitializationReady, + rederiveAutomaticPositions: sortMode === 'dependency' && resourceGraphReady, }); - const resourcePositionById = new Map( - resourceLayout.positions.map((position) => [position.resourceId, position]), + const resourcePositionById = useMemo( + () => + new Map( + resourceLayout.positions.map((position) => [ + position.resourceId, + position, + ]), + ), + [resourceLayout.positions], ); + const selectedVersionBindingResourceIds = useMemo(() => { + if (!currentRunnableVersion) { + return new Set(); + } + const boundManifestAssetIds = new Set( + currentRunnableVersion.resourceBindings.map( + (binding) => binding.resourceId, + ), + ); + return new Set( + resources + .filter( + (resource) => + resource.manifestAssetId !== null && + boundManifestAssetIds.has(resource.manifestAssetId), + ) + .map((resource) => resource.id), + ); + }, [currentRunnableVersion, resources]); const normalizedSearch = searchText.trim().toLowerCase(); - const visibleResources = resources.filter((resource) => - normalizedSearch - ? [ - resource.label, - resource.path, - resource.mediaType, - resource.taskTitle ?? '', - ].some((value) => value.toLowerCase().includes(normalizedSearch)) - : true, + const visibleResources = useMemo( + () => + resources.filter((resource) => + normalizedSearch + ? [ + resource.label, + resource.path, + resource.mediaType, + resource.taskTitle ?? '', + ].some((value) => value.toLowerCase().includes(normalizedSearch)) + : true, + ), + [normalizedSearch, resources], + ); + const visibleResourceIds = useMemo( + () => new Set(visibleResources.map((resource) => resource.id)), + [visibleResources], + ); + const visibleResourcesByCategory = useMemo( + () => + new Map( + categoryOrder.map((category) => [ + category, + visibleResources.filter((resource) => resource.category === category), + ]), + ), + [visibleResources], + ); + const resourcePositionsByCategory = useMemo( + () => + new Map( + categoryOrder.map((category) => [ + category, + resourceLayout.positions.filter( + (position) => position.section === category, + ), + ]), + ), + [resourceLayout.positions], + ); + const resourceBaseExtentByCategory = useMemo( + () => + new Map( + categoryOrder.map((category) => [ + category, + resourceCanvasSectionExtent( + resourcePositionsByCategory.get(category) ?? [], + ), + ]), + ), + [resourcePositionsByCategory], ); const selectedResource = resources.find((resource) => resource.id === selectedResourceId) ?? null; - const selectedResourceIsImage = Boolean( - selectedResource && isRasterImageResource(selectedResource), + const focusedResource = + resources.find((resource) => resource.id === focusedResourceId) ?? null; + const focusedResourceIsImage = Boolean( + focusedResource && isRasterImageResource(focusedResource), ); + const focusedResourceIsExtendedArtMedia = Boolean( + focusedResource && isExtendedArtMediaResource(focusedResource), + ); + const focusedResourceIsAudio = focusedResource?.category === 'audio'; const hasRegisteredArtImageAssets = manifest.assets.some( (asset) => asset.kind === 'art-spritesheet' && asset.mediaType.startsWith('image/'), @@ -613,35 +748,62 @@ export default function ProjectDevelopmentView({ const currentApprovalLabel = approvalOptions.find((option) => option.id === approvalMode)?.label ?? '严格审批'; + const FocusIcon = focusedResource + ? categoryIcons[focusedResource.category] + : FileText; + const focusedPreviewMediaType = + focusedResource && + mediaPreview.status === 'loaded' && + mediaPreview.resourceId === focusedResource.id + ? mediaPreview.preview.mediaType + : focusedResource && + imagePreview.status === 'loaded' && + imagePreview.resourceId === focusedResource.id + ? imagePreview.preview.mediaType + : focusedResource && + textPreview.status === 'loaded' && + textPreview.resourceId === focusedResource.id + ? textPreview.preview.mediaType + : focusedResource?.mediaType; + + useEffect(() => { + setSelectedRunnableVersionId(manifest.currentRunnableVersionId ?? ''); + }, [manifest.currentRunnableVersionId, manifest.projectId]); + + useEffect(() => { + setActiveRunnablePreviewVersionId(null); + }, [manifest.projectId]); useEffect(() => { if (embeddedPreviewUrl) { + setFocusedResourceId(null); setMode('run'); } }, [embeddedPreviewUrl]); useEffect(() => { setSelectedResourceId(null); - resourceCardDragRef.current = null; - setDraggedResourceId(null); - setResourceDragPreview(null); + setFocusedResourceId(null); + resourceListScrollRef.current = { left: 0, top: 0 }; + restoreResourceListScrollRef.current = false; }, [projectPath]); useEffect(() => { - if (!selectedResourceId) { + if (!focusedResourceId) { return undefined; } function closeOnEscape(event: KeyboardEvent) { if (event.key === 'Escape') { - setSelectedResourceId(null); + restoreResourceListScrollRef.current = true; + setFocusedResourceId(null); } } window.addEventListener('keydown', closeOnEscape); return () => window.removeEventListener('keydown', closeOnEscape); - }, [selectedResourceId]); + }, [focusedResourceId]); useEffect(() => { - if (!selectedResource || !selectedResourceIsImage) { + if (!focusedResource || !focusedResourceIsImage) { setImagePreview({ status: 'idle', resourceId: null }); return undefined; } @@ -649,23 +811,23 @@ export default function ProjectDevelopmentView({ if (!invoke) { setImagePreview({ status: 'failed', - resourceId: selectedResource.id, + resourceId: focusedResource.id, error: '图片预览需要在客户端内打开', }); return undefined; } let cancelled = false; - setImagePreview({ status: 'loading', resourceId: selectedResource.id }); + setImagePreview({ status: 'loading', resourceId: focusedResource.id }); void invoke('read_local_project_image_preview', { projectPath, - relativePath: selectedResource.path, + relativePath: focusedResource.path, }) .then((preview) => { if (!cancelled) { setImagePreview({ status: 'loaded', - resourceId: selectedResource.id, + resourceId: focusedResource.id, preview, }); } @@ -674,7 +836,7 @@ export default function ProjectDevelopmentView({ if (!cancelled) { setImagePreview({ status: 'failed', - resourceId: selectedResource.id, + resourceId: focusedResource.id, error: imagePreviewErrorMessage(error), }); } @@ -682,212 +844,225 @@ export default function ProjectDevelopmentView({ return () => { cancelled = true; }; - }, [projectPath, selectedResource, selectedResourceIsImage]); - - const clampResourceDialogPosition = useCallback((x: number, y: number) => { - const dialog = resourceDialogRef.current; - const workbench = workbenchRef.current; - const dock = dockRef.current; - if (!dialog) { - return { x, y }; - } - - const dialogRect = dialog.getBoundingClientRect(); - const workbenchRect = workbench?.getBoundingClientRect(); - const dockRect = dock?.getBoundingClientRect(); - const viewportWidth = document.documentElement.clientWidth; - const viewportHeight = document.documentElement.clientHeight; - const minX = Math.max(12, (workbenchRect?.left ?? 0) + 12); - const maxRight = Math.min( - viewportWidth - 12, - (workbenchRect?.right ?? viewportWidth) - 12, - ); - const minY = Math.max(12, (workbenchRect?.top ?? 0) + 12); - const maxBottom = Math.min( - viewportHeight - 12, - dockRect ? dockRect.top - 12 : viewportHeight - 12, - ); - const maxX = Math.max(minX, maxRight - dialogRect.width); - const maxY = Math.max(minY, maxBottom - dialogRect.height); - - return { - x: Math.min(Math.max(x, minX), maxX), - y: Math.min(Math.max(y, minY), maxY), - }; - }, []); - - useLayoutEffect(() => { - if (!selectedResource) { - setResourceDialogPosition(null); - return; - } - const dialog = resourceDialogRef.current; - const stage = stageRef.current; - if (!dialog || !stage) { - return; - } - const dialogRect = dialog.getBoundingClientRect(); - const stageRect = stage.getBoundingClientRect(); - setResourceDialogPosition( - clampResourceDialogPosition( - stageRect.left + (stageRect.width - dialogRect.width) / 2, - stageRect.top + (stageRect.height - dialogRect.height) / 2, - ), - ); - dialog.focus({ preventScroll: true }); - }, [clampResourceDialogPosition, selectedResource]); + }, [focusedResource, focusedResourceIsImage, projectPath]); useEffect(() => { - if (!selectedResource) { + if (!focusedResource || focusedResource.category !== 'document') { + setTextPreview({ status: 'idle', resourceId: null }); return undefined; } - function clampOnResize() { - setResourceDialogPosition((current) => - current ? clampResourceDialogPosition(current.x, current.y) : current, - ); + if (focusedResource.content !== undefined) { + setTextPreview({ + status: 'loaded', + resourceId: focusedResource.id, + preview: { + path: focusedResource.path, + mediaType: focusedResource.mediaType, + byteLen: new TextEncoder().encode(focusedResource.content).byteLength, + content: focusedResource.content, + }, + }); + return undefined; + } + const invoke = window.__TAURI__?.core?.invoke; + if (!invoke) { + setTextPreview({ + status: 'failed', + resourceId: focusedResource.id, + error: '文档预览需要在客户端内打开', + }); + return undefined; } - window.addEventListener('resize', clampOnResize); - return () => window.removeEventListener('resize', clampOnResize); - }, [clampResourceDialogPosition, selectedResource]); - function handleResourceCardPointerDown( - event: ReactPointerEvent, - resource: ProjectResource, - ) { - if (event.button !== 0) { - return; - } - const position = resourcePositionById.get(resource.id); - if (!position || position.section !== resource.category) { - return; - } - resourceCardDragRef.current = { - pointerId: event.pointerId, - resourceId: resource.id, - section: resource.category, - startClientX: event.clientX, - startClientY: event.clientY, - startX: position.x, - startY: position.y, - moved: false, + let cancelled = false; + setTextPreview({ status: 'loading', resourceId: focusedResource.id }); + void invoke('read_local_project_text_preview', { + projectPath, + relativePath: focusedResource.path, + }) + .then((preview) => { + if (!cancelled) { + setTextPreview({ + status: 'loaded', + resourceId: focusedResource.id, + preview, + }); + } + }) + .catch((error: unknown) => { + if (!cancelled) { + setTextPreview({ + status: 'failed', + resourceId: focusedResource.id, + error: mediaPreviewErrorMessage(error), + }); + } + }); + return () => { + cancelled = true; }; - event.currentTarget.setPointerCapture?.(event.pointerId); - } + }, [focusedResource, projectPath]); - function handleResourceCardPointerMove( - event: ReactPointerEvent, - ) { - const drag = resourceCardDragRef.current; - if (!drag || drag.pointerId !== event.pointerId) { - return; - } - const deltaX = event.clientX - drag.startClientX; - const deltaY = event.clientY - drag.startClientY; + useEffect(() => { if ( - !drag.moved && - Math.hypot(deltaX, deltaY) < RESOURCE_CANVAS_DRAG_THRESHOLD + !focusedResource || + (!focusedResourceIsExtendedArtMedia && !focusedResourceIsAudio) ) { - return; + setMediaPreview({ status: 'idle', resourceId: null }); + setMediaDuration(null); + return undefined; + } + const invoke = window.__TAURI__?.core?.invoke; + if (!invoke) { + setMediaPreview({ + status: 'failed', + resourceId: focusedResource.id, + error: '媒体预览需要在客户端内打开', + }); + return undefined; } - drag.moved = true; - setDraggedResourceId(drag.resourceId); - setResourceDragPreview({ - resourceId: drag.resourceId, - x: Math.max(0, drag.startX + deltaX), - y: Math.max(0, drag.startY + deltaY), - }); - event.preventDefault(); - } - function handleResourceCardPointerEnd( - event: ReactPointerEvent, - cancelled: boolean, - ) { - const drag = resourceCardDragRef.current; - if (!drag || drag.pointerId !== event.pointerId) { - return; - } - resourceCardDragRef.current = null; - if (event.currentTarget.hasPointerCapture?.(event.pointerId)) { - event.currentTarget.releasePointerCapture?.(event.pointerId); - } - if (drag.moved && !cancelled) { - suppressResourceClickRef.current = drag.resourceId; - commitResourcePosition( - drag.resourceId, - drag.section, - Math.max(0, drag.startX + event.clientX - drag.startClientX), - Math.max(0, drag.startY + event.clientY - drag.startClientY), - ); - } - setDraggedResourceId(null); - setResourceDragPreview(null); - } - - function handleResourceDialogPointerDown( - event: ReactPointerEvent, - ) { - if (event.button !== 0 || (event.target as HTMLElement).closest('button')) { - return; - } - const dialogRect = resourceDialogRef.current?.getBoundingClientRect(); - if (!dialogRect) { - return; - } - resourceDialogDragRef.current = { - pointerId: event.pointerId, - offsetX: event.clientX - dialogRect.left, - offsetY: event.clientY - dialogRect.top, + let cancelled = false; + setMediaDuration(null); + setMediaPreview({ status: 'loading', resourceId: focusedResource.id }); + void invoke('read_local_project_media_preview', { + projectPath, + relativePath: focusedResource.path, + category: focusedResource.category, + }) + .then((preview) => { + if (!cancelled) { + setMediaPreview({ + status: 'loaded', + resourceId: focusedResource.id, + preview, + }); + } + }) + .catch((error: unknown) => { + if (!cancelled) { + setMediaPreview({ + status: 'failed', + resourceId: focusedResource.id, + error: mediaPreviewErrorMessage(error), + }); + } + }); + return () => { + cancelled = true; }; - event.currentTarget.setPointerCapture(event.pointerId); - event.preventDefault(); - } + }, [ + focusedResource, + focusedResourceIsAudio, + focusedResourceIsExtendedArtMedia, + projectPath, + ]); - function handleResourceDialogPointerMove( - event: ReactPointerEvent, - ) { - const drag = resourceDialogDragRef.current; - if (!drag || drag.pointerId !== event.pointerId) { + useLayoutEffect(() => { + if (focusedResource) { + resourceFocusRef.current?.focus({ preventScroll: true }); return; } - setResourceDialogPosition( - clampResourceDialogPosition( - event.clientX - drag.offsetX, - event.clientY - drag.offsetY, - ), - ); - } - - function handleResourceDialogPointerEnd( - event: ReactPointerEvent, - ) { - if (resourceDialogDragRef.current?.pointerId !== event.pointerId) { + if (!restoreResourceListScrollRef.current) { return; } - resourceDialogDragRef.current = null; - if (event.currentTarget.hasPointerCapture(event.pointerId)) { - event.currentTarget.releasePointerCapture(event.pointerId); + const canvas = resourceCanvasRef.current; + if (canvas) { + canvas.scrollLeft = resourceListScrollRef.current.left; + canvas.scrollTop = resourceListScrollRef.current.top; + restoreResourceListScrollRef.current = false; + } + }, [focusedResource]); + + const handleResourceSelect = useCallback((resourceId: string) => { + const canvas = resourceCanvasRef.current; + if (canvas) { + resourceListScrollRef.current = { + left: canvas.scrollLeft, + top: canvas.scrollTop, + }; + } + setSelectedResourceId(resourceId); + setFocusedResourceId(resourceId); + }, []); + + function closeResourceFocus() { + restoreResourceListScrollRef.current = true; + setFocusedResourceId(null); + } + + async function launchRunnableVersion(versionId: string) { + const invoke = resolveTauriInvoke(); + if (!invoke) { + setRunStatus('需要在 Tauri App 内运行'); + return false; + } + setRunSwitching(true); + setRunStatus('正在启动可运行版本'); + setSelectedRunnableVersionId(versionId); + setActiveRunnablePreviewVersionId(null); + onPreviewChange?.(null); + try { + const result = await invoke( + 'launch_local_game_runnable_version', + { + projectPath, + expectedProjectId: manifest.projectId, + versionId, + }, + ); + onManifestChange?.(result.manifest); + onPreviewChange?.({ + status: 'running', + url: result.preview.url, + port: result.preview.port, + }); + setSelectedRunnableVersionId(result.version.versionId); + setActiveRunnablePreviewVersionId(result.version.versionId); + setRunStatus(`正在运行版本 ${result.version.versionId}`); + return true; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + setSelectedRunnableVersionId(manifest.currentRunnableVersionId ?? ''); + setActiveRunnablePreviewVersionId(null); + setRunStatus(message); + onPreviewChange?.({ status: 'failed' }); + return false; + } finally { + setRunSwitching(false); } } function showRunView() { - if (!runAvailable) { + if (!currentRunnableVersion) { + setRunStatus( + runnableVersions.length > 0 + ? '当前可运行版本选择无效' + : '当前无可运行版本', + ); return; } + setFocusedResourceId(null); setMode('run'); + void launchRunnableVersion(currentRunnableVersion.versionId); } return (
@@ -915,7 +1090,7 @@ export default function ProjectDevelopmentView({
- {mode === 'resources' ? ( + {mode === 'resources' && !focusedResource ? ( <>
- {!runAvailable ? ( + {!runAvailable && !focusedResource ? (

- 首个可运行原型尚未完成,运行视图暂不可用 + {runStatus || '当前无可运行版本'}

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

{imagePreview.error}

+ ) : ( +

正在载入图片…

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

文档为空

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

{textPreview.error}

+ ) : ( +

正在载入文档…

+ )} +
+ ) : null} +
+
+
资源路径
+
{focusedResource.path}
+
+
+
资源类型
+
{focusedPreviewMediaType}
+
+
+
资源来源
+
{focusedResource.sourceLabel}
+
+ {focusedResource.taskTitle ? ( +
+
来源任务
+
{focusedResource.taskTitle}
+
+ ) : null} + {focusedResourceIsAudio ? ( +
+
音频时长
+
{formatMediaDuration(mediaDuration)}
+
+ ) : null} + {focusedResource.version ? ( + <> +
+
版本 ID
+
{focusedResource.version.versionId}
+
+
+
项目修订
+
{focusedResource.version.projectRevision}
+
+
+
父版本
+
+ {focusedResource.version.parentVersionId ?? + '首个版本'} +
+
+
+
直接子版本
+
+ {focusedResource.version.childVersionIds.length > 0 + ? focusedResource.version.childVersionIds.join('、') + : '暂无'} +
+
+
+
创建原因
+
+ { + { + initial: '初始版本', + 'resource-replacement': '资源替换', + 'agent-revision': 'Agent 修订', + }[focusedResource.version.createdReason] + } +
+
+
+
创建时间
+
+ {formatVersionCreatedAt( + focusedResource.version.createdAt, + )} +
+
+
+
资源绑定
+
+ {focusedResource.version.resourceBindings.length > 0 + ? focusedResource.version.resourceBindings + .map( + (binding) => + `${binding.slotId} → ${binding.resourceId}`, + ) + .join(';') + : '暂无'} +
+
+ + ) : null} +
+
+
+ ) : mode === 'resources' ? (
) : null}
- {categoryOrder.map((category) => { - const categoryResources = visibleResources.filter( - (resource) => resource.category === category, - ); - const categoryPositions = resourceLayout.positions.filter( - (position) => position.section === category, - ); - const extent = resourceCanvasSectionExtent( - categoryPositions.map((position) => - resourceDragPreview?.resourceId === position.resourceId - ? { - ...position, - x: resourceDragPreview.x, - y: resourceDragPreview.y, - } - : position, - ), - ); - const Icon = categoryIcons[category]; - return ( -
-
- - - {categoryResources.length} -
- {categoryResources.length > 0 ? ( -
- {categoryResources.map((resource) => { - const position = resourcePositionById.get( - resource.id, - ); - if (!position) { - return null; - } - const preview = - resourceDragPreview?.resourceId === resource.id - ? resourceDragPreview - : null; - return ( - { - if ( - suppressResourceClickRef.current === - resource.id - ) { - suppressResourceClickRef.current = null; - return; +
+ {sortMode === 'dependency' ? ( + + ) : null} + {categoryOrder.map((category) => { + const categoryResources = + visibleResourcesByCategory.get(category) ?? []; + const baseExtent = resourceBaseExtentByCategory.get( + category, + ) ?? { width: 0, height: 0 }; + const extent = { + width: + baseExtent.width + + (sortMode === 'dependency' + ? RESOURCE_DEPENDENCY_VISUAL_GUTTER + : 0), + height: baseExtent.height, + }; + const Icon = categoryIcons[category]; + return ( +
+
+ + + {categoryResources.length} +
+ {categoryResources.length > 0 ? ( +
+ {categoryResources.map((resource) => { + const position = resourcePositionById.get( + resource.id, + ); + if (!position) { + return null; + } + const relationState = + selectedVersionBindingResourceIds.has( + resource.id, + ) + ? 'version-binding' + : null; + return ( + - handleResourceCardPointerDown(event, resource) - } - onPointerMove={handleResourceCardPointerMove} - onPointerUp={(event) => - handleResourceCardPointerEnd(event, false) - } - onPointerCancel={(event) => - handleResourceCardPointerEnd(event, true) - } - /> - ); - })} -
- ) : ( -

暂无已登记资源

- )} -
- ); - })} + relationState={relationState} + x={position.x} + y={position.y} + onSelect={handleResourceSelect} + /> + ); + })} +
+ ) : ( +

暂无已登记资源

+ )} +
+ ); + })} +
) : ( @@ -1096,92 +1525,44 @@ export default function ProjectDevelopmentView({ ) : (
)} -
- - - {`测试切片 ${activeSlice + 1}`} - -
-
+
- {selectedResource ? ( + {currentRunnableVersion ? (
-
名称
-
{selectedResource.label}
+
版本
+
{currentRunnableVersion.versionId}
-
路径
-
{selectedResource.path}
+
修订
+
{currentRunnableVersion.projectRevision}
-
类型
-
{selectedResource.mediaType}
+
验证
+
静态检查与交互试玩均通过
) : ( -

暂停后选择资源可查看已登记信息

+

当前无可运行版本

)}
-
-
-
- - - -
+ {runStatus ? ( +

+ {runStatus} +

+ ) : null}
)} @@ -1207,11 +1588,7 @@ export default function ProjectDevelopmentView({ -