From ffb4cb5d62958876da4d8fbb366a59ef4523ae21 Mon Sep 17 00:00:00 2001 From: menghao Date: Tue, 11 Aug 2026 18:04:11 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E8=B5=84=E6=BA=90=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E5=B7=A5=E4=BD=9C=E5=8F=B0=E4=B8=8E=E6=9C=89=E7=95=8C?= =?UTF-8?q?=E9=A2=84=E8=A7=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将资源卡本体化并补齐分区缩放滚动与依赖聚类 增加全进程三槽预览调度范围取消与安全分块读取 保持布局 CAS 预览缓存回收和依赖图权威合同 补齐资源管理前端 Tauri AppSurface 回归并同步文档 --- .../src-tauri/src/commands.rs | 83 +- .../src-tauri/src/image_inspect.rs | 95 +- .../src-tauri/src/main.rs | 4 + .../src-tauri/src/project/resource_layout.rs | 18 +- .../src-tauri/src/resource_inspect.rs | 96 +- .../src/resource_preview_scheduler.rs | 676 ++++++ .../src-tauri/src/tests/project_tools.rs | 86 +- .../SupervisorChatOnlyView.tsx | 9 + .../projectResourcePreviewTransport.ts | 51 + apps/ai-game-creator-shell/src/styles.css | 388 ++-- .../ResourceDependencyOverlay.tsx | 907 ++++---- .../src/view/project-development/index.tsx | 1892 +++++++++++------ .../resourceCanvasLayoutModel.ts | 709 +++++- .../resourceCardPreviewModel.ts | 167 ++ .../resourceSectionHeightModel.ts | 122 ++ .../useProjectResourceCanvasLayout.ts | 147 +- .../useProjectResourceCardPreviews.ts | 659 ++++++ .../useProjectResourceSectionHeights.ts | 239 +++ .../tests/ResourceDependencyOverlay.test.ts | 551 ++++- .../tests/appSurface/harness.ts | 22 + .../tests/appSurface/home.suite.ts | 4 +- .../appSurface/project-development.suite.ts | 1478 +++++++++++-- .../tests/resourceCanvasLayoutModel.test.ts | 382 +++- .../tests/resourceSectionHeightModel.test.ts | 103 + .../useProjectResourceCanvasLayout.test.ts | 130 +- .../useProjectResourceCardPreviews.test.ts | 686 ++++++ ...AI游戏创作】项目开发工作台PRD-2026-07-20.md | 76 +- ...资源管理评审缺陷修复与阶段七收口-2026-08-11.md | 374 ++++ .../shared-memory/decision-log.md | 70 +- docs/project-memory/shared-memory/pitfalls.md | 68 +- ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 39 +- .../shared/src/contracts/gameCreationApp.ts | 12 + 32 files changed, 8777 insertions(+), 1566 deletions(-) create mode 100644 apps/ai-game-creator-shell/src-tauri/src/resource_preview_scheduler.rs create mode 100644 apps/ai-game-creator-shell/src/services/projectResourcePreviewTransport.ts create mode 100644 apps/ai-game-creator-shell/src/view/project-development/resourceCardPreviewModel.ts create mode 100644 apps/ai-game-creator-shell/src/view/project-development/resourceSectionHeightModel.ts create mode 100644 apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts create mode 100644 apps/ai-game-creator-shell/src/view/project-development/useProjectResourceSectionHeights.ts create mode 100644 apps/ai-game-creator-shell/tests/resourceSectionHeightModel.test.ts create mode 100644 apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts create mode 100644 docs/project-memory/plans/【实施计划】资源管理评审缺陷修复与阶段七收口-2026-08-11.md 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 4f3af8430..f13cea5f4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -1255,14 +1255,32 @@ pub(crate) fn read_local_project_file( } #[tauri::command] -pub(crate) fn read_local_project_image_preview( +pub(crate) async fn read_local_project_image_preview( + preview_manager: tauri::State<'_, ProjectResourcePreviewReadManager>, project_path: String, relative_path: String, + scope_id: String, + request_id: String, ) -> Result { + preview_manager + .run(&scope_id, &request_id, move |cancellation| { + read_local_project_image_preview_at(&project_path, &relative_path, cancellation) + }) + .await +} + +pub(crate) fn read_local_project_image_preview_at( + project_path: &str, + relative_path: &str, + cancellation: &ProjectResourcePreviewScopeCancellation, +) -> Result { + cancellation.check()?; let root = Path::new(project_path.trim()); enforce_project_auto_permission_policy(root, "file.read")?; + cancellation.check()?; let normalized_path = normalize_relative_path(relative_path.trim())?; let manifest = read_manifest(&root.join(".agent/manifest.json"))?; + cancellation.check()?; let is_registered_asset = manifest .assets .iter() @@ -1274,18 +1292,37 @@ pub(crate) fn read_local_project_image_preview( if !is_registered_asset && !is_completed_task_artifact { return Err("只能预览已登记资源或已完成任务的图片产物".to_string()); } - load_local_project_image_preview(root, &normalized_path) + cancellation.check()?; + load_local_project_image_preview_with_cancellation(root, &normalized_path, cancellation) } #[tauri::command] -pub(crate) fn read_local_project_text_preview( +pub(crate) async fn read_local_project_text_preview( + preview_manager: tauri::State<'_, ProjectResourcePreviewReadManager>, project_path: String, relative_path: String, + scope_id: String, + request_id: String, ) -> Result { + preview_manager + .run(&scope_id, &request_id, move |cancellation| { + read_local_project_text_preview_at(&project_path, &relative_path, cancellation) + }) + .await +} + +pub(crate) fn read_local_project_text_preview_at( + project_path: &str, + relative_path: &str, + cancellation: &ProjectResourcePreviewScopeCancellation, +) -> Result { + cancellation.check()?; let root = Path::new(project_path.trim()); enforce_project_auto_permission_policy(root, "file.read")?; + cancellation.check()?; let normalized_path = normalize_relative_path(relative_path.trim())?; let manifest = read_manifest(&root.join(".agent/manifest.json"))?; + cancellation.check()?; 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) @@ -1297,19 +1334,44 @@ pub(crate) fn read_local_project_text_preview( if !is_registered_document { return Err("只能读取当前项目已登记的文档资源".to_string()); } - load_local_project_text_preview(root, &normalized_path) + cancellation.check()?; + load_local_project_text_preview_with_cancellation(root, &normalized_path, cancellation) } #[tauri::command] -pub(crate) fn read_local_project_media_preview( +pub(crate) async fn read_local_project_media_preview( + preview_manager: tauri::State<'_, ProjectResourcePreviewReadManager>, project_path: String, relative_path: String, category: String, + scope_id: String, + request_id: String, ) -> Result { + preview_manager + .run(&scope_id, &request_id, move |cancellation| { + read_local_project_media_preview_at( + &project_path, + &relative_path, + &category, + cancellation, + ) + }) + .await +} + +pub(crate) fn read_local_project_media_preview_at( + project_path: &str, + relative_path: &str, + category: &str, + cancellation: &ProjectResourcePreviewScopeCancellation, +) -> Result { + cancellation.check()?; let root = Path::new(project_path.trim()); enforce_project_auto_permission_policy(root, "file.read")?; + cancellation.check()?; let normalized_path = normalize_relative_path(relative_path.trim())?; let manifest = read_manifest(&root.join(".agent/manifest.json"))?; + cancellation.check()?; let kind = match category.trim() { "art" => ProjectMediaPreviewKind::Art, "audio" => ProjectMediaPreviewKind::Audio, @@ -1334,7 +1396,16 @@ pub(crate) fn read_local_project_media_preview( if !is_registered_media { return Err("只能预览当前项目已登记的媒体资源".to_string()); } - load_local_project_media_preview(root, &normalized_path, kind) + cancellation.check()?; + load_local_project_media_preview_with_cancellation(root, &normalized_path, kind, cancellation) +} + +#[tauri::command] +pub(crate) fn cancel_local_project_resource_preview_scope( + preview_manager: tauri::State<'_, ProjectResourcePreviewReadManager>, + scope_id: String, +) -> Result<(), String> { + preview_manager.cancel_scope(&scope_id) } #[tauri::command] 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 5d6362ba5..752160a6e 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 @@ -2,6 +2,7 @@ use crate::project::{ normalize_relative_path, open_project_snapshot_regular_file, reject_sensitive_project_file_read, resolve_local_project_path, }; +use crate::resource_preview_scheduler::ProjectResourcePreviewScopeCancellation; use base64::Engine as _; use serde::Serialize; use sha2::{Digest, Sha256}; @@ -15,6 +16,7 @@ pub(crate) const AGENT_RUNTIME_IMAGE_INSPECT_MAX_FILE_BYTES: u64 = 8 * 1024 * 10 pub(crate) const AGENT_RUNTIME_IMAGE_INSPECT_MAX_TOTAL_BYTES: u64 = 12 * 1024 * 1024; const PROJECT_IMAGE_PREVIEW_MAX_DIMENSION: u32 = 8_192; const PROJECT_IMAGE_PREVIEW_MAX_PIXELS: u64 = 32 * 1024 * 1024; +const PROJECT_IMAGE_PREVIEW_READ_CHUNK_BYTES: usize = 64 * 1024; #[derive(Debug, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] @@ -41,12 +43,34 @@ impl AgentRuntimeInspectionImage { base64::engine::general_purpose::STANDARD.encode(&self.bytes) ) } + + fn data_url_with_cancellation( + &self, + cancellation: &ProjectResourcePreviewScopeCancellation, + ) -> Result { + cancellation.check()?; + Ok(self.data_url()) + } } +#[cfg(test)] pub(crate) fn load_local_project_image_preview( root: &Path, relative_path: &str, ) -> Result { + load_local_project_image_preview_with_cancellation( + root, + relative_path, + &ProjectResourcePreviewScopeCancellation::uncancelled(), + ) +} + +pub(crate) fn load_local_project_image_preview_with_cancellation( + root: &Path, + relative_path: &str, + cancellation: &ProjectResourcePreviewScopeCancellation, +) -> Result { + cancellation.check()?; let normalized = normalize_relative_path(relative_path.trim())?; if !normalized.starts_with("assets/") && !normalized.starts_with("game/") { return Err("图片预览只允许读取 assets/ 或 game/ 下的项目图片".to_string()); @@ -54,12 +78,15 @@ pub(crate) fn load_local_project_image_preview( reject_sensitive_project_file_read(&normalized)?; let absolute = resolve_local_project_path(root, &normalized)?; validate_agent_runtime_inspection_ancestors(root, &absolute)?; - let image = read_agent_runtime_inspection_image(&absolute, normalized)?; + cancellation.check()?; + let image = + read_agent_runtime_inspection_image_with_cancellation(&absolute, normalized, cancellation)?; + cancellation.check()?; Ok(LocalProjectImagePreview { path: image.relative_path.clone(), media_type: image.media_type.to_string(), byte_len: image.byte_len, - data_url: image.data_url(), + data_url: image.data_url_with_cancellation(cancellation)?, }) } @@ -236,7 +263,21 @@ fn read_agent_runtime_inspection_image( path: &Path, relative_path: String, ) -> Result { + read_agent_runtime_inspection_image_with_cancellation( + path, + relative_path, + &ProjectResourcePreviewScopeCancellation::uncancelled(), + ) +} + +fn read_agent_runtime_inspection_image_with_cancellation( + path: &Path, + relative_path: String, + cancellation: &ProjectResourcePreviewScopeCancellation, +) -> Result { + cancellation.check()?; let (mut file, initial_metadata) = open_project_snapshot_regular_file(path, "视觉检查图片")?; + cancellation.check()?; if initial_metadata.len() == 0 { return Err(format!("image.inspect 图片不能为空:{relative_path}")); } @@ -248,10 +289,26 @@ fn read_agent_runtime_inspection_image( } let mut bytes = Vec::with_capacity(initial_metadata.len() as usize); - file.by_ref() - .take(AGENT_RUNTIME_IMAGE_INSPECT_MAX_FILE_BYTES + 1) - .read_to_end(&mut bytes) - .map_err(|error| format!("读取 image.inspect 图片失败:{relative_path}: {error}"))?; + let mut chunk = [0_u8; PROJECT_IMAGE_PREVIEW_READ_CHUNK_BYTES]; + loop { + cancellation.check()?; + let remaining = + (AGENT_RUNTIME_IMAGE_INSPECT_MAX_FILE_BYTES + 1).saturating_sub(bytes.len() as u64); + if remaining == 0 { + break; + } + let read_len = usize::try_from(remaining) + .unwrap_or(usize::MAX) + .min(chunk.len()); + let count = file + .read(&mut chunk[..read_len]) + .map_err(|error| format!("读取 image.inspect 图片失败:{relative_path}: {error}"))?; + if count == 0 { + break; + } + bytes.extend_from_slice(&chunk[..count]); + } + cancellation.check()?; if bytes.len() as u64 > AGENT_RUNTIME_IMAGE_INSPECT_MAX_FILE_BYTES { return Err(format!( "image.inspect 单张图片不能超过 {} MiB:{relative_path}", @@ -270,14 +327,17 @@ fn read_agent_runtime_inspection_image( )); } + cancellation.check()?; let (reopened, reopened_metadata) = open_project_snapshot_regular_file(path, "视觉检查图片")?; if !same_open_file_identity(&file, &initial_metadata, &reopened, &reopened_metadata)? { return Err(format!( "image.inspect 图片路径读取期间发生替换:{relative_path}" )); } + cancellation.check()?; let media_type = detect_agent_runtime_image_media_type(&bytes) .ok_or_else(|| format!("image.inspect 只支持 PNG、JPEG 或 WEBP:{relative_path}"))?; + cancellation.check()?; let (width, height) = detect_raster_image_dimensions(&bytes, media_type) .ok_or_else(|| format!("image.inspect 图片结构无效:{relative_path}"))?; let pixels = u64::from(width) @@ -293,6 +353,7 @@ fn read_agent_runtime_inspection_image( "image.inspect 图片尺寸过大:{width}x{height},最大边长 {PROJECT_IMAGE_PREVIEW_MAX_DIMENSION},最大像素 {PROJECT_IMAGE_PREVIEW_MAX_PIXELS}:{relative_path}" )); } + cancellation.check()?; let sha256 = format!("{:x}", Sha256::digest(&bytes)); Ok(AgentRuntimeInspectionImage { relative_path, @@ -588,6 +649,28 @@ mod tests { assert!(preview.data_url.starts_with("data:image/png;base64,")); } + #[test] + fn cancelled_image_preview_does_not_enter_base64_encoding() { + let root = tempfile::tempdir().expect("temp root"); + fs::create_dir_all(root.path().join("assets/ui")).expect("asset dir"); + fs::write(root.path().join("assets/ui/prototype.png"), png_bytes()).expect("image"); + let image = read_agent_runtime_inspection_image( + &root.path().join("assets/ui/prototype.png"), + "assets/ui/prototype.png".to_string(), + ) + .expect("read project image"); + let cancellation = ProjectResourcePreviewScopeCancellation::uncancelled(); + cancellation.cancel(); + + assert_eq!( + image.data_url_with_cancellation(&cancellation), + Err( + crate::resource_preview_scheduler::PROJECT_RESOURCE_PREVIEW_CANCELLED_ERROR + .to_string() + ) + ); + } + #[test] fn local_project_image_preview_rejects_non_project_asset_paths() { let root = tempfile::tempdir().expect("temp root"); 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 2428dabf6..98cecf815 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -74,6 +74,7 @@ mod provider_handoff; mod provider_retry; mod repository_context; mod resource_inspect; +mod resource_preview_scheduler; mod runner; mod swarm_cli; mod tool_plan_handoff; @@ -104,6 +105,7 @@ use process_session::*; use project::*; use repository_context::*; use resource_inspect::*; +use resource_preview_scheduler::*; use runner::*; use swarm_cli::*; use user_input::*; @@ -2040,6 +2042,7 @@ fn main() { .plugin(tauri_plugin_http::init()) .plugin(tauri_plugin_clipboard_manager::init()) .manage(game_creator_preview_registry()) + .manage(ProjectResourcePreviewReadManager::default()) .setup(move |app| { if let Some(path) = setup_log.as_deref() { let _ = append_bounded_diagnostic_line(path, "startup.setup.begin"); @@ -2223,6 +2226,7 @@ fn main() { read_local_project_image_preview, read_local_project_text_preview, read_local_project_media_preview, + cancel_local_project_resource_preview_scope, write_local_project_file, delete_local_project_file, read_local_game_memory, diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/resource_layout.rs b/apps/ai-game-creator-shell/src-tauri/src/project/resource_layout.rs index f6045c9fc..d4c23f8e9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/resource_layout.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/resource_layout.rs @@ -862,11 +862,27 @@ mod tests { .expect_err("unknown schema must fail"); assert!(schema_error.contains("schema")); - let coordinate_error = update_project_resource_canvas_layout_at( + let boundary = update_project_resource_canvas_layout_at( &root, ProjectResourceCanvasLayoutMode::Type, "layout-invalid", 0, + vec![layout_position( + "asset-coordinate-boundary", + RESOURCE_LAYOUT_MAX_COORDINATE, + )], + ) + .expect("boundary coordinate must remain valid"); + assert_eq!( + boundary.layout.positions[0].x, + RESOURCE_LAYOUT_MAX_COORDINATE + ); + + let coordinate_error = update_project_resource_canvas_layout_at( + &root, + ProjectResourceCanvasLayoutMode::Type, + "layout-invalid", + boundary.layout.revision, vec![layout_position( "asset-coordinate", RESOURCE_LAYOUT_MAX_COORDINATE + 1, diff --git a/apps/ai-game-creator-shell/src-tauri/src/resource_inspect.rs b/apps/ai-game-creator-shell/src-tauri/src/resource_inspect.rs index 129b01f75..db740c23e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/resource_inspect.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/resource_inspect.rs @@ -5,6 +5,7 @@ use crate::project::{ normalize_relative_path, open_project_snapshot_regular_file, reject_sensitive_project_file_read, resolve_local_project_path, }; +use crate::resource_preview_scheduler::ProjectResourcePreviewScopeCancellation; use base64::Engine as _; use serde::Serialize; use std::io::Read; @@ -12,6 +13,7 @@ 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; +const PROJECT_RESOURCE_PREVIEW_READ_CHUNK_BYTES: usize = 64 * 1024; #[derive(Debug, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] @@ -69,10 +71,24 @@ pub(crate) fn is_supported_project_audio_resource(path: &str, media_type: &str) ) || media_type.starts_with("audio/") } +#[cfg(test)] pub(crate) fn load_local_project_text_preview( root: &Path, relative_path: &str, ) -> Result { + load_local_project_text_preview_with_cancellation( + root, + relative_path, + &ProjectResourcePreviewScopeCancellation::uncancelled(), + ) +} + +pub(crate) fn load_local_project_text_preview_with_cancellation( + root: &Path, + relative_path: &str, + cancellation: &ProjectResourcePreviewScopeCancellation, +) -> Result { + cancellation.check()?; let normalized = normalize_relative_path(relative_path.trim())?; reject_sensitive_project_file_read(&normalized)?; let media_type = project_text_media_type(&normalized) @@ -82,7 +98,9 @@ pub(crate) fn load_local_project_text_preview( &normalized, PROJECT_TEXT_PREVIEW_MAX_FILE_BYTES, "项目文档", + cancellation, )?; + cancellation.check()?; let content = String::from_utf8(bytes).map_err(|_| "文档预览只支持 UTF-8 编码的文本文件".to_string())?; Ok(LocalProjectTextPreview { @@ -93,11 +111,27 @@ pub(crate) fn load_local_project_text_preview( }) } +#[cfg(test)] pub(crate) fn load_local_project_media_preview( root: &Path, relative_path: &str, kind: ProjectMediaPreviewKind, ) -> Result { + load_local_project_media_preview_with_cancellation( + root, + relative_path, + kind, + &ProjectResourcePreviewScopeCancellation::uncancelled(), + ) +} + +pub(crate) fn load_local_project_media_preview_with_cancellation( + root: &Path, + relative_path: &str, + kind: ProjectMediaPreviewKind, + cancellation: &ProjectResourcePreviewScopeCancellation, +) -> Result { + cancellation.check()?; let normalized = normalize_relative_path(relative_path.trim())?; reject_sensitive_project_file_read(&normalized)?; let bytes = read_stable_project_resource( @@ -105,39 +139,70 @@ pub(crate) fn load_local_project_media_preview( &normalized, PROJECT_MEDIA_PREVIEW_MAX_FILE_BYTES, "项目媒体资源", + cancellation, )?; if bytes.is_empty() { return Err("媒体文件为空,无法预览".to_string()); } + cancellation.check()?; let media_type = detect_project_media_type(&normalized, &bytes, kind)?; + cancellation.check()?; 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) - ), + data_url: encode_project_resource_preview_data_url(media_type, &bytes, cancellation)?, }) } +fn encode_project_resource_preview_data_url( + media_type: &str, + bytes: &[u8], + cancellation: &ProjectResourcePreviewScopeCancellation, +) -> Result { + cancellation.check()?; + Ok(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, + cancellation: &ProjectResourcePreviewScopeCancellation, ) -> Result, String> { + cancellation.check()?; let absolute = resolve_local_project_path(root, normalized)?; validate_agent_runtime_inspection_ancestors(root, &absolute)?; + cancellation.check()?; let (mut file, initial_metadata) = open_project_snapshot_regular_file(&absolute, label)?; + cancellation.check()?; 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}"))?; + let mut chunk = [0_u8; PROJECT_RESOURCE_PREVIEW_READ_CHUNK_BYTES]; + loop { + cancellation.check()?; + let remaining = (max_bytes + 1).saturating_sub(bytes.len() as u64); + if remaining == 0 { + break; + } + let read_len = usize::try_from(remaining) + .unwrap_or(usize::MAX) + .min(chunk.len()); + let count = file + .read(&mut chunk[..read_len]) + .map_err(|error| format!("读取{label}失败:{normalized}: {error}"))?; + if count == 0 { + break; + } + bytes.extend_from_slice(&chunk[..count]); + } + cancellation.check()?; if bytes.len() as u64 > max_bytes { return Err(format!("{label}不能超过 {} MiB", max_bytes / 1024 / 1024)); } @@ -150,10 +215,12 @@ fn read_stable_project_resource( { return Err(format!("{label}读取期间发生漂移:{normalized}")); } + cancellation.check()?; 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}")); } + cancellation.check()?; Ok(bytes) } @@ -438,4 +505,17 @@ mod tests { 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()); } + + #[test] + fn cancelled_media_preview_does_not_enter_base64_encoding() { + let cancellation = ProjectResourcePreviewScopeCancellation::uncancelled(); + cancellation.cancel(); + assert_eq!( + encode_project_resource_preview_data_url("audio/mpeg", b"ID3", &cancellation), + Err( + crate::resource_preview_scheduler::PROJECT_RESOURCE_PREVIEW_CANCELLED_ERROR + .to_string() + ) + ); + } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/resource_preview_scheduler.rs b/apps/ai-game-creator-shell/src-tauri/src/resource_preview_scheduler.rs new file mode 100644 index 000000000..76668d039 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/resource_preview_scheduler.rs @@ -0,0 +1,676 @@ +use std::collections::{HashMap, HashSet, VecDeque}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; + +use tokio::sync::{watch, Semaphore}; + +pub(crate) const PROJECT_RESOURCE_PREVIEW_CANCELLED_ERROR: &str = + "project-resource-preview-scope-cancelled"; + +const PROJECT_RESOURCE_PREVIEW_GLOBAL_CONCURRENCY: usize = 3; +const PROJECT_RESOURCE_PREVIEW_MAX_REGISTERED_REQUESTS: usize = 4_096; +const PROJECT_RESOURCE_PREVIEW_SEEN_REQUEST_LIMIT: usize = 8_192; +const PROJECT_RESOURCE_PREVIEW_CANCELLED_SCOPE_LIMIT: usize = 1_024; +const PROJECT_RESOURCE_PREVIEW_OPAQUE_ID_MAX_BYTES: usize = 160; + +#[derive(Debug)] +struct ProjectResourcePreviewScopeCancellationInner { + cancelled: AtomicBool, + signal: watch::Sender, +} + +#[derive(Clone, Debug)] +pub(crate) struct ProjectResourcePreviewScopeCancellation { + inner: Arc, +} + +impl ProjectResourcePreviewScopeCancellation { + fn new() -> Self { + let (signal, _receiver) = watch::channel(false); + Self { + inner: Arc::new(ProjectResourcePreviewScopeCancellationInner { + cancelled: AtomicBool::new(false), + signal, + }), + } + } + + pub(crate) fn uncancelled() -> Self { + Self::new() + } + + pub(crate) fn is_cancelled(&self) -> bool { + self.inner.cancelled.load(Ordering::Acquire) + } + + pub(crate) fn check(&self) -> Result<(), String> { + if self.is_cancelled() { + Err(PROJECT_RESOURCE_PREVIEW_CANCELLED_ERROR.to_string()) + } else { + Ok(()) + } + } + + pub(crate) fn cancel(&self) { + if !self.inner.cancelled.swap(true, Ordering::AcqRel) { + self.inner.signal.send_replace(true); + } + } + + async fn cancelled(&self) { + let mut receiver = self.inner.signal.subscribe(); + loop { + if self.is_cancelled() || *receiver.borrow() { + return; + } + if receiver.changed().await.is_err() { + return; + } + } + } +} + +#[derive(Debug)] +struct ProjectResourcePreviewScopeEntry { + cancellation: ProjectResourcePreviewScopeCancellation, + request_count: usize, +} + +#[derive(Debug, Default)] +struct ProjectResourcePreviewRegistry { + scopes: HashMap, + active_requests: HashMap, + seen_request_ids: HashSet, + seen_request_order: VecDeque, + cancelled_scope_ids: HashSet, + cancelled_scope_order: VecDeque, +} + +#[derive(Debug)] +struct ProjectResourcePreviewReadManagerInner { + permits: Arc, + registry: Mutex, +} + +#[derive(Clone, Debug)] +pub(crate) struct ProjectResourcePreviewReadManager { + inner: Arc, +} + +impl Default for ProjectResourcePreviewReadManager { + fn default() -> Self { + Self::new(PROJECT_RESOURCE_PREVIEW_GLOBAL_CONCURRENCY) + } +} + +impl ProjectResourcePreviewReadManager { + fn new(concurrency: usize) -> Self { + Self { + inner: Arc::new(ProjectResourcePreviewReadManagerInner { + permits: Arc::new(Semaphore::new(concurrency)), + registry: Mutex::new(ProjectResourcePreviewRegistry::default()), + }), + } + } + + pub(crate) async fn run( + &self, + scope_id: &str, + request_id: &str, + operation: F, + ) -> Result + where + T: Send + 'static, + F: FnOnce(&ProjectResourcePreviewScopeCancellation) -> Result + Send + 'static, + { + let registration = self.register(scope_id, request_id)?; + let cancellation = registration.cancellation.clone(); + let permits = Arc::clone(&self.inner.permits); + let permit = tokio::select! { + biased; + _ = cancellation.cancelled() => { + return Err(PROJECT_RESOURCE_PREVIEW_CANCELLED_ERROR.to_string()); + } + permit = permits.acquire_owned() => { + permit.map_err(|_| "资源预览读取管理器已关闭".to_string())? + } + }; + cancellation.check()?; + let blocking_cancellation = cancellation.clone(); + let result = tokio::task::spawn_blocking(move || { + let _permit_guard = permit; + let _registration_guard = registration; + blocking_cancellation.check()?; + let result = operation(&blocking_cancellation)?; + blocking_cancellation.check()?; + Ok(result) + }) + .await + .map_err(|_| "资源预览读取任务异常结束".to_string())?; + result + } + + pub(crate) fn cancel_scope(&self, scope_id: &str) -> Result<(), String> { + let scope_id = validate_opaque_id("scopeId", scope_id)?; + let cancellation = { + let mut registry = self + .inner + .registry + .lock() + .map_err(|_| "资源预览读取登记表不可用".to_string())?; + remember_cancelled_scope(&mut registry, scope_id); + registry + .scopes + .get(scope_id) + .map(|entry| entry.cancellation.clone()) + }; + if let Some(cancellation) = cancellation { + cancellation.cancel(); + } + Ok(()) + } + + fn register( + &self, + scope_id: &str, + request_id: &str, + ) -> Result { + let scope_id = validate_opaque_id("scopeId", scope_id)?; + let request_id = validate_opaque_id("requestId", request_id)?; + let mut registry = self + .inner + .registry + .lock() + .map_err(|_| "资源预览读取登记表不可用".to_string())?; + if registry.cancelled_scope_ids.contains(scope_id) { + return Err(PROJECT_RESOURCE_PREVIEW_CANCELLED_ERROR.to_string()); + } + if registry.seen_request_ids.contains(request_id) { + return Err("资源预览 requestId 已经使用".to_string()); + } + if registry.active_requests.len() >= PROJECT_RESOURCE_PREVIEW_MAX_REGISTERED_REQUESTS { + return Err("资源预览等待队列已满".to_string()); + } + remember_request_id(&mut registry, request_id); + let cancellation = { + let scope = registry + .scopes + .entry(scope_id.to_string()) + .or_insert_with(|| ProjectResourcePreviewScopeEntry { + cancellation: ProjectResourcePreviewScopeCancellation::new(), + request_count: 0, + }); + scope.request_count += 1; + scope.cancellation.clone() + }; + registry + .active_requests + .insert(request_id.to_string(), scope_id.to_string()); + Ok(ProjectResourcePreviewRequestRegistration { + manager: self.clone(), + request_id: request_id.to_string(), + cancellation, + }) + } + + fn finish_request(&self, request_id: &str) { + let Ok(mut registry) = self.inner.registry.lock() else { + return; + }; + let Some(scope_id) = registry.active_requests.remove(request_id) else { + return; + }; + let remove_scope = if let Some(scope) = registry.scopes.get_mut(&scope_id) { + scope.request_count = scope.request_count.saturating_sub(1); + scope.request_count == 0 + } else { + false + }; + if remove_scope { + registry.scopes.remove(&scope_id); + trim_cancelled_scopes(&mut registry); + } + } + + #[cfg(test)] + fn active_registry_counts(&self) -> (usize, usize) { + let registry = self.inner.registry.lock().expect("preview registry"); + (registry.active_requests.len(), registry.scopes.len()) + } +} + +#[derive(Debug)] +struct ProjectResourcePreviewRequestRegistration { + manager: ProjectResourcePreviewReadManager, + request_id: String, + cancellation: ProjectResourcePreviewScopeCancellation, +} + +impl Drop for ProjectResourcePreviewRequestRegistration { + fn drop(&mut self) { + self.manager.finish_request(&self.request_id); + } +} + +fn validate_opaque_id<'a>(label: &str, value: &'a str) -> Result<&'a str, String> { + let value = value.trim(); + if value.is_empty() + || value.len() > PROJECT_RESOURCE_PREVIEW_OPAQUE_ID_MAX_BYTES + || !value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':')) + { + return Err(format!( + "资源预览 {label} 必须是长度不超过 {PROJECT_RESOURCE_PREVIEW_OPAQUE_ID_MAX_BYTES} 的不透明标识" + )); + } + Ok(value) +} + +fn remember_request_id(registry: &mut ProjectResourcePreviewRegistry, request_id: &str) { + registry.seen_request_ids.insert(request_id.to_string()); + registry + .seen_request_order + .push_back(request_id.to_string()); + let scan_limit = registry.seen_request_order.len(); + for _ in 0..scan_limit { + if registry.seen_request_order.len() <= PROJECT_RESOURCE_PREVIEW_SEEN_REQUEST_LIMIT { + break; + } + let Some(oldest) = registry.seen_request_order.pop_front() else { + break; + }; + if registry.active_requests.contains_key(&oldest) { + registry.seen_request_order.push_back(oldest); + continue; + } + registry.seen_request_ids.remove(&oldest); + } +} + +fn remember_cancelled_scope(registry: &mut ProjectResourcePreviewRegistry, scope_id: &str) { + if registry.cancelled_scope_ids.insert(scope_id.to_string()) { + registry + .cancelled_scope_order + .push_back(scope_id.to_string()); + } + trim_cancelled_scopes(registry); +} + +fn trim_cancelled_scopes(registry: &mut ProjectResourcePreviewRegistry) { + let scan_limit = registry.cancelled_scope_order.len(); + for _ in 0..scan_limit { + if registry.cancelled_scope_order.len() <= PROJECT_RESOURCE_PREVIEW_CANCELLED_SCOPE_LIMIT { + break; + } + let Some(oldest) = registry.cancelled_scope_order.pop_front() else { + break; + }; + if registry.scopes.contains_key(&oldest) { + registry.cancelled_scope_order.push_back(oldest); + continue; + } + registry.cancelled_scope_ids.remove(&oldest); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::time::Duration; + + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn global_permits_bound_reads_and_scope_cancel_releases_new_work() { + let manager = ProjectResourcePreviewReadManager::new(3); + let active = Arc::new(AtomicUsize::new(0)); + let peak = Arc::new(AtomicUsize::new(0)); + let entered = Arc::new(AtomicUsize::new(0)); + let mut old_tasks = Vec::new(); + for index in 0..3 { + let manager = manager.clone(); + let active = Arc::clone(&active); + let peak = Arc::clone(&peak); + let entered = Arc::clone(&entered); + old_tasks.push(tokio::spawn(async move { + manager + .run("scope-a", &format!("request-a-{index}"), move |cancel| { + let current = active.fetch_add(1, Ordering::SeqCst) + 1; + peak.fetch_max(current, Ordering::SeqCst); + entered.fetch_add(1, Ordering::SeqCst); + while cancel.check().is_ok() { + std::thread::yield_now(); + } + active.fetch_sub(1, Ordering::SeqCst); + cancel.check()?; + Ok(()) + }) + .await + })); + } + tokio::time::timeout(Duration::from_secs(2), async { + while entered.load(Ordering::SeqCst) != 3 { + tokio::task::yield_now().await; + } + }) + .await + .expect("old scope occupies all permits"); + + let new_started = Arc::new(AtomicBool::new(false)); + let manager_for_new = manager.clone(); + let new_started_for_task = Arc::clone(&new_started); + let new_task = tokio::spawn(async move { + manager_for_new + .run("scope-b", "request-b-0", move |_| { + new_started_for_task.store(true, Ordering::SeqCst); + Ok(()) + }) + .await + }); + tokio::task::yield_now().await; + assert!(!new_started.load(Ordering::SeqCst)); + + manager.cancel_scope("scope-a").expect("cancel old scope"); + assert_eq!(new_task.await.expect("new task join"), Ok(())); + for task in old_tasks { + assert_eq!( + task.await.expect("old task join"), + Err(PROJECT_RESOURCE_PREVIEW_CANCELLED_ERROR.to_string()) + ); + } + assert_eq!(peak.load(Ordering::SeqCst), 3); + assert_eq!(active.load(Ordering::SeqCst), 0); + assert_eq!(manager.active_registry_counts(), (0, 0)); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn queued_cancel_does_not_enter_operation_and_unknown_cancel_closes_late_request() { + let manager = ProjectResourcePreviewReadManager::new(1); + let blocker_entered = Arc::new(AtomicBool::new(false)); + let release_blocker = Arc::new(AtomicBool::new(false)); + let manager_for_blocker = manager.clone(); + let blocker_entered_for_task = Arc::clone(&blocker_entered); + let release_blocker_for_task = Arc::clone(&release_blocker); + let blocker = tokio::spawn(async move { + manager_for_blocker + .run("scope-blocker", "request-blocker", move |_| { + blocker_entered_for_task.store(true, Ordering::SeqCst); + while !release_blocker_for_task.load(Ordering::SeqCst) { + std::thread::yield_now(); + } + Ok(()) + }) + .await + }); + tokio::time::timeout(Duration::from_secs(2), async { + while !blocker_entered.load(Ordering::SeqCst) { + tokio::task::yield_now().await; + } + }) + .await + .expect("blocker starts"); + + let queued_entered = Arc::new(AtomicBool::new(false)); + let manager_for_queued = manager.clone(); + let queued_entered_for_task = Arc::clone(&queued_entered); + let queued = tokio::spawn(async move { + manager_for_queued + .run("scope-queued", "request-queued", move |_| { + queued_entered_for_task.store(true, Ordering::SeqCst); + Ok(()) + }) + .await + }); + tokio::task::yield_now().await; + manager + .cancel_scope("scope-queued") + .expect("cancel queued scope"); + assert_eq!( + queued.await.expect("queued task join"), + Err(PROJECT_RESOURCE_PREVIEW_CANCELLED_ERROR.to_string()) + ); + assert!(!queued_entered.load(Ordering::SeqCst)); + + manager + .cancel_scope("scope-before-register") + .expect("cancel unknown scope"); + let late_result = manager + .run("scope-before-register", "request-late", |_| Ok(())) + .await; + assert_eq!( + late_result, + Err(PROJECT_RESOURCE_PREVIEW_CANCELLED_ERROR.to_string()) + ); + + release_blocker.store(true, Ordering::SeqCst); + assert_eq!(blocker.await.expect("blocker join"), Ok(())); + assert_eq!(manager.active_registry_counts(), (0, 0)); + } + + #[tokio::test] + async fn duplicate_request_id_is_rejected_after_completion() { + let manager = ProjectResourcePreviewReadManager::new(1); + assert_eq!( + manager.run("scope-a", "same-request", |_| Ok(())).await, + Ok(()) + ); + let duplicate = manager + .run("scope-b", "same-request", |_| Ok(())) + .await + .expect_err("duplicate request rejected"); + assert!(duplicate.contains("requestId 已经使用")); + } + + #[tokio::test] + async fn operation_error_and_panic_release_registration_and_permit() { + let manager = ProjectResourcePreviewReadManager::new(1); + assert_eq!( + manager + .run("scope-error", "request-error", |_| { + Err::<(), _>("预览读取失败".to_string()) + }) + .await, + Err("预览读取失败".to_string()) + ); + assert_eq!(manager.active_registry_counts(), (0, 0)); + + let panic_error = manager + .run("scope-panic", "request-panic", |_| -> Result<(), String> { + panic!("preview worker panic") + }) + .await + .expect_err("panic is converted into a stable command error"); + assert!(panic_error.contains("资源预览读取任务异常结束")); + assert_eq!(manager.active_registry_counts(), (0, 0)); + + assert_eq!( + manager + .run("scope-after-panic", "request-after-panic", |_| Ok(())) + .await, + Ok(()) + ); + assert_eq!(manager.active_registry_counts(), (0, 0)); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn aborting_the_outer_future_keeps_the_blocking_read_registered_and_permitted() { + let manager = ProjectResourcePreviewReadManager::new(1); + let entered = Arc::new(AtomicBool::new(false)); + let cancellation_observed = Arc::new(AtomicBool::new(false)); + let (release_sender, release_receiver) = std::sync::mpsc::sync_channel::<()>(0); + let manager_for_old = manager.clone(); + let entered_for_old = Arc::clone(&entered); + let cancellation_observed_for_old = Arc::clone(&cancellation_observed); + let old_task = tokio::spawn(async move { + manager_for_old + .run("scope-aborted", "request-aborted", move |cancel| { + entered_for_old.store(true, Ordering::SeqCst); + loop { + if let Err(error) = cancel.check() { + cancellation_observed_for_old.store(true, Ordering::SeqCst); + return Err(error); + } + match release_receiver.recv_timeout(Duration::from_millis(5)) { + Ok(()) | Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { + return Ok(()) + } + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {} + } + } + }) + .await + }); + tokio::time::timeout(Duration::from_secs(2), async { + while !entered.load(Ordering::SeqCst) { + tokio::task::yield_now().await; + } + }) + .await + .expect("blocking read starts"); + + old_task.abort(); + assert!(old_task + .await + .expect_err("outer future is aborted") + .is_cancelled()); + assert_eq!(manager.active_registry_counts(), (1, 1)); + + let new_started = Arc::new(AtomicBool::new(false)); + let manager_for_new = manager.clone(); + let new_started_for_task = Arc::clone(&new_started); + let new_task = tokio::spawn(async move { + manager_for_new + .run("scope-new", "request-new", move |_| { + new_started_for_task.store(true, Ordering::SeqCst); + Ok(()) + }) + .await + }); + tokio::task::yield_now().await; + assert!(!new_started.load(Ordering::SeqCst)); + + manager + .cancel_scope("scope-aborted") + .expect("cancel blocking read after outer abort"); + if tokio::time::timeout(Duration::from_secs(2), async { + while !cancellation_observed.load(Ordering::SeqCst) { + tokio::task::yield_now().await; + } + }) + .await + .is_err() + { + drop(release_sender); + panic!("blocking read did not observe scope cancellation after outer abort"); + } + drop(release_sender); + assert_eq!( + tokio::time::timeout(Duration::from_secs(2), new_task) + .await + .expect("new task starts after cancelled blocking read exits") + .expect("new task join"), + Ok(()) + ); + assert!(new_started.load(Ordering::SeqCst)); + assert_eq!(manager.active_registry_counts(), (0, 0)); + } + + #[test] + fn seen_request_tombstones_evict_the_oldest_entry_at_the_limit() { + let mut registry = ProjectResourcePreviewRegistry::default(); + for index in 0..=PROJECT_RESOURCE_PREVIEW_SEEN_REQUEST_LIMIT { + remember_request_id(&mut registry, &format!("request-{index}")); + } + + assert_eq!( + registry.seen_request_ids.len(), + PROJECT_RESOURCE_PREVIEW_SEEN_REQUEST_LIMIT + ); + assert_eq!( + registry.seen_request_order.len(), + PROJECT_RESOURCE_PREVIEW_SEEN_REQUEST_LIMIT + ); + assert!(!registry.seen_request_ids.contains("request-0")); + assert!(registry.seen_request_ids.contains("request-1")); + assert!(registry.seen_request_ids.contains(&format!( + "request-{}", + PROJECT_RESOURCE_PREVIEW_SEEN_REQUEST_LIMIT + ))); + } + + #[test] + fn cancelled_scope_tombstones_evict_the_oldest_entry_at_the_limit() { + let mut registry = ProjectResourcePreviewRegistry::default(); + for index in 0..=PROJECT_RESOURCE_PREVIEW_CANCELLED_SCOPE_LIMIT { + remember_cancelled_scope(&mut registry, &format!("scope-{index}")); + } + + assert_eq!( + registry.cancelled_scope_ids.len(), + PROJECT_RESOURCE_PREVIEW_CANCELLED_SCOPE_LIMIT + ); + assert_eq!( + registry.cancelled_scope_order.len(), + PROJECT_RESOURCE_PREVIEW_CANCELLED_SCOPE_LIMIT + ); + assert!(!registry.cancelled_scope_ids.contains("scope-0")); + assert!(registry.cancelled_scope_ids.contains("scope-1")); + assert!(registry.cancelled_scope_ids.contains(&format!( + "scope-{}", + PROJECT_RESOURCE_PREVIEW_CANCELLED_SCOPE_LIMIT + ))); + } + + #[test] + fn active_cancelled_scope_tombstones_converge_after_the_scope_finishes() { + let manager = ProjectResourcePreviewReadManager::new(1); + let mut registrations = Vec::new(); + for index in 0..=PROJECT_RESOURCE_PREVIEW_CANCELLED_SCOPE_LIMIT { + let scope_id = format!("active-scope-{index}"); + let request_id = format!("active-request-{index}"); + registrations.push( + manager + .register(&scope_id, &request_id) + .expect("register active scope"), + ); + manager + .cancel_scope(&scope_id) + .expect("cancel active scope"); + } + + { + let registry = manager.inner.registry.lock().expect("preview registry"); + assert_eq!( + registry.cancelled_scope_ids.len(), + PROJECT_RESOURCE_PREVIEW_CANCELLED_SCOPE_LIMIT + 1 + ); + assert_eq!( + registry.cancelled_scope_order.len(), + PROJECT_RESOURCE_PREVIEW_CANCELLED_SCOPE_LIMIT + 1 + ); + } + assert_eq!( + manager + .register("active-scope-0", "late-request") + .expect_err("active cancelled scope stays closed"), + PROJECT_RESOURCE_PREVIEW_CANCELLED_ERROR + ); + + let completed_scope = format!( + "active-scope-{}", + PROJECT_RESOURCE_PREVIEW_CANCELLED_SCOPE_LIMIT + ); + drop(registrations.pop()); + let registry = manager.inner.registry.lock().expect("preview registry"); + assert_eq!( + registry.cancelled_scope_ids.len(), + PROJECT_RESOURCE_PREVIEW_CANCELLED_SCOPE_LIMIT + ); + assert_eq!( + registry.cancelled_scope_order.len(), + PROJECT_RESOURCE_PREVIEW_CANCELLED_SCOPE_LIMIT + ); + assert!(!registry.cancelled_scope_ids.contains(&completed_scope)); + assert!(registry.cancelled_scope_ids.contains("active-scope-0")); + } +} 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 141878dab..0855d3403 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 @@ -6004,6 +6004,8 @@ fn project_checkpoint_excludes_and_preserves_sensitive_local_files() { #[test] fn local_project_image_preview_obeys_auto_file_read_policy() { let root = unique_project_path(); + let project_path = root.to_string_lossy().into_owned(); + let cancellation = ProjectResourcePreviewScopeCancellation::uncancelled(); init_local_game_project_at(&root, "image-preview-policy", "图片预览策略项目") .expect("project init"); fs::create_dir_all(root.join("assets")).expect("asset dir"); @@ -6041,11 +6043,9 @@ fn local_project_image_preview_obeys_auto_file_read_policy() { }, ) .expect("confirm policy"); - let confirm_error = read_local_project_image_preview( - root.to_string_lossy().into_owned(), - "assets/preview.png".to_string(), - ) - .expect_err("confirm policy blocks automatic preview"); + let confirm_error = + read_local_project_image_preview_at(&project_path, "assets/preview.png", &cancellation) + .expect_err("confirm policy blocks automatic preview"); assert!(confirm_error.contains("要求用户确认:file.read")); write_project_permission_policy_at( @@ -6057,11 +6057,9 @@ fn local_project_image_preview_obeys_auto_file_read_policy() { }, ) .expect("deny policy"); - let deny_error = read_local_project_image_preview( - root.to_string_lossy().into_owned(), - "assets/preview.png".to_string(), - ) - .expect_err("deny policy blocks preview"); + let deny_error = + read_local_project_image_preview_at(&project_path, "assets/preview.png", &cancellation) + .expect_err("deny policy blocks preview"); assert!(deny_error.contains("拒绝执行:file.read")); write_project_permission_policy_at( @@ -6073,17 +6071,16 @@ fn local_project_image_preview_obeys_auto_file_read_policy() { }, ) .expect("auto policy"); - let preview = read_local_project_image_preview( - root.to_string_lossy().into_owned(), - "assets/preview.png".to_string(), - ) - .expect("auto preview"); + let preview = + read_local_project_image_preview_at(&project_path, "assets/preview.png", &cancellation) + .expect("auto preview"); assert_eq!(preview.media_type, "image/png"); fs::write(root.join("assets/unregistered.png"), &preview_bytes).expect("unregistered image"); - let unregistered_error = read_local_project_image_preview( - root.to_string_lossy().into_owned(), - "assets/unregistered.png".to_string(), + let unregistered_error = read_local_project_image_preview_at( + &project_path, + "assets/unregistered.png", + &cancellation, ) .expect_err("unregistered image rejected"); assert!(unregistered_error.contains("只能预览已登记资源")); @@ -6094,6 +6091,8 @@ fn local_project_image_preview_obeys_auto_file_read_policy() { #[test] fn local_project_resource_previews_require_registered_safe_resources() { let root = unique_project_path(); + let project_path = root.to_string_lossy().into_owned(); + let cancellation = ProjectResourcePreviewScopeCancellation::uncancelled(); init_local_game_project_at(&root, "resource-preview-policy", "资源预览策略项目") .expect("project init"); fs::create_dir_all(root.join("assets")).expect("asset dir"); @@ -6151,44 +6150,37 @@ fn local_project_resource_previews_require_registered_safe_resources() { ) .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"); + let document = + read_local_project_text_preview_at(&project_path, "game/design.md", &cancellation) + .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"); + let svg = + read_local_project_media_preview_at(&project_path, "assets/icon.svg", "art", &cancellation) + .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(), + let audio = read_local_project_media_preview_at( + &project_path, + "assets/bgm.mp3", + "audio", + &cancellation, ) .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"); + let unregistered_error = + read_local_project_text_preview_at(&project_path, "game/unregistered.md", &cancellation) + .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(), + assert!( + read_local_project_text_preview_at(&project_path, "../outside.md", &cancellation,).is_err() + ); + assert!(read_local_project_media_preview_at( + &project_path, + "assets/bgm.mp3", + "art", + &cancellation, ) .is_err()); diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx index a78af79e3..37d0abd05 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx @@ -22,6 +22,11 @@ import type { PendingCommand, PendingUiConfirmation, } from '../../app/types'; +import { + cancelLocalProjectResourcePreviewScope, + createProjectResourcePreviewRequestId, + createProjectResourcePreviewScopeId, +} from '../../services/projectResourcePreviewTransport'; import { formatAgentRuntimeEvent, isAgentRuntimeTerminalState, @@ -1184,6 +1189,7 @@ export function SupervisorChatOnlyView({ ); return undefined; } + const scopeId = createProjectResourcePreviewScopeId(); let cancelled = false; setSelectedResultImage(null); setResultImagePreviews( @@ -1201,6 +1207,8 @@ export function SupervisorChatOnlyView({ { projectPath, relativePath: image.path, + scopeId, + requestId: createProjectResourcePreviewRequestId(), }, ); if ( @@ -1222,6 +1230,7 @@ export function SupervisorChatOnlyView({ }); return () => { cancelled = true; + cancelLocalProjectResourcePreviewScope(scopeId); }; // resultImageKey is the stable semantic identity for the derived image list. // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/apps/ai-game-creator-shell/src/services/projectResourcePreviewTransport.ts b/apps/ai-game-creator-shell/src/services/projectResourcePreviewTransport.ts new file mode 100644 index 000000000..e89e9d06e --- /dev/null +++ b/apps/ai-game-creator-shell/src/services/projectResourcePreviewTransport.ts @@ -0,0 +1,51 @@ +const PROJECT_RESOURCE_PREVIEW_CANCELLED_ERROR = + 'project-resource-preview-scope-cancelled'; + +let projectResourcePreviewOpaqueIdSequence = 0; + +function createProjectResourcePreviewOpaqueId(prefix: 'scope' | 'request') { + projectResourcePreviewOpaqueIdSequence += 1; + const cryptoApi = globalThis.crypto; + const randomId = cryptoApi?.randomUUID?.(); + if (randomId) { + return `${prefix}:${randomId}`; + } + if (cryptoApi?.getRandomValues) { + const randomWords = cryptoApi.getRandomValues(new Uint32Array(4)); + return `${prefix}:${Array.from(randomWords, (word) => word.toString(36)).join('-')}`; + } + return `${prefix}:${Date.now().toString(36)}:${Math.random().toString(36).slice(2)}:${projectResourcePreviewOpaqueIdSequence.toString(36)}`; +} + +export function createProjectResourcePreviewScopeId() { + return createProjectResourcePreviewOpaqueId('scope'); +} + +export function createProjectResourcePreviewRequestId() { + return createProjectResourcePreviewOpaqueId('request'); +} + +export function isProjectResourcePreviewCancellation(error: unknown) { + const message = + error instanceof Error + ? error.message + : typeof error === 'string' + ? error + : null; + return message === PROJECT_RESOURCE_PREVIEW_CANCELLED_ERROR; +} + +export function cancelLocalProjectResourcePreviewScope(scopeId: string) { + const invoke = window.__TAURI__?.core?.invoke; + if (!invoke) { + return; + } + try { + void Promise.resolve( + invoke('cancel_local_project_resource_preview_scope', { scopeId }), + ).catch(() => undefined); + } catch { + // The epoch/owner fence remains authoritative if the WebView is already + // tearing down and can no longer deliver the best-effort cancellation. + } +} diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index 1573fb5d1..ce26d5054 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -750,7 +750,9 @@ textarea { @media (prefers-reduced-motion: reduce) { .launcher-agent-chat-waiting > span, - .game-chat-runtime-status[data-tone='active'] .game-chat-runtime-state > span { + .game-chat-runtime-status[data-tone='active'] + .game-chat-runtime-state + > span { animation: none; } } @@ -1538,7 +1540,9 @@ textarea { box-shadow: 0 0 0 4px rgb(240 68 56 / 15%); } -.game-chat-runtime-status[data-tone='complete'] .game-chat-runtime-state > span { +.game-chat-runtime-status[data-tone='complete'] + .game-chat-runtime-state + > span { background: #2e90fa; box-shadow: 0 0 0 4px rgb(46 144 250 / 14%); } @@ -4111,11 +4115,15 @@ iframe.preview-frame { position: relative; display: grid; align-content: start; - width: max-content; - min-width: 100%; + width: 100%; + min-width: max(100%, 620px); min-height: 100%; } +.game-resource-section-stack { + display: contents; +} + .game-resource-dependency-overlay { position: absolute; inset: 0; @@ -4152,21 +4160,6 @@ iframe.preview-frame { 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; @@ -4177,18 +4170,18 @@ iframe.preview-frame { stroke-linejoin: round; } -.game-resource-dependency-marker--task path { - fill: #918b87; - stroke-linejoin: round; -} - .game-resource-section { position: relative; z-index: 1; display: grid; + grid-template-rows: auto minmax(0, 1fr); gap: 10px; + box-sizing: border-box; + height: var(--resource-section-height); min-width: 620px; + min-height: 0; padding: 14px 6px 18px; + overflow: hidden; border-bottom: 1px solid #eaded8; } @@ -4201,6 +4194,7 @@ iframe.preview-frame { align-items: center; justify-content: space-between; gap: 12px; + min-height: 22px; } .game-resource-section > header span { @@ -4212,7 +4206,20 @@ iframe.preview-frame { font-weight: 800; } -.game-resource-section > header small { +.game-resource-section-actions { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.game-resource-section-height-actions, +.game-resource-section-zoom-actions { + display: inline-flex; + align-items: center; + gap: 3px; +} + +.game-resource-section-actions small { display: grid; min-width: 22px; height: 22px; @@ -4224,46 +4231,103 @@ iframe.preview-frame { place-items: center; } -.game-resource-section > p { +.game-resource-section-actions button { + display: grid; + width: 24px; + height: 24px; + padding: 0; + border: 1px solid #e3cfc5; + border-radius: 7px; + background: rgb(255 255 255 / 88%); + color: #8c6252; + cursor: pointer; + place-items: center; +} + +.game-resource-section-actions button:hover:not(:disabled), +.game-resource-section-actions button:focus-visible { + border-color: #ce7650; + outline: 2px solid rgb(206 118 80 / 22%); + outline-offset: 1px; + color: #bd5f37; +} + +.game-resource-section-actions button:disabled { + border-color: #eadfd9; + background: #f7f1ed; + color: #c5afa5; + cursor: not-allowed; + opacity: 0.64; +} + +.game-resource-section-actions .game-resource-section-zoom-value { + width: auto; + min-width: 40px; + padding: 0 6px; + font-size: 10px; + font-variant-numeric: tabular-nums; +} + +.game-resource-section-viewport { + position: relative; + min-width: 0; + min-height: 0; + overflow: auto; + border-radius: 8px; + overscroll-behavior: contain; + scrollbar-gutter: stable; +} + +.game-resource-section-viewport:focus-visible { + outline: 2px solid rgb(206 118 80 / 26%); + outline-offset: -2px; +} + +.game-resource-section-viewport > p { margin: 0; color: #b0958a; font-size: 11px; } -.game-resource-plane { +.game-resource-plane-frame { position: relative; + min-width: 0; + min-height: 0; +} + +.game-resource-plane { + position: absolute; + top: 0; + left: 0; min-width: 620px; min-height: 108px; + transform: scale(var(--resource-section-zoom, 1)); + transform-origin: 0 0; } .game-resource-card { position: absolute; + z-index: 1; top: 0; left: 0; - display: grid; - grid-template-columns: 36px minmax(100px, 1fr); - grid-template-rows: auto auto auto; - align-items: center; - gap: 2px 8px; - width: 180px; - min-width: 180px; - min-height: 92px; - padding: 10px; + width: var(--resource-card-width); + min-width: var(--resource-card-width); + height: var(--resource-card-height); + min-height: var(--resource-card-height); + padding: 0; overflow: hidden; border: 1px solid #eadbd4; border-radius: 12px; background: #fff; color: #4e382f; - text-align: left; box-shadow: 0 6px 18px rgb(96 62 47 / 6%); - cursor: pointer; touch-action: manipulation; user-select: none; transform: translate3d(var(--resource-x, 0), var(--resource-y, 0), 0); } .game-resource-card:hover, -.game-resource-card:focus-visible, +.game-resource-card:focus-within, .game-resource-card.is-selected { border-color: #d57b51; outline: 0; @@ -4277,32 +4341,153 @@ iframe.preview-frame { 0 0 0 2px rgb(216 115 66 / 14%); } -.game-resource-card-icon { +.game-resource-card-visual { + position: absolute; + inset: 0; display: grid; - grid-row: 1 / 4; - width: 36px; - height: 36px; - border-radius: 10px; - background: #fae9df; + overflow: hidden; + background: linear-gradient(145deg, #fffaf6, #f6ece6); color: #c46a40; place-items: center; } -.game-resource-card strong, -.game-resource-card small { - min-width: 0; +.game-resource-card[data-preview-kind='raster-image'] + .game-resource-card-visual, +.game-resource-card[data-preview-kind='media-image'] .game-resource-card-visual, +.game-resource-card[data-preview-kind='video'] .game-resource-card-visual { + 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-card-visual > img, +.game-resource-card-visual > video { + display: block; + width: 100%; + height: 100%; + object-fit: contain; +} + +.game-resource-card-visual > video { + visibility: hidden; + background: #211d1b; +} + +.game-resource-card-visual > video.is-decoded { + visibility: visible; +} + +.game-resource-card-open { + position: absolute; + z-index: 1; + inset: 0; + width: 100%; + height: 100%; + padding: 0; + border: 0; + border-radius: inherit; + background: transparent; + cursor: pointer; + touch-action: manipulation; +} + +.game-resource-card-open:focus-visible { + outline: 3px solid rgb(213 123 81 / 55%); + outline-offset: -4px; +} + +.game-resource-card-media-control { + position: absolute; + right: 9px; + bottom: 9px; + z-index: 2; + display: grid; + width: 34px; + height: 34px; + padding: 0; + border: 1px solid rgb(255 255 255 / 70%); + border-radius: 50%; + background: rgb(75 48 38 / 82%); + color: #fff; + box-shadow: 0 5px 14px rgb(52 30 22 / 24%); + cursor: pointer; + place-items: center; +} + +.game-resource-card-media-control:hover, +.game-resource-card-media-control:focus-visible, +.game-resource-card-media-control[aria-pressed='true'] { + background: #c96f44; + outline: 2px solid rgb(255 255 255 / 92%); + outline-offset: 1px; +} + +.game-resource-card-placeholder, +.game-resource-card-version-visual, +.game-resource-card-audio-visual { + display: grid; + width: 100%; + height: 100%; + place-items: center; +} + +.game-resource-card-audio-visual { + position: relative; + background: linear-gradient(145deg, #fff8f1, #f2ded2); +} + +.game-resource-card-audio-visual > span { + width: 92px; + height: 18px; + margin-top: -24px; + background: repeating-linear-gradient( + 90deg, + rgb(196 106 64 / 32%) 0 3px, + transparent 3px 7px + ); + mask-image: linear-gradient( + to bottom, + transparent, + #000 25%, + #000 75%, + transparent + ); +} + +.game-resource-card-audio-visual audio { + display: none; +} + +.game-resource-card-document-summary { + display: -webkit-box; + max-height: 100%; + padding: 16px; overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + color: #674c41; + font-size: 11px; + line-height: 1.55; + text-align: left; + white-space: normal; + -webkit-box-orient: vertical; + -webkit-line-clamp: 5; } -.game-resource-card strong { - font-size: 12px; +.game-resource-card-version-visual { + align-content: center; + gap: 10px; } -.game-resource-card small { - color: #9a8176; +.game-resource-card-version-relations { + color: #8e6f62; font-size: 9px; + font-weight: 700; } .game-resource-focus { @@ -4372,21 +4557,11 @@ iframe.preview-frame { gap: 6px; min-height: 0; padding: 18px 20px 24px; - overflow: hidden; + overflow: auto; overscroll-behavior: contain; scrollbar-gutter: stable; } -.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--version .game-resource-focus-body { - overflow: auto; -} - .game-resource-focus-close { display: grid; width: 28px; @@ -4434,29 +4609,6 @@ iframe.preview-frame { color: #5d4339; } -.game-resource-image-preview { - position: relative; - display: grid; - height: min(460px, calc(100dvh - 330px)); - min-height: 260px; - margin-bottom: 8px; - overflow: hidden; - border: 1px solid #ead8cf; - border-radius: 12px; - 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; - place-items: center; -} - -.game-resource-media-preview, .game-resource-audio-preview { position: relative; display: grid; @@ -4470,33 +4622,6 @@ iframe.preview-frame { 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; @@ -4507,25 +4632,30 @@ iframe.preview-frame { width: min(620px, 100%); } -.game-resource-media-preview p, -.game-resource-audio-preview p { - margin: 0; - padding: 20px; - color: #92776c; +.game-resource-audio-load { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + min-height: 38px; + padding: 0 16px; + border: 1px solid #ddb7a5; + border-radius: 999px; + background: #fff; + color: #a95734; font-size: 12px; - text-align: center; + font-weight: 800; + cursor: pointer; } -.game-resource-image-preview img { - position: absolute; - inset: 0; - display: block; - width: 100%; - height: 100%; - object-fit: contain; +.game-resource-audio-load:hover, +.game-resource-audio-load:focus-visible { + border-color: #c96f44; + outline: 2px solid rgb(201 111 68 / 24%); + outline-offset: 2px; } -.game-resource-image-preview p { +.game-resource-audio-preview p { margin: 0; padding: 20px; color: #92776c; 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 index 38a51a13c..2c892669a 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/ResourceDependencyOverlay.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/ResourceDependencyOverlay.tsx @@ -1,6 +1,5 @@ import { forwardRef, - useCallback, useId, useImperativeHandle, useLayoutEffect, @@ -20,7 +19,6 @@ import { import { type ProjectResourceGraph, type ProjectResourceReferenceEdge, - type ProjectResourceTaskFlow, } from './resourceDependencyGraphModel'; type Point = { @@ -33,8 +31,6 @@ type Rect = Point & { height: number; }; -type SectionOrigins = Partial>; - type RectLookup = { get(resourceId: string): Rect | undefined; }; @@ -42,7 +38,9 @@ type RectLookup = { export type ResourceDependencyOverlayProps = { graph: ProjectResourceGraph; positions: readonly ProjectResourceCanvasPosition[]; + section: ProjectResourceCanvasSection; visibleResourceIds: ReadonlySet; + geometryRevision?: string; }; export type ResourceDependencyOverlayHandle = { @@ -50,79 +48,92 @@ export type ResourceDependencyOverlayHandle = { clearDragPreview: () => void; }; -type TaskFlowPathRefs = { - sourceBranches: Map; - targetBranches: Map; - trunk: SVGPathElement | null; +type ConnectionAxis = 'horizontal' | 'vertical'; + +type ReferenceRouteOffsets = { + source: number; + target: number; }; -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 SECTION_SCROLL_SELECTOR = '[data-resource-section-scroll]'; +const ROUTE_CORNER_RADIUS = 10; const SELF_REFERENCE_LOOP_WIDTH = 56; const SELF_REFERENCE_LOOP_ANCHOR_OFFSET = 18; -const RESOURCE_SECTIONS: readonly ProjectResourceCanvasSection[] = [ - 'document', - 'version', - 'art', - 'audio', -]; +const ARROW_CLEARANCE = 10; +const CONTINUATION_VIEWPORT_INSET = 12; +const HORIZONTAL_ROUTE_MIN_GAP = 8; +const REFERENCE_PORT_GAP = 14; +const REFERENCE_PORT_INSET = 18; -function pointsEqual(left: SectionOrigins, right: SectionOrigins) { - return RESOURCE_SECTIONS.every( - (section) => - left[section]?.x === right[section]?.x && - left[section]?.y === right[section]?.y, +function rectsEqual(left: Rect | null, right: Rect | null) { + return ( + left === right || + (left !== null && + right !== null && + left.x === right.x && + left.y === right.y && + left.width === right.width && + left.height === right.height) ); } -function connectionPath(source: Point, target: Point) { +function rectIntersectsViewport(rect: Rect, viewport: Rect) { + return ( + rect.x < viewport.x + viewport.width && + rect.x + rect.width > viewport.x && + rect.y < viewport.y + viewport.height && + rect.y + rect.height > viewport.y + ); +} + +function connectionPath(source: Point, target: Point, axis: ConnectionAxis) { 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}`; + return `M ${source.x} ${source.y} L ${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, + if (axis === 'vertical') { + const dx = target.x - source.x; + const dy = target.y - source.y; + if (Math.abs(dx) < 1 || Math.abs(dy) < ROUTE_CORNER_RADIUS * 2) { + return `M ${source.x} ${source.y} L ${target.x} ${target.y}`; + } + const horizontalDirection = dx >= 0 ? 1 : -1; + const verticalDirection = dy >= 0 ? 1 : -1; + const middleY = source.y + dy / 2; + const radius = Math.min( + ROUTE_CORNER_RADIUS, + Math.abs(dx) / 2, + Math.abs(dy) / 4, ); - return `M ${source.x} ${source.y} C ${source.x} ${ - source.y + direction * handle - }, ${target.x} ${target.y - direction * handle}, ${target.x} ${target.y}`; + return `M ${source.x} ${source.y} V ${ + middleY - verticalDirection * radius + } Q ${source.x} ${middleY} ${ + source.x + horizontalDirection * radius + } ${middleY} H ${target.x - horizontalDirection * radius} Q ${ + target.x + } ${middleY} ${target.x} ${ + middleY + verticalDirection * radius + } V ${target.y}`; } - const direction = target.x >= source.x ? 1 : -1; - const handle = Math.min( - TASK_FLOW_BRANCH_MAX_HANDLE, - horizontalDistance * 0.5, + const dx = target.x - source.x; + const dy = target.y - source.y; + if (Math.abs(dy) < 1 || Math.abs(dx) < ROUTE_CORNER_RADIUS * 2) { + return `M ${source.x} ${source.y} L ${target.x} ${target.y}`; + } + const horizontalDirection = dx >= 0 ? 1 : -1; + const verticalDirection = dy >= 0 ? 1 : -1; + const middleX = source.x + dx / 2; + const radius = Math.min( + ROUTE_CORNER_RADIUS, + Math.abs(dx) / 4, + Math.abs(dy) / 2, ); - return `M ${source.x} ${source.y} C ${source.x + direction * handle} ${ - source.y - }, ${target.x - direction * handle} ${target.y}, ${target.x} ${target.y}`; + return `M ${source.x} ${source.y} H ${ + middleX - horizontalDirection * radius + } Q ${middleX} ${source.y} ${middleX} ${ + source.y + verticalDirection * radius + } V ${target.y - verticalDirection * radius} Q ${middleX} ${ + target.y + } ${middleX + horizontalDirection * radius} ${target.y} H ${target.x}`; } function rectCenter(rect: Rect): Point { @@ -132,20 +143,219 @@ function rectCenter(rect: Rect): Point { }; } -function average(values: readonly number[]) { - return values.reduce((sum, value) => sum + value, 0) / values.length; +function rectAnchor( + rect: Rect, + axis: ConnectionAxis, + direction: 1 | -1, + clearance = 0, + portOffset = 0, +): Point { + if (axis === 'vertical') { + return { + x: rect.x + rect.width / 2 + portOffset, + y: + direction === 1 ? rect.y + rect.height + clearance : rect.y - clearance, + }; + } + return { + x: direction === 1 ? rect.x + rect.width + clearance : rect.x - clearance, + y: rect.y + rect.height / 2 + portOffset, + }; } -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 horizontalGap(left: Rect, right: Rect) { + return Math.max( + right.x - (left.x + left.width), + left.x - (right.x + right.width), + ); +} + +function connectionAxis(source: Rect, target: Rect): ConnectionAxis { + return horizontalGap(source, target) >= HORIZONTAL_ROUTE_MIN_GAP + ? 'horizontal' + : 'vertical'; +} + +function referenceStaysInSection( + edge: ProjectResourceReferenceEdge, + sectionByResourceId: ReadonlyMap, +) { + const sourceSection = sectionByResourceId.get(edge.sourceResourceId); + return ( + sourceSection !== undefined && + sourceSection === sectionByResourceId.get(edge.targetResourceId) + ); +} + +function referenceRouteOffsets( + edges: readonly ProjectResourceReferenceEdge[], + rectByResourceId: RectLookup, + sectionByResourceId: ReadonlyMap, +) { + type Endpoint = { + axis: ConnectionAxis; + edgeId: string; + oppositeCoordinate: number; + rect: Rect; + role: keyof ReferenceRouteOffsets; }; + const endpointsByPort = new Map(); + const addEndpoint = ( + resourceId: string, + side: 1 | -1, + endpoint: Endpoint, + ) => { + const key = `${resourceId}\n${endpoint.axis}\n${side}`; + const endpoints = endpointsByPort.get(key) ?? []; + endpoints.push(endpoint); + endpointsByPort.set(key, endpoints); + }; + + for (const edge of edges) { + if ( + edge.sourceResourceId === edge.targetResourceId || + !referenceStaysInSection(edge, sectionByResourceId) + ) { + continue; + } + const sourceRect = rectByResourceId.get(edge.sourceResourceId); + const targetRect = rectByResourceId.get(edge.targetResourceId); + if (!sourceRect || !targetRect) { + continue; + } + const axis = connectionAxis(sourceRect, targetRect); + const sourceCenter = rectCenter(sourceRect); + const targetCenter = rectCenter(targetRect); + const direction: 1 | -1 = + axis === 'horizontal' + ? targetCenter.x >= sourceCenter.x + ? 1 + : -1 + : targetCenter.y >= sourceCenter.y + ? 1 + : -1; + addEndpoint(edge.sourceResourceId, direction, { + axis, + edgeId: edge.id, + oppositeCoordinate: + axis === 'horizontal' ? targetCenter.y : targetCenter.x, + rect: sourceRect, + role: 'source', + }); + addEndpoint(edge.targetResourceId, direction === 1 ? -1 : 1, { + axis, + edgeId: edge.id, + oppositeCoordinate: + axis === 'horizontal' ? sourceCenter.y : sourceCenter.x, + rect: targetRect, + role: 'target', + }); + } + + const offsetsByEdgeId = new Map>(); + for (const endpoints of endpointsByPort.values()) { + endpoints.sort( + (left, right) => + left.oppositeCoordinate - right.oppositeCoordinate || + left.edgeId.localeCompare(right.edgeId), + ); + const extent = + endpoints[0]?.axis === 'horizontal' + ? (endpoints[0]?.rect.height ?? 0) + : (endpoints[0]?.rect.width ?? 0); + const maximumOffset = Math.max(0, extent / 2 - REFERENCE_PORT_INSET); + const step = + endpoints.length > 1 + ? Math.min( + REFERENCE_PORT_GAP, + (maximumOffset * 2) / (endpoints.length - 1), + ) + : 0; + endpoints.forEach((endpoint, index) => { + const current = offsetsByEdgeId.get(endpoint.edgeId) ?? {}; + current[endpoint.role] = (index - (endpoints.length - 1) / 2) * step; + offsetsByEdgeId.set(endpoint.edgeId, current); + }); + } + return new Map( + Array.from(offsetsByEdgeId, ([edgeId, offsets]) => [ + edgeId, + { source: offsets.source ?? 0, target: offsets.target ?? 0 }, + ]), + ); +} + +function clampPointToRect(point: Point, rect: Rect, inset = 0): Point { + return { + x: Math.min(rect.x + rect.width - inset, Math.max(rect.x + inset, point.x)), + y: Math.min( + rect.y + rect.height - inset, + Math.max(rect.y + inset, point.y), + ), + }; +} + +function viewportExitPoint(from: Point, toward: Point, viewport: Rect) { + const insetViewport = { + x: viewport.x + CONTINUATION_VIEWPORT_INSET, + y: viewport.y + CONTINUATION_VIEWPORT_INSET, + width: Math.max(1, viewport.width - CONTINUATION_VIEWPORT_INSET * 2), + height: Math.max(1, viewport.height - CONTINUATION_VIEWPORT_INSET * 2), + }; + const start = clampPointToRect(from, insetViewport); + const dx = toward.x - start.x; + const dy = toward.y - start.y; + if (dx === 0 && dy === 0) { + return null; + } + const candidates: number[] = []; + if (dx > 0) { + candidates.push((insetViewport.x + insetViewport.width - start.x) / dx); + } else if (dx < 0) { + candidates.push((insetViewport.x - start.x) / dx); + } + if (dy > 0) { + candidates.push((insetViewport.y + insetViewport.height - start.y) / dy); + } else if (dy < 0) { + candidates.push((insetViewport.y - start.y) / dy); + } + const distance = Math.min( + ...candidates.filter((candidate) => candidate >= 0), + ); + if (!Number.isFinite(distance)) { + return null; + } + return { + start, + end: { + x: start.x + dx * distance, + y: start.y + dy * distance, + }, + }; +} + +function outgoingContinuationPath(from: Point, toward: Point, viewport: Rect) { + const continuation = viewportExitPoint(from, toward, viewport); + return continuation + ? `M ${continuation.start.x} ${continuation.start.y} L ${ + continuation.end.x + } ${continuation.end.y}` + : null; +} + +function incomingContinuationPath(from: Point, target: Point, viewport: Rect) { + const continuation = viewportExitPoint(target, from, viewport); + return continuation + ? `M ${continuation.end.x} ${continuation.end.y} L ${ + continuation.start.x + } ${continuation.start.y}` + : null; } function referenceGeometry( edge: ProjectResourceReferenceEdge, rectByResourceId: RectLookup, + routeOffsets: ReferenceRouteOffsets = { source: 0, target: 0 }, ) { const sourceRect = rectByResourceId.get(edge.sourceResourceId); const targetRect = rectByResourceId.get(edge.targetResourceId); @@ -153,153 +363,151 @@ function referenceGeometry( return null; } if (edge.sourceResourceId === edge.targetResourceId) { - const anchorX = sourceRect.x + sourceRect.width; + const anchorX = sourceRect.x + sourceRect.width + ARROW_CLEARANCE; 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 { + axis: 'horizontal' as const, path: `M ${anchorX} ${sourceY} C ${loopX} ${sourceY}, ${loopX} ${targetY}, ${anchorX} ${targetY}`, + source: { x: anchorX, y: sourceY }, + target: { x: anchorX, y: 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); + const axis = connectionAxis(sourceRect, targetRect); + const direction: 1 | -1 = + axis === 'horizontal' + ? targetCenter.x >= sourceCenter.x + ? 1 + : -1 + : targetCenter.y >= sourceCenter.y + ? 1 + : -1; + const source = rectAnchor( + sourceRect, + axis, + direction, + 0, + routeOffsets.source, + ); + const target = rectAnchor( + targetRect, + axis, + direction === 1 ? -1 : 1, + ARROW_CLEARANCE, + routeOffsets.target, + ); return { - path: connectionPath(source, target), + axis, + path: connectionPath(source, target, axis), + source, + target, selfLoop: false, + routeOffsets, }; } -function taskFlowGeometry( - flow: ProjectResourceTaskFlow, +function visibleReferencePath( + edge: ProjectResourceReferenceEdge, + geometry: NonNullable>, rectByResourceId: RectLookup, + viewport: Rect | null, ) { - 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) { + const sourceRect = rectByResourceId.get(edge.sourceResourceId); + const targetRect = rectByResourceId.get(edge.targetResourceId); + if (!sourceRect || !targetRect) { 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, + if (!viewport) { + return { continuation: null, path: geometry.path } as const; + } + const sourceVisible = rectIntersectsViewport(sourceRect, viewport); + const targetVisible = rectIntersectsViewport(targetRect, viewport); + if (geometry.selfLoop) { + return sourceVisible + ? ({ continuation: null, path: geometry.path } as const) + : null; + } + if (!sourceVisible && !targetVisible) { + return null; + } + if (sourceVisible && targetVisible) { + return { continuation: null, path: geometry.path } as const; + } + if (sourceVisible) { + const path = outgoingContinuationPath( + geometry.source, + rectCenter(targetRect), + viewport, ); - return geometry ? [{ ...geometry, section }] : []; - }); -} - -function taskFlowRenderKey( - flowId: string, - section: ProjectResourceCanvasSection, -) { - return `${flowId}\n${section}`; + return path ? ({ continuation: 'outgoing', path } as const) : null; + } + const path = incomingContinuationPath( + rectCenter(sourceRect), + geometry.target, + viewport, + ); + return path ? ({ continuation: 'incoming', path } as const) : null; } export const ResourceDependencyOverlay = forwardRef< ResourceDependencyOverlayHandle, ResourceDependencyOverlayProps >(function ResourceDependencyOverlay( - { graph, positions, visibleResourceIds }, + { geometryRevision = '', graph, positions, section, 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 [logicalViewport, setLogicalViewport] = useState(null); + const [dragPreview, setDragPreview] = useState< + (Point & { resourceId: string }) | null + >(null); + + useImperativeHandle( + ref, + () => ({ + updateDragPreview(preview) { + setDragPreview(preview); + }, + clearDragPreview() { + setDragPreview(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) { + const plane = overlayRef.current?.parentElement; + const viewport = plane?.closest(SECTION_SCROLL_SELECTOR); + if (!plane || !viewport) { + setLogicalViewport(null); 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 parsedScale = Number(plane.dataset.resourceSectionScale); + const scale = + Number.isFinite(parsedScale) && parsedScale > 0 ? parsedScale : 1; + const viewportRect = viewport.getBoundingClientRect(); + const width = viewport.clientWidth || viewportRect.width; + const height = viewport.clientHeight || viewportRect.height; + const next = + width > 0 && height > 0 + ? { + x: viewport.scrollLeft / scale, + y: viewport.scrollTop / scale, + width: width / scale, + height: height / scale, + } + : null; + setLogicalViewport((current) => + rectsEqual(current, next) ? current : next, ); }; const scheduleMeasure = () => { @@ -313,42 +521,45 @@ export const ResourceDependencyOverlay = forwardRef< const observer = ResizeObserverClass ? new ResizeObserverClass(scheduleMeasure) : null; - observer?.observe(canvas); - canvas - .querySelectorAll(SECTION_SELECTOR) - .forEach((plane) => observer?.observe(plane)); + observer?.observe(plane); + observer?.observe(viewport); + viewport.addEventListener('scroll', scheduleMeasure, { passive: true }); window.addEventListener('resize', scheduleMeasure); return () => { if (frameId !== null) { window.cancelAnimationFrame(frameId); } observer?.disconnect(); + viewport.removeEventListener('scroll', scheduleMeasure); window.removeEventListener('resize', scheduleMeasure); }; - }, []); + }, [geometryRevision, section]); const rectByResourceId = useMemo(() => { const result = new Map(); for (const position of positions) { if ( + position.section !== section || !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, + x: + dragPreview?.resourceId === position.resourceId + ? dragPreview.x + : position.x, + y: + dragPreview?.resourceId === position.resourceId + ? dragPreview.y + : position.y, width: RESOURCE_CANVAS_CARD_WIDTH, height: RESOURCE_CANVAS_CARD_HEIGHT, }); } return result; - }, [graph.resourceIds, positions, sectionOrigins, visibleResourceIds]); + }, [dragPreview, graph.resourceIds, positions, section, visibleResourceIds]); const sectionByResourceId = useMemo( () => new Map( @@ -356,140 +567,55 @@ export const ResourceDependencyOverlay = forwardRef< ), [positions], ); - graphRef.current = graph; - positionByResourceIdRef.current = new Map( - positions.map((position) => [position.resourceId, position]), - ); - rectByResourceIdRef.current = rectByResourceId; - - const taskFlowRenderEntries = useMemo( + const sameSectionReferenceEdges = useMemo( () => - graph.taskFlows.flatMap((flow) => - taskFlowSectionGeometries( - flow, - rectByResourceId, - sectionByResourceId, - ).map((geometry) => ({ - flow, - geometry, - renderKey: taskFlowRenderKey(flow.id, geometry.section), - })), + graph.referenceEdges.filter( + (edge) => + sectionByResourceId.get(edge.sourceResourceId) === section && + referenceStaysInSection(edge, sectionByResourceId), ), - [graph.taskFlows, rectByResourceId, sectionByResourceId], + [graph.referenceEdges, section, sectionByResourceId], ); - - const renderTaskFlows = useMemo( + const routeOffsetsByReferenceEdgeId = 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], + referenceRouteOffsets( + sameSectionReferenceEdges, + rectByResourceId, + sectionByResourceId, + ), + [rectByResourceId, sameSectionReferenceEdges, sectionByResourceId], ); const renderReferenceEdges = useMemo( () => - graph.referenceEdges.map((edge) => { - const geometry = referenceGeometry(edge, rectByResourceId); + sameSectionReferenceEdges.map((edge) => { + const routeOffsets = routeOffsetsByReferenceEdgeId.get(edge.id); + const geometry = referenceGeometry( + edge, + rectByResourceId, + routeOffsets, + ); if (!geometry) { return null; } + const visibleGeometry = visibleReferencePath( + edge, + geometry, + rectByResourceId, + logicalViewport, + ); + if (!visibleGeometry) { + return null; + } const className = `game-resource-dependency-edge game-resource-dependency-edge--reference${ edge.cyclic ? ' is-cyclic' : '' + }${ + visibleGeometry.continuation + ? ' game-resource-dependency-continuation' + : '' }`; 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" @@ -498,145 +624,40 @@ export const ResourceDependencyOverlay = forwardRef< data-target-resource-id={edge.targetResourceId} data-cyclic={edge.cyclic || undefined} data-self-loop={geometry.selfLoop || undefined} - d={geometry.path} + data-route-axis={geometry.axis} + data-source-port-offset={routeOffsets?.source ?? 0} + data-target-port-offset={routeOffsets?.target ?? 0} + data-edge-continuation={visibleGeometry.continuation ?? undefined} + data-offscreen-target={ + visibleGeometry.continuation === 'outgoing' || undefined + } + d={visibleGeometry.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], + [ + logicalViewport, + markerPrefix, + rectByResourceId, + routeOffsetsByReferenceEdgeId, + sameSectionReferenceEdges, + ], ); 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 c6c2e3791..a7a5ad2cf 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 @@ -7,14 +7,19 @@ import { Image, Info, ListFilter, + Minus, Music2, Pause, Play, + Plus, + RotateCcw, Search, Settings2, SlidersHorizontal, Sparkles, X, + ZoomIn, + ZoomOut, } from 'lucide-react'; import { type CSSProperties, @@ -41,7 +46,16 @@ import { LocalGamePreviewFrame, resolveEmbeddedPreviewUrl, } from '../../features/project-workspace/LocalGamePreviewFrame'; -import { resourceCanvasSectionExtent } from './resourceCanvasLayoutModel'; +import { + RESOURCE_CANVAS_CARD_HEIGHT, + RESOURCE_CANVAS_CARD_WIDTH, + resourceCanvasSectionExtent, +} from './resourceCanvasLayoutModel'; +import { + projectResourceCardPreviewKind, + type ProjectResourceCardPreviewState, + summarizeProjectResourceDocument, +} from './resourceCardPreviewModel'; import { EMPTY_PROJECT_RESOURCE_GRAPH, normalizeProjectResourceGraph, @@ -57,7 +71,13 @@ import { type ProjectResourceCategory, projectResourcesFromReadModels, } from './resourceProjectionModel'; +import { + clampProjectResourceSectionZoom, + projectResourceSectionZoomFromWheel, +} from './resourceSectionHeightModel'; import { useProjectResourceCanvasLayout } from './useProjectResourceCanvasLayout'; +import { useProjectResourceCardPreviews } from './useProjectResourceCardPreviews'; +import { useProjectResourceSectionHeights } from './useProjectResourceSectionHeights'; export type { ProjectAgentResultSummary, @@ -69,57 +89,12 @@ type ResourceSortMode = ProjectResourceCanvasLayoutMode; type WorkbenchMode = 'resources' | 'run'; type ApprovalMode = 'strict' | 'risk' | 'none'; -type LocalProjectImagePreview = { - path: string; - mediaType: string; - byteLen: number; - dataUrl: string; +type WebKitGestureEvent = Event & { + clientX?: number; + clientY?: number; + scale?: number; }; -type LocalProjectTextPreview = { - path: string; - mediaType: string; - byteLen: number; - content: string; -}; - -type LocalProjectMediaPreview = { - path: string; - mediaType: string; - byteLen: number; - dataUrl: string; -}; - -type ImagePreviewState = - | { status: 'idle'; resourceId: null } - | { status: 'loading'; resourceId: string } - | { - status: 'loaded'; - resourceId: string; - preview: LocalProjectImagePreview; - } - | { status: 'failed'; resourceId: string; error: string }; - -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; label: string; @@ -200,48 +175,6 @@ const approvalOptions: Array<{ }, ]; -function isRasterImageResource(resource: ProjectResource) { - const mediaType = resource.mediaType.toLowerCase(); - return ( - ['image/png', 'image/jpeg', 'image/jpg', 'image/webp'].includes( - mediaType, - ) || /\.(png|jpe?g|webp)$/iu.test(resource.path) - ); -} - -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 '载入后显示'; @@ -280,35 +213,6 @@ function SafeProjectMarkdown({ content }: { content: string }) { ); } -function imagePreviewErrorMessage(error: unknown) { - const message = error instanceof Error ? error.message : String(error); - if (message.includes('项目权限策略要求用户确认')) { - return '当前项目策略要求先确认读取图片'; - } - if (message.includes('项目权限策略拒绝执行')) { - return '当前项目策略不允许读取图片'; - } - if (message.includes('单张图片不能超过')) { - return message.replace('image.inspect', '图片预览'); - } - if (message.includes('只支持 PNG、JPEG 或 WEBP')) { - return '暂时只能预览 PNG、JPEG 或 WEBP 图片'; - } - if (message.includes('不能为空')) { - return '图片文件为空,无法预览'; - } - if (message.includes('发生漂移') || message.includes('发生替换')) { - return '图片读取期间发生变化,请关闭后重试'; - } - if (message.includes('图片尺寸过大')) { - return '图片尺寸过大,暂时无法在客户端预览'; - } - if (message.includes('图片结构无效')) { - return '图片内容损坏,无法预览'; - } - return '图片暂时无法读取,请关闭后重试'; -} - function summarizeAgent( manifest: GameCreationAppManifest, group: AgentSummary['group'], @@ -358,46 +262,279 @@ const ResourceCard = memo(function ResourceCard({ relationState, x, y, + previewIdentity, + preview, + activeMediaIdentity, onSelect, + onObservePreview, + onRequestPreview, + onPreviewDecodeError, + onMediaToggle, + onMediaReady, + onMediaPlaybackChange, }: { resource: ProjectResource; selected: boolean; relationState: 'version-binding' | null; x: number; y: number; + previewIdentity: string; + preview: ProjectResourceCardPreviewState; + activeMediaIdentity: string | null; onSelect: (resourceId: string) => void; + onObservePreview: ( + element: HTMLElement, + resource: ProjectResource, + identity: string, + ) => () => void; + onRequestPreview: ( + resource: ProjectResource, + identity: string, + reason: 'visible' | 'detail' | 'play', + ) => void; + onPreviewDecodeError: (identity: string, error: string) => void; + onMediaToggle: ( + resource: ProjectResource, + identity: string, + element: HTMLMediaElement | null, + ) => void; + onMediaReady: (identity: string, element: HTMLMediaElement) => void; + onMediaPlaybackChange: ( + resourceId: string, + identity: string, + element: HTMLMediaElement, + playing: boolean, + ) => void; }) { + const cardRef = useRef(null); + const mediaRef = useRef(null); + const [decodedIdentity, setDecodedIdentity] = useState(null); const Icon = categoryIcons[resource.category]; + const kind = projectResourceCardPreviewKind(resource); + const isMedia = kind === 'video' || kind === 'audio'; + const mediaActive = activeMediaIdentity === previewIdentity; + const sourceUrl = + preview.status === 'loaded' ? preview.preview.sourceUrl : null; + const documentSummary = + preview.status === 'loaded' && preview.preview.content !== undefined + ? summarizeProjectResourceDocument(preview.preview.content) + : ''; + + useEffect(() => { + const element = cardRef.current; + if (!element) { + return undefined; + } + return onObservePreview(element, resource, previewIdentity); + }, [onObservePreview, previewIdentity, resource]); + + useEffect(() => { + setDecodedIdentity(null); + }, [previewIdentity]); + + useEffect(() => { + const media = mediaRef.current; + if (mediaActive && media && sourceUrl) { + onMediaReady(previewIdentity, media); + } + }, [mediaActive, onMediaReady, previewIdentity, sourceUrl]); + + useEffect(() => { + const media = mediaRef.current; + return () => { + media?.pause(); + media?.removeAttribute('src'); + }; + }, [previewIdentity, sourceUrl]); + + const visual = (() => { + if ((kind === 'raster-image' || kind === 'media-image') && sourceUrl) { + return ( + + onPreviewDecodeError( + previewIdentity, + '美术资源无法解码,请重新生成或替换该资源', + ) + } + /> + ); + } + if (kind === 'video' && sourceUrl) { + return ( + <> +