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 614254bed..67a4f83f4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -196,6 +196,31 @@ pub(crate) fn get_local_game_manifest( read_manifest_for_project(root) } +#[tauri::command] +pub(crate) fn read_local_project_resource_canvas_layout( + project_path: String, + mode: ProjectResourceCanvasLayoutMode, +) -> Result { + read_project_resource_canvas_layout_at(Path::new(project_path.trim()), mode) +} + +#[tauri::command] +pub(crate) fn update_local_project_resource_canvas_layout( + project_path: String, + expected_project_id: String, + mode: ProjectResourceCanvasLayoutMode, + expected_revision: u64, + positions: Vec, +) -> Result { + update_project_resource_canvas_layout_at( + Path::new(project_path.trim()), + mode, + &expected_project_id, + expected_revision, + positions, + ) +} + #[tauri::command] pub(crate) async fn control_agent_run( app: tauri::AppHandle, 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 66a9706ae..d72097414 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -30,9 +30,11 @@ use shared_contracts::game_creation_app::{ GameCreationAppCommandRunStatus, GameCreationAppLimitedRunCommandDescriptor, GameCreationAppManifest, GameCreationAppPermission, GameCreationAppPreviewState, GameCreationAppPreviewStatus, GameCreationAppTaskState, GameCreationAppTaskStatus, + ProjectResourceCanvasLayout, ProjectResourceCanvasLayoutMode, ProjectResourceCanvasPosition, + UpdateProjectResourceCanvasLayoutResult, UpdateProjectResourceCanvasLayoutStatus, GAME_CREATION_AGENT_CAPABILITIES, GAME_CREATION_AGENT_RUN_SCHEMA_VERSION, GAME_CREATION_AGENT_TOOL_CALL_MAX, GAME_CREATION_APP_COMMANDS, - GAME_CREATION_APP_LIMITED_RUN_COMMANDS, + GAME_CREATION_APP_LIMITED_RUN_COMMANDS, GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION, }; use tauri::{Emitter, Manager}; use tauri_plugin_dialog::DialogExt; @@ -1917,6 +1919,8 @@ fn main() { activate_local_game_preview, stop_local_game_preview, get_local_game_preview_status, + read_local_project_resource_canvas_layout, + update_local_project_resource_canvas_layout, get_local_game_manifest ]) .build(tauri_context) diff --git a/apps/ai-game-creator-shell/src-tauri/src/project.rs b/apps/ai-game-creator-shell/src-tauri/src/project.rs index d87420cae..aee2ea42a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project.rs @@ -10,6 +10,7 @@ mod export; mod filesystem; mod manifest; mod memory; +mod resource_layout; mod verification; pub(crate) use agent_db::*; @@ -19,4 +20,5 @@ pub(crate) use export::*; pub(crate) use filesystem::*; pub(crate) use manifest::*; pub(crate) use memory::*; +pub(crate) use resource_layout::*; pub(crate) use verification::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs b/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs index ef7930abd..f1fd8f501 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs @@ -159,6 +159,7 @@ pub(crate) fn list_local_project_files_at( let relative_path = relative_project_path(root, &path)?; if is_agent_runtime_private_control_path(&relative_path) || is_agent_checkpoint_control_path(&relative_path) + || is_agent_workbench_control_path(&relative_path) { continue; } @@ -236,6 +237,12 @@ fn is_agent_checkpoint_control_path(normalized_path: &str) -> bool { && matches!(parts.next(), Some(part) if part.eq_ignore_ascii_case("checkpoints")) } +fn is_agent_workbench_control_path(normalized_path: &str) -> bool { + let mut parts = normalized_path.split('/'); + matches!(parts.next(), Some(part) if part.eq_ignore_ascii_case(".agent")) + && matches!(parts.next(), Some(part) if part.eq_ignore_ascii_case("workbench")) +} + pub(crate) fn reject_agent_runtime_private_control_path( normalized_path: &str, ) -> Result<(), String> { @@ -245,6 +252,9 @@ pub(crate) fn reject_agent_runtime_private_control_path( if is_agent_checkpoint_control_path(normalized_path) { return Err("Agent checkpoint 控制面不可通过通用文件工具访问".to_string()); } + if is_agent_workbench_control_path(normalized_path) { + return Err("Agent workbench 控制面不可通过通用文件工具访问".to_string()); + } Ok(()) } 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 new file mode 100644 index 000000000..f6045c9fc --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/project/resource_layout.rs @@ -0,0 +1,990 @@ +use super::*; +use shared_contracts::game_creation_app::{ + validate_project_resource_canvas_layout_revision, + GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION, +}; +use std::collections::HashSet; +use std::fs::File; +use std::sync::{Mutex, OnceLock}; + +const RESOURCE_LAYOUT_DIRECTORY: &str = ".agent/workbench/resource-layouts"; +const RESOURCE_LAYOUT_LOCK_PATH: &str = ".agent/workbench/resource-layouts/.layout.lock"; +const RESOURCE_LAYOUT_MAX_BYTES: usize = 2 * 1024 * 1024; +const RESOURCE_LAYOUT_MAX_POSITIONS: usize = 4096; +const RESOURCE_LAYOUT_MAX_RESOURCE_ID_CHARS: usize = 512; +const RESOURCE_LAYOUT_MAX_COORDINATE: u32 = 1_000_000; +const RESOURCE_LAYOUT_LOCK_WAIT_ATTEMPTS: usize = 100; +const RESOURCE_LAYOUT_LOCK_WAIT_MILLIS: u64 = 10; + +static RESOURCE_LAYOUT_LOCK_OPEN_GUARD: OnceLock> = OnceLock::new(); + +#[derive(Debug)] +struct ResourceLayoutWriteLock { + _file: File, +} + +fn resource_layout_mode_name(mode: ProjectResourceCanvasLayoutMode) -> &'static str { + match mode { + ProjectResourceCanvasLayoutMode::Dependency => "dependency", + ProjectResourceCanvasLayoutMode::Type => "type", + } +} + +fn resource_layout_relative_path(mode: ProjectResourceCanvasLayoutMode) -> String { + format!( + "{RESOURCE_LAYOUT_DIRECTORY}/{}.json", + resource_layout_mode_name(mode) + ) +} + +fn acquire_resource_layout_write_lock(root: &Path) -> Result { + for attempt in 0..RESOURCE_LAYOUT_LOCK_WAIT_ATTEMPTS { + if let Some(file) = try_open_resource_layout_write_lock_file(root)? { + return Ok(ResourceLayoutWriteLock { _file: file }); + } + if attempt + 1 < RESOURCE_LAYOUT_LOCK_WAIT_ATTEMPTS { + std::thread::sleep(Duration::from_millis(RESOURCE_LAYOUT_LOCK_WAIT_MILLIS)); + } + } + Err("资源布局正在被其他窗口保存,请稍后重试".to_string()) +} + +#[cfg(unix)] +fn try_open_resource_layout_write_lock_file(root: &Path) -> Result, String> { + use std::ffi::CString; + use std::os::fd::{AsRawFd, FromRawFd}; + use std::os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt}; + + let _open_guard = RESOURCE_LAYOUT_LOCK_OPEN_GUARD + .get_or_init(|| Mutex::new(())) + .lock() + .map_err(|_| "资源布局锁安全打开门禁已损坏".to_string())?; + validate_project_root(root)?; + let relative_path = normalize_relative_path(RESOURCE_LAYOUT_LOCK_PATH)?; + let path = root.join(&relative_path); + let mut components = relative_path.split('/').collect::>(); + let file_name = components + .pop() + .ok_or_else(|| "资源布局锁路径缺少文件名".to_string())?; + let mut directory = fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_CLOEXEC | libc::O_DIRECTORY | libc::O_NOFOLLOW) + .open(root) + .map_err(|error| format!("安全打开项目目录失败:{}: {error}", root.display()))?; + for component in components { + let component = + CString::new(component).map_err(|_| "资源布局锁目录包含 NUL".to_string())?; + // SAFETY: `directory` is a live directory fd and `component` is NUL terminated. + let created = unsafe { libc::mkdirat(directory.as_raw_fd(), component.as_ptr(), 0o700) }; + if created != 0 { + let error = std::io::Error::last_os_error(); + if error.kind() != std::io::ErrorKind::AlreadyExists { + return Err(format!( + "创建资源布局锁目录失败:{}: {error}", + path.display() + )); + } + } + // SAFETY: `directory` and `component` remain valid for the duration of openat. + let fd = unsafe { + libc::openat( + directory.as_raw_fd(), + component.as_ptr(), + libc::O_RDONLY | libc::O_CLOEXEC | libc::O_DIRECTORY | libc::O_NOFOLLOW, + ) + }; + if fd < 0 { + return Err(format!( + "安全打开资源布局锁目录失败:{}: {}", + path.display(), + std::io::Error::last_os_error() + )); + } + // SAFETY: openat returned a new owned fd. + directory = unsafe { File::from_raw_fd(fd) }; + } + let file_name = CString::new(file_name).map_err(|_| "资源布局锁文件名包含 NUL".to_string())?; + // SAFETY: `directory` is a live directory fd and `file_name` is NUL terminated. + let fd = unsafe { + libc::openat( + directory.as_raw_fd(), + file_name.as_ptr(), + libc::O_RDWR | libc::O_CREAT | libc::O_CLOEXEC | libc::O_NOFOLLOW, + 0o600, + ) + }; + if fd < 0 { + return Err(format!( + "安全打开资源布局锁失败:{}: {}", + path.display(), + std::io::Error::last_os_error() + )); + } + // SAFETY: openat returned a new owned fd. + let file = unsafe { File::from_raw_fd(fd) }; + let metadata = file + .metadata() + .map_err(|error| format!("读取资源布局锁句柄元数据失败:{}: {error}", path.display()))?; + // SAFETY: geteuid takes no arguments and has no memory safety preconditions. + let effective_user_id = unsafe { libc::geteuid() }; + if !metadata.is_file() || metadata.uid() != effective_user_id || metadata.nlink() != 1 { + return Err(format!( + "资源布局锁必须是当前用户持有的无硬链接普通文件:{}", + path.display() + )); + } + file.set_permissions(fs::Permissions::from_mode(0o600)) + .map_err(|error| format!("收紧资源布局锁权限失败:{}: {error}", path.display()))?; + let path_metadata = fs::symlink_metadata(&path) + .map_err(|error| format!("复核资源布局锁路径失败:{}: {error}", path.display()))?; + if path_metadata.file_type().is_symlink() + || path_metadata.dev() != metadata.dev() + || path_metadata.ino() != metadata.ino() + { + return Err(format!( + "资源布局锁路径在安全打开期间发生替换:{}", + path.display() + )); + } + let verified = file + .metadata() + .map_err(|error| format!("复核资源布局锁句柄失败:{}: {error}", path.display()))?; + if verified.uid() != effective_user_id + || verified.nlink() != 1 + || verified.permissions().mode() & 0o777 != 0o600 + { + return Err(format!( + "资源布局锁必须由当前用户持有且权限为 0600:{}", + path.display() + )); + } + // SAFETY: flock observes only the live fd owned by `file`; dropping it releases the lock. + if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0 { + return Ok(Some(file)); + } + let error = std::io::Error::last_os_error(); + if error.kind() == std::io::ErrorKind::WouldBlock { + Ok(None) + } else { + Err(format!( + "获取资源布局系统文件锁失败:{}: {error}", + path.display() + )) + } +} + +#[cfg(windows)] +fn try_open_resource_layout_write_lock_file(root: &Path) -> Result, String> { + use std::os::windows::fs::{MetadataExt, OpenOptionsExt}; + + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; + const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000; + const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; + const FILE_SHARE_READ_WRITE: u32 = 0x0000_0003; + + let _open_guard = RESOURCE_LAYOUT_LOCK_OPEN_GUARD + .get_or_init(|| Mutex::new(())) + .lock() + .map_err(|_| "资源布局锁安全打开门禁已损坏".to_string())?; + validate_project_root(root)?; + let relative_path = normalize_relative_path(RESOURCE_LAYOUT_LOCK_PATH)?; + let path = root.join(&relative_path); + let mut components = relative_path.split('/').collect::>(); + components + .pop() + .ok_or_else(|| "资源布局锁路径缺少文件名".to_string())?; + let mut guarded_directories = Vec::with_capacity(components.len() + 1); + let mut current = root.to_path_buf(); + for component in std::iter::once(None).chain(components.into_iter().map(Some)) { + if let Some(component) = component { + current.push(component); + if !current.exists() { + fs::create_dir(¤t).map_err(|error| { + format!("创建资源布局锁目录失败:{}: {error}", current.display()) + })?; + } + } + let metadata = fs::symlink_metadata(¤t).map_err(|error| { + format!( + "读取资源布局锁目录元数据失败:{}: {error}", + current.display() + ) + })?; + if !metadata.is_dir() || metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return Err(format!( + "资源布局锁目录必须是普通目录且不能是 reparse point:{}", + current.display() + )); + } + let handle = fs::OpenOptions::new() + .read(true) + .share_mode(FILE_SHARE_READ_WRITE) + .custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT) + .open(¤t) + .map_err(|error| { + format!("安全打开资源布局锁目录失败:{}: {error}", current.display()) + })?; + let opened = handle + .metadata() + .map_err(|error| format!("复核资源布局锁目录失败:{}: {error}", current.display()))?; + if !opened.is_dir() || opened.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return Err(format!( + "资源布局锁目录打开后身份无效:{}", + current.display() + )); + } + guarded_directories.push(handle); + } + if let Ok(metadata) = fs::symlink_metadata(&path) { + if metadata.file_type().is_symlink() + || !metadata.is_file() + || metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 + { + return Err(format!( + "资源布局锁必须是普通文件且不能是 reparse point:{}", + path.display() + )); + } + } + match fs::OpenOptions::new() + .create(true) + .read(true) + .write(true) + .share_mode(0) + .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT) + .open(&path) + { + Ok(file) => { + validate_windows_regular_file_handle(&file, "资源布局锁")?; + crate::secure_windows_game_creator_path_for_current_user(&path, false, true)?; + Ok(Some(file)) + } + Err(error) + if matches!( + error.kind(), + std::io::ErrorKind::PermissionDenied | std::io::ErrorKind::WouldBlock + ) => + { + Ok(None) + } + Err(error) => Err(format!( + "获取资源布局系统文件锁失败:{}: {error}", + path.display() + )), + } +} + +#[cfg(not(any(unix, windows)))] +fn try_open_resource_layout_write_lock_file(root: &Path) -> Result, String> { + Err(format!( + "当前平台不支持资源布局系统文件锁:{}", + root.join(RESOURCE_LAYOUT_LOCK_PATH).display() + )) +} + +fn current_resource_layout_project_id(root: &Path) -> Result { + validate_project_root(root)?; + let manifest_path = resolve_local_project_path(root, ".agent/manifest.json")?; + let manifest = read_manifest(&manifest_path)?; + let project_id = manifest.project_id.trim(); + if project_id.is_empty() { + return Err("项目 manifest 缺少 projectId".to_string()); + } + Ok(project_id.to_string()) +} + +fn empty_resource_layout( + project_id: String, + mode: ProjectResourceCanvasLayoutMode, +) -> ProjectResourceCanvasLayout { + ProjectResourceCanvasLayout { + schema_version: GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION.to_string(), + project_id, + mode, + revision: 0, + positions: Vec::new(), + updated_at: 0, + } +} + +fn validate_resource_layout_positions( + positions: &[ProjectResourceCanvasPosition], +) -> Result<(), String> { + if positions.len() > RESOURCE_LAYOUT_MAX_POSITIONS { + return Err(format!( + "资源布局最多支持 {RESOURCE_LAYOUT_MAX_POSITIONS} 个位置" + )); + } + let mut resource_ids = HashSet::with_capacity(positions.len()); + for position in positions { + let resource_id = position.resource_id.trim(); + if resource_id.is_empty() { + return Err("资源布局 resourceId 不能为空".to_string()); + } + if resource_id.chars().count() > RESOURCE_LAYOUT_MAX_RESOURCE_ID_CHARS { + return Err(format!( + "资源布局 resourceId 最多支持 {RESOURCE_LAYOUT_MAX_RESOURCE_ID_CHARS} 个字符" + )); + } + if resource_id.chars().any(char::is_control) { + return Err("资源布局 resourceId 不能包含控制字符".to_string()); + } + if !resource_ids.insert(resource_id) { + return Err("资源布局 resourceId 不能重复".to_string()); + } + if position.x > RESOURCE_LAYOUT_MAX_COORDINATE + || position.y > RESOURCE_LAYOUT_MAX_COORDINATE + { + return Err(format!( + "资源布局坐标不能超过 {RESOURCE_LAYOUT_MAX_COORDINATE}" + )); + } + } + Ok(()) +} + +fn validate_resource_layout( + layout: &ProjectResourceCanvasLayout, + project_id: &str, + mode: ProjectResourceCanvasLayoutMode, +) -> Result<(), String> { + if layout.schema_version != GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION { + return Err(format!( + "不支持的资源布局 schema:{}", + layout.schema_version + )); + } + if layout.project_id != project_id { + return Err("资源布局 projectId 与当前项目不匹配".to_string()); + } + if layout.mode != mode { + return Err("资源布局 mode 与文件不匹配".to_string()); + } + validate_project_resource_canvas_layout_revision(layout.revision).map_err(str::to_string)?; + validate_resource_layout_positions(&layout.positions) +} + +fn validate_existing_resource_layout_storage( + root: &Path, + relative_path: &str, +) -> Result<(), String> { + let path = resolve_local_project_path(root, relative_path)?; + match fs::symlink_metadata(&path) { + Ok(_) => { + let (_, metadata) = open_project_snapshot_regular_file(&path, "资源布局 sidecar")?; + if metadata.len() > RESOURCE_LAYOUT_MAX_BYTES as u64 { + return Err(format!( + "资源布局 sidecar 超过 {RESOURCE_LAYOUT_MAX_BYTES} 字节上限" + )); + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(format!( + "读取资源布局 sidecar 元数据失败:{}: {error}", + path.display() + )); + } + } + let backup_path = agent_runtime_json_sidecar_backup_path(&path); + match fs::symlink_metadata(&backup_path) { + Ok(_) => { + let (_, metadata) = + open_project_snapshot_regular_file(&backup_path, "资源布局恢复副本")?; + if metadata.len() > RESOURCE_LAYOUT_MAX_BYTES as u64 { + return Err(format!( + "资源布局恢复副本超过 {RESOURCE_LAYOUT_MAX_BYTES} 字节上限" + )); + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(format!( + "读取资源布局恢复副本元数据失败:{}: {error}", + backup_path.display() + )); + } + } + Ok(()) +} + +fn read_resource_layout_sidecar( + root: &Path, + relative_path: &str, +) -> Result, String> { + validate_existing_resource_layout_storage(root, relative_path)?; + read_agent_runtime_json_sidecar_with_max_bytes( + root, + relative_path, + "资源布局 sidecar", + RESOURCE_LAYOUT_MAX_BYTES, + ) +} + +pub(crate) fn read_project_resource_canvas_layout_at( + root: &Path, + mode: ProjectResourceCanvasLayoutMode, +) -> Result { + let project_id = current_resource_layout_project_id(root)?; + let relative_path = resource_layout_relative_path(mode); + let Some(layout) = read_resource_layout_sidecar(root, &relative_path)? else { + return Ok(empty_resource_layout(project_id, mode)); + }; + validate_resource_layout(&layout, &project_id, mode)?; + Ok(layout) +} + +pub(crate) fn update_project_resource_canvas_layout_at( + root: &Path, + mode: ProjectResourceCanvasLayoutMode, + expected_project_id: &str, + expected_revision: u64, + positions: Vec, +) -> Result { + validate_project_resource_canvas_layout_revision(expected_revision).map_err(str::to_string)?; + validate_resource_layout_positions(&positions)?; + let expected_project_id = expected_project_id.trim(); + if expected_project_id.is_empty() { + return Err("资源布局 expectedProjectId 不能为空".to_string()); + } + let preflight_project_id = current_resource_layout_project_id(root)?; + if preflight_project_id != expected_project_id { + return Err("资源布局 expectedProjectId 与当前项目不匹配".to_string()); + } + let _lock = acquire_resource_layout_write_lock(root)?; + let project_id = current_resource_layout_project_id(root)?; + if project_id != expected_project_id { + return Err("项目 manifest 在资源布局锁获取期间发生变化,请重试".to_string()); + } + let relative_path = resource_layout_relative_path(mode); + let current = match read_resource_layout_sidecar(root, &relative_path)? { + Some(layout) => { + validate_resource_layout(&layout, &project_id, mode)?; + layout + } + None => empty_resource_layout(project_id.clone(), mode), + }; + if current.revision != expected_revision { + return Ok(UpdateProjectResourceCanvasLayoutResult { + status: UpdateProjectResourceCanvasLayoutStatus::Conflict, + layout: current, + }); + } + if current.revision == GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION { + return Err("资源布局 revision 已达到 JavaScript 安全整数上限,无法继续保存".to_string()); + } + let next_revision = current.revision + 1; + if current_resource_layout_project_id(root)? != project_id { + return Err("项目 manifest 在资源布局写入前发生变化,请重试".to_string()); + } + let next = ProjectResourceCanvasLayout { + schema_version: GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION.to_string(), + project_id, + mode, + revision: next_revision, + positions, + updated_at: unix_millis().min(u128::from(u64::MAX)) as u64, + }; + validate_existing_resource_layout_storage(root, &relative_path)?; + write_agent_runtime_json_sidecar_with_max_bytes( + root, + &relative_path, + "资源布局 sidecar", + &next, + RESOURCE_LAYOUT_MAX_BYTES, + )?; + Ok(UpdateProjectResourceCanvasLayoutResult { + status: UpdateProjectResourceCanvasLayoutStatus::Updated, + layout: next, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{ + atomic::{AtomicU64, Ordering}, + Arc, Barrier, + }; + + static NEXT_RESOURCE_LAYOUT_TEST_ID: AtomicU64 = AtomicU64::new(0); + + fn unique_resource_layout_project_path() -> PathBuf { + std::env::temp_dir().join(format!( + "genarrative-resource-layout-{}-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(), + NEXT_RESOURCE_LAYOUT_TEST_ID.fetch_add(1, Ordering::Relaxed) + )) + } + + fn layout_position(id: &str, x: u32) -> ProjectResourceCanvasPosition { + ProjectResourceCanvasPosition { + resource_id: id.to_string(), + section: shared_contracts::game_creation_app::ProjectResourceCanvasSection::Art, + x, + y: 24, + manually_placed: true, + } + } + + #[test] + fn resource_layout_missing_file_starts_at_revision_zero_and_modes_are_independent() { + let root = unique_resource_layout_project_path(); + init_local_game_project_at(&root, "layout-project", "布局项目").expect("init project"); + + let dependency = read_project_resource_canvas_layout_at( + &root, + ProjectResourceCanvasLayoutMode::Dependency, + ) + .expect("read dependency layout"); + assert_eq!(dependency.revision, 0); + assert!(dependency.positions.is_empty()); + assert!(!root + .join(resource_layout_relative_path( + ProjectResourceCanvasLayoutMode::Dependency + )) + .exists()); + + let updated = update_project_resource_canvas_layout_at( + &root, + ProjectResourceCanvasLayoutMode::Dependency, + "layout-project", + 0, + vec![layout_position("asset-a", 18)], + ) + .expect("update dependency layout"); + assert_eq!( + updated.status, + UpdateProjectResourceCanvasLayoutStatus::Updated + ); + assert_eq!(updated.layout.revision, 1); + + let type_layout = + read_project_resource_canvas_layout_at(&root, ProjectResourceCanvasLayoutMode::Type) + .expect("read type layout"); + assert_eq!(type_layout.revision, 0); + assert!(type_layout.positions.is_empty()); + fs::remove_dir_all(root).ok(); + } + + #[test] + fn resource_layout_invalid_roots_have_zero_workbench_side_effects() { + let missing_root = unique_resource_layout_project_path(); + let missing_error = update_project_resource_canvas_layout_at( + &missing_root, + ProjectResourceCanvasLayoutMode::Dependency, + "missing-project", + 0, + vec![layout_position("asset-a", 10)], + ) + .expect_err("missing project root must fail"); + assert!(missing_error.contains("manifest") || missing_error.contains("项目")); + assert!(!missing_root.exists()); + + let non_project_root = unique_resource_layout_project_path(); + fs::create_dir_all(&non_project_root).expect("create non-project root"); + update_project_resource_canvas_layout_at( + &non_project_root, + ProjectResourceCanvasLayoutMode::Dependency, + "non-project", + 0, + vec![layout_position("asset-a", 10)], + ) + .expect_err("non-project root must fail"); + assert!(!non_project_root.join(".agent").exists()); + fs::remove_dir_all(&non_project_root).ok(); + + let invalid_manifest_root = unique_resource_layout_project_path(); + let agent_directory = invalid_manifest_root.join(".agent"); + fs::create_dir_all(&agent_directory).expect("create invalid project agent directory"); + fs::write(agent_directory.join("manifest.json"), b"{invalid-json") + .expect("write invalid manifest"); + update_project_resource_canvas_layout_at( + &invalid_manifest_root, + ProjectResourceCanvasLayoutMode::Dependency, + "invalid-project", + 0, + vec![layout_position("asset-a", 10)], + ) + .expect_err("invalid project manifest must fail"); + assert!(!agent_directory.join("workbench").exists()); + fs::remove_dir_all(invalid_manifest_root).ok(); + } + + #[test] + fn resource_layout_expected_project_id_fences_stale_windows_before_lock_side_effects() { + let root = unique_resource_layout_project_path(); + init_local_game_project_at(&root, "layout-current-project", "布局当前项目") + .expect("init project"); + + let error = update_project_resource_canvas_layout_at( + &root, + ProjectResourceCanvasLayoutMode::Dependency, + "layout-stale-project", + 0, + vec![layout_position("asset-a", 10)], + ) + .expect_err("stale project window must fail"); + assert!(error.contains("expectedProjectId")); + assert!(!root.join(".agent/workbench").exists()); + fs::remove_dir_all(root).ok(); + } + + #[cfg(unix)] + #[test] + fn resource_layout_live_system_lock_is_not_reclaimed_from_old_mtime() { + use std::fs::FileTimes; + use std::os::unix::fs::MetadataExt; + + let root = unique_resource_layout_project_path(); + init_local_game_project_at(&root, "layout-system-lock", "布局系统锁") + .expect("init project"); + let first = acquire_resource_layout_write_lock(&root).expect("acquire first lock"); + let lock_path = root.join(RESOURCE_LAYOUT_LOCK_PATH); + let original = fs::symlink_metadata(&lock_path).expect("stat original lock"); + first + ._file + .set_times(FileTimes::new().set_modified(UNIX_EPOCH)) + .expect("age live lock mtime"); + + assert!(try_open_resource_layout_write_lock_file(&root) + .expect("try competing lock") + .is_none()); + let while_locked = fs::symlink_metadata(&lock_path).expect("stat live lock"); + assert_eq!(while_locked.dev(), original.dev()); + assert_eq!(while_locked.ino(), original.ino()); + + drop(first); + let second = try_open_resource_layout_write_lock_file(&root) + .expect("try released lock") + .expect("released system lock must be acquirable"); + let after_reacquire = fs::symlink_metadata(&lock_path).expect("stat reacquired lock"); + assert_eq!(after_reacquire.dev(), original.dev()); + assert_eq!(after_reacquire.ino(), original.ino()); + drop(second); + assert!(lock_path.exists()); + fs::remove_dir_all(root).ok(); + } + + #[test] + fn resource_layout_compare_and_swap_returns_latest_without_overwrite() { + let root = unique_resource_layout_project_path(); + init_local_game_project_at(&root, "layout-cas", "布局 CAS").expect("init project"); + let first = update_project_resource_canvas_layout_at( + &root, + ProjectResourceCanvasLayoutMode::Dependency, + "layout-cas", + 0, + vec![layout_position("asset-a", 10)], + ) + .expect("first update"); + assert_eq!(first.layout.revision, 1); + + let conflict = update_project_resource_canvas_layout_at( + &root, + ProjectResourceCanvasLayoutMode::Dependency, + "layout-cas", + 0, + vec![layout_position("asset-a", 999)], + ) + .expect("conflict result"); + assert_eq!( + conflict.status, + UpdateProjectResourceCanvasLayoutStatus::Conflict + ); + assert_eq!(conflict.layout.revision, 1); + assert_eq!(conflict.layout.positions[0].x, 10); + + let persisted = read_project_resource_canvas_layout_at( + &root, + ProjectResourceCanvasLayoutMode::Dependency, + ) + .expect("read persisted"); + assert_eq!(persisted.positions[0].x, 10); + fs::remove_dir_all(root).ok(); + } + + #[test] + fn resource_layout_safe_revision_exhaustion_fails_without_overwrite() { + let root = unique_resource_layout_project_path(); + init_local_game_project_at(&root, "layout-revision-max", "布局 revision 上限") + .expect("init project"); + let path = root.join(resource_layout_relative_path( + ProjectResourceCanvasLayoutMode::Dependency, + )); + fs::create_dir_all(path.parent().expect("layout parent")).expect("create layout parent"); + let exhausted = ProjectResourceCanvasLayout { + schema_version: GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION.to_string(), + project_id: "layout-revision-max".to_string(), + mode: ProjectResourceCanvasLayoutMode::Dependency, + revision: GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION, + positions: vec![layout_position("asset-a", 10)], + updated_at: 1, + }; + let original = serde_json::to_vec(&exhausted).expect("serialize exhausted layout"); + fs::write(&path, &original).expect("write exhausted layout"); + + let error = update_project_resource_canvas_layout_at( + &root, + ProjectResourceCanvasLayoutMode::Dependency, + "layout-revision-max", + GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION, + vec![layout_position("asset-a", 999)], + ) + .expect_err("revision exhaustion must fail"); + assert!(error.contains("revision")); + assert_eq!(fs::read(&path).expect("read exhausted layout"), original); + fs::remove_dir_all(root).ok(); + } + + #[test] + fn resource_layout_rejects_unsafe_revision_without_side_effects() { + let root = unique_resource_layout_project_path(); + init_local_game_project_at(&root, "layout-unsafe-revision", "布局非安全 revision") + .expect("init project"); + + let expected_error = update_project_resource_canvas_layout_at( + &root, + ProjectResourceCanvasLayoutMode::Dependency, + "layout-unsafe-revision", + GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION + 1, + vec![layout_position("asset-a", 10)], + ) + .expect_err("unsafe expected revision must fail"); + assert!(expected_error.contains("revision")); + assert!(!root.join(".agent/workbench").exists()); + + let path = root.join(resource_layout_relative_path( + ProjectResourceCanvasLayoutMode::Dependency, + )); + fs::create_dir_all(path.parent().expect("layout parent")).expect("create layout parent"); + let unsafe_payload = serde_json::json!({ + "schemaVersion": GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION, + "projectId": "layout-unsafe-revision", + "mode": "dependency", + "revision": GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION + 1, + "positions": [], + "updatedAt": 1 + }); + let original = serde_json::to_vec(&unsafe_payload).expect("serialize unsafe fixture"); + fs::write(&path, &original).expect("write unsafe fixture"); + + let read_error = read_project_resource_canvas_layout_at( + &root, + ProjectResourceCanvasLayoutMode::Dependency, + ) + .expect_err("unsafe persisted revision must fail"); + assert!(read_error.contains("revision") || read_error.contains("安全整数")); + assert_eq!(fs::read(&path).expect("read unsafe fixture"), original); + fs::remove_dir_all(root).ok(); + } + + #[test] + fn resource_layout_rejects_duplicate_ids_and_project_identity_drift() { + let root = unique_resource_layout_project_path(); + init_local_game_project_at(&root, "layout-identity", "布局身份").expect("init project"); + let duplicate_error = update_project_resource_canvas_layout_at( + &root, + ProjectResourceCanvasLayoutMode::Type, + "layout-identity", + 0, + vec![ + layout_position("asset-a", 10), + layout_position("asset-a", 20), + ], + ) + .expect_err("duplicate ids must fail"); + assert!(duplicate_error.contains("不能重复")); + + let path = root.join(resource_layout_relative_path( + ProjectResourceCanvasLayoutMode::Dependency, + )); + fs::create_dir_all(path.parent().expect("layout parent")).expect("create parent"); + let foreign = ProjectResourceCanvasLayout { + schema_version: GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION.to_string(), + project_id: "foreign-project".to_string(), + mode: ProjectResourceCanvasLayoutMode::Dependency, + revision: 1, + positions: vec![], + updated_at: 1, + }; + fs::write( + &path, + serde_json::to_vec(&foreign).expect("serialize foreign"), + ) + .expect("write foreign layout"); + let identity_error = read_project_resource_canvas_layout_at( + &root, + ProjectResourceCanvasLayoutMode::Dependency, + ) + .expect_err("foreign project id must fail"); + assert!(identity_error.contains("projectId")); + fs::remove_dir_all(root).ok(); + } + + #[test] + fn resource_layout_rejects_corrupt_unknown_and_out_of_bounds_payloads() { + let root = unique_resource_layout_project_path(); + init_local_game_project_at(&root, "layout-invalid", "无效布局").expect("init project"); + let path = root.join(resource_layout_relative_path( + ProjectResourceCanvasLayoutMode::Dependency, + )); + fs::create_dir_all(path.parent().expect("layout parent")).expect("create parent"); + fs::write(&path, b"{invalid-json").expect("write corrupt layout"); + let corrupt_error = read_project_resource_canvas_layout_at( + &root, + ProjectResourceCanvasLayoutMode::Dependency, + ) + .expect_err("corrupt layout must fail"); + assert!(corrupt_error.contains("解析") || corrupt_error.contains("JSON")); + + let unknown = ProjectResourceCanvasLayout { + schema_version: "game-creator-resource-layout.v999".to_string(), + project_id: "layout-invalid".to_string(), + mode: ProjectResourceCanvasLayoutMode::Dependency, + revision: 1, + positions: vec![], + updated_at: 1, + }; + fs::write( + &path, + serde_json::to_vec(&unknown).expect("serialize unknown"), + ) + .expect("write unknown layout"); + let schema_error = read_project_resource_canvas_layout_at( + &root, + ProjectResourceCanvasLayoutMode::Dependency, + ) + .expect_err("unknown schema must fail"); + assert!(schema_error.contains("schema")); + + let coordinate_error = update_project_resource_canvas_layout_at( + &root, + ProjectResourceCanvasLayoutMode::Type, + "layout-invalid", + 0, + vec![layout_position( + "asset-coordinate", + RESOURCE_LAYOUT_MAX_COORDINATE + 1, + )], + ) + .expect_err("oversized coordinate must fail"); + assert!(coordinate_error.contains("坐标")); + let id_error = update_project_resource_canvas_layout_at( + &root, + ProjectResourceCanvasLayoutMode::Type, + "layout-invalid", + 0, + vec![layout_position( + &"x".repeat(RESOURCE_LAYOUT_MAX_RESOURCE_ID_CHARS + 1), + 10, + )], + ) + .expect_err("oversized resource id must fail"); + assert!(id_error.contains("resourceId")); + fs::remove_dir_all(root).ok(); + } + + #[cfg(unix)] + #[test] + fn resource_layout_rejects_symlinks_and_hardlinks() { + use std::os::unix::fs::symlink; + + let root = unique_resource_layout_project_path(); + init_local_game_project_at(&root, "layout-links", "布局链接").expect("init project"); + let path = root.join(resource_layout_relative_path( + ProjectResourceCanvasLayoutMode::Dependency, + )); + fs::create_dir_all(path.parent().expect("layout parent")).expect("create parent"); + let payload = ProjectResourceCanvasLayout { + schema_version: GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION.to_string(), + project_id: "layout-links".to_string(), + mode: ProjectResourceCanvasLayoutMode::Dependency, + revision: 1, + positions: vec![], + updated_at: 1, + }; + let bytes = serde_json::to_vec(&payload).expect("serialize layout"); + let target = root.join("layout-link-target.json"); + fs::write(&target, &bytes).expect("write symlink target"); + symlink(&target, &path).expect("create sidecar symlink"); + let symlink_error = read_project_resource_canvas_layout_at( + &root, + ProjectResourceCanvasLayoutMode::Dependency, + ) + .expect_err("symlink must fail"); + assert!(symlink_error.contains("符号链接")); + + fs::remove_file(&path).expect("remove symlink"); + fs::write(&path, &bytes).expect("write sidecar"); + let hardlink = root.join("layout-hardlink.json"); + fs::hard_link(&path, &hardlink).expect("create hardlink"); + let hardlink_error = read_project_resource_canvas_layout_at( + &root, + ProjectResourceCanvasLayoutMode::Dependency, + ) + .expect_err("hardlink must fail"); + assert!(hardlink_error.contains("硬链接")); + fs::remove_dir_all(root).ok(); + } + + #[test] + fn resource_layout_serializes_concurrent_updates_and_hides_workbench_files() { + let root = unique_resource_layout_project_path(); + init_local_game_project_at(&root, "layout-concurrent", "布局并发").expect("init project"); + let barrier = Arc::new(Barrier::new(2)); + let handles = [10, 20].map(|x| { + let root = root.clone(); + let barrier = Arc::clone(&barrier); + std::thread::spawn(move || { + barrier.wait(); + update_project_resource_canvas_layout_at( + &root, + ProjectResourceCanvasLayoutMode::Dependency, + "layout-concurrent", + 0, + vec![layout_position("asset-a", x)], + ) + }) + }); + let results = handles + .into_iter() + .map(|handle| { + handle + .join() + .expect("layout update thread") + .expect("layout update") + }) + .collect::>(); + assert_eq!( + results + .iter() + .filter(|result| result.status == UpdateProjectResourceCanvasLayoutStatus::Updated) + .count(), + 1 + ); + assert_eq!( + results + .iter() + .filter(|result| result.status == UpdateProjectResourceCanvasLayoutStatus::Conflict) + .count(), + 1 + ); + + let relative_path = + resource_layout_relative_path(ProjectResourceCanvasLayoutMode::Dependency); + let listed = list_local_project_files_at(&root).expect("list project files"); + assert!(!listed + .files + .iter() + .any(|entry| entry.path.starts_with(".agent/workbench"))); + assert!(read_local_project_file_at(&root, &relative_path).is_err()); + assert!(write_local_project_file_at(&root, &relative_path, "tampered").is_err()); + assert!(delete_local_project_file_at(&root, &relative_path).is_err()); + fs::remove_dir_all(root).ok(); + } +} diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index f9f223696..cec14b71b 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -4,6 +4,7 @@ import { type ChangeEvent, type FormEvent, type UIEvent, + useCallback, useEffect, useLayoutEffect, useRef, @@ -64,10 +65,10 @@ import type { LocalGameMemoryResult, LocalPreviewResult, LocalPreviewStatus, - LocalProjectDirectoryStatus, LocalProjectCheckpointResult, LocalProjectCheckpointSummary, LocalProjectDiffResult, + LocalProjectDirectoryStatus, LocalProjectExportPackageResult, LocalProjectExportPackagesResult, LocalProjectFileEntry, @@ -200,6 +201,7 @@ import { } from './features/project-workspace/agentRunTrace'; import { DeveloperProjectPanels } from './features/project-workspace/DeveloperProjectPanels'; import { DeveloperRuntimePanels } from './features/project-workspace/DeveloperRuntimePanels'; +import { resolveEmbeddedPreviewUrl } from './features/project-workspace/LocalGamePreviewFrame'; import { appendMemoryContent, memoryScopeLabel, @@ -221,7 +223,6 @@ import { import { handleProjectSummaryChatCommand } from './features/project-workspace/projectSummaryCommands'; import { ProjectSupervisorView } from './features/project-workspace/ProjectSupervisorView'; import { ProjectWorkspaceChatPane } from './features/project-workspace/ProjectWorkspaceChatPane'; -import { resolveEmbeddedPreviewUrl } from './features/project-workspace/LocalGamePreviewFrame'; import { buildGameChatProgressEvidence, collectGameChatResultImages, @@ -596,6 +597,9 @@ export function App({ ) => Promise) | null >(null); + const executeChatAgentReplyRef = useRef< + (prompt: string) => Promise + >(async () => undefined); const agentConversationSavingRef = useRef(false); const agentConversationBackgroundBusyRef = useRef(false); const agentConversationLoadVersionRef = useRef(0); @@ -620,27 +624,30 @@ export function App({ storeGameChatAutoPreviewAuthorization(authorization); } - function updateProjectSupervisorRuntime( - runtime: AgentRuntimeState | null, - previous = projectSupervisorRuntimeRef.current, - ) { - const nextRuntime = runtime - ? normalizeAgentRuntimeState(runtime, previous) - : null; - const nextProjectPath = localProjectPathRef.current; - if ( - gameChatOnly && - nextProjectPath && - nextRuntime?.runId && - !isAgentRuntimeTerminalState(nextRuntime) - ) { - gameChatObservedRunKeysRef.current.add( - `${nextProjectPath}\n${nextRuntime.runId}`, - ); - } - projectSupervisorRuntimeRef.current = nextRuntime; - setProjectSupervisorRuntime(nextRuntime); - } + const updateProjectSupervisorRuntime = useCallback( + ( + runtime: AgentRuntimeState | null, + previous = projectSupervisorRuntimeRef.current, + ) => { + const nextRuntime = runtime + ? normalizeAgentRuntimeState(runtime, previous) + : null; + const nextProjectPath = localProjectPathRef.current; + if ( + gameChatOnly && + nextProjectPath && + nextRuntime?.runId && + !isAgentRuntimeTerminalState(nextRuntime) + ) { + gameChatObservedRunKeysRef.current.add( + `${nextProjectPath}\n${nextRuntime.runId}`, + ); + } + projectSupervisorRuntimeRef.current = nextRuntime; + setProjectSupervisorRuntime(nextRuntime); + }, + [gameChatOnly], + ); function updateProjectSupervisorResponseStream( incoming: AgentRuntimeResponseStream | null | undefined, @@ -946,7 +953,7 @@ export function App({ disposed = true; cleanup?.(); }; - }, []); + }, [updateProjectSupervisorRuntime]); useEffect(() => { const invoke = resolveTauriInvoke(); @@ -5149,6 +5156,8 @@ export function App({ } } + executeChatAgentReplyRef.current = executeChatAgentReply; + useEffect(() => { const latch = initialSupervisorMessageLatchRef.current; if (!gameChatOnly || !latch.prompt || !localProject) { @@ -5169,7 +5178,7 @@ export function App({ ...current, { role: 'user', text: latch.prompt, runtimeOwned: true }, ]); - void executeChatAgentReply(latch.prompt); + void executeChatAgentReplyRef.current(latch.prompt); }, [chatAgentBusy, gameChatOnly, initialSupervisorMessage, localProject]); async function handleProjectSupervisorToolAction( diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/LocalGamePreviewFrame.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/LocalGamePreviewFrame.tsx index 40a425b5a..401757601 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/LocalGamePreviewFrame.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/LocalGamePreviewFrame.tsx @@ -1,3 +1,5 @@ +/* eslint-disable react-refresh/only-export-components -- The URL guard is exported with its small rendering adapter for focused tests. */ + export type LocalGamePreviewLike = { status?: string | null; url?: string | null; 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 f3ce2ea79..0b886a469 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 @@ -1,3 +1,5 @@ +/* eslint-disable react-refresh/only-export-components -- Testable game-chat presentation helpers share this focused view module. */ + import { FolderOpen, Send, Settings } from 'lucide-react'; import type { ComponentProps, @@ -21,12 +23,12 @@ import type { import { formatAgentRuntimeEvent, isAgentRuntimeTerminalState, - projectProfessionalAgentLabel, projectNameFromPath, + projectProfessionalAgentLabel, projectRuntimePlanProgress, projectRuntimeVisibleCurrentWork, - projectSupervisorCollaboratingAgentRuntimes, projectSupervisorChatRuntimeStatus, + projectSupervisorCollaboratingAgentRuntimes, ProjectSupervisorRuntimeControls, } from '../agent-runtime'; import { RuntimeConfigDialog } from '../runtime-config/RuntimeConfigDialog'; @@ -111,7 +113,12 @@ function gameChatResultImageLabel(kind: string, path: string) { } function isGameChatResultImagePath(path: string) { - if (/[\\\u0000-\u001f\u007f]/u.test(path)) { + if ( + Array.from(path).some((character) => { + const codePoint = character.codePointAt(0) ?? 0; + return character === '\\' || codePoint <= 0x1f || codePoint === 0x7f; + }) + ) { return false; } const segments = path.split('/'); diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index 5b627d28a..cafeb314c 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -3928,19 +3928,16 @@ iframe.preview-frame { font-size: 11px; } -.game-resource-row { - display: flex; - align-items: flex-start; - gap: 12px; +.game-resource-plane { + position: relative; + min-width: 620px; min-height: 108px; } -.game-resource-canvas--dependency .game-resource-card { - position: relative; - margin-left: calc(var(--resource-depth, 0) * 12px); -} - .game-resource-card { + position: absolute; + top: 0; + left: 0; display: grid; grid-template-columns: 36px minmax(100px, 1fr); grid-template-rows: auto auto auto; @@ -3957,7 +3954,11 @@ iframe.preview-frame { color: #4e382f; text-align: left; box-shadow: 0 6px 18px rgb(96 62 47 / 6%); - cursor: pointer; + cursor: grab; + touch-action: none; + user-select: none; + transform: translate3d(var(--resource-x, 0), var(--resource-y, 0), 0); + will-change: transform; } .game-resource-card:hover, @@ -3969,20 +3970,12 @@ iframe.preview-frame { } .game-resource-card.is-dragging { - opacity: 0.42; + z-index: 2; + opacity: 0.72; cursor: grabbing; -} - -.game-resource-card.is-drop-before { box-shadow: - -5px 0 0 -2px #cf7047, - 0 8px 22px rgb(195 105 62 / 15%); -} - -.game-resource-card.is-drop-after { - box-shadow: - 5px 0 0 -2px #cf7047, - 0 8px 22px rgb(195 105 62 / 15%); + 0 12px 28px rgb(195 105 62 / 24%), + 0 0 0 2px rgb(213 123 81 / 18%); } .game-resource-card-icon { @@ -4938,10 +4931,6 @@ iframe.preview-frame { min-width: 460px; } - .game-resource-row { - flex-wrap: wrap; - } - .game-run-slice-controls { grid-template-columns: repeat(3, minmax(0, 1fr)); } 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 9062762a7..dd5e99492 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -18,7 +18,6 @@ import { } from 'lucide-react'; import { type CSSProperties, - type DragEvent, type PointerEvent as ReactPointerEvent, type ReactNode, useCallback, @@ -36,11 +35,18 @@ import type { GameCreationAppManifest, GameCreationAppPreviewState, GameCreationAppTaskState, + ProjectResourceCanvasLayoutMode, + ProjectResourceCanvasSection, } from '../../../../../packages/shared/src/contracts/gameCreationApp'; import { LocalGamePreviewFrame, resolveEmbeddedPreviewUrl, } from '../../features/project-workspace/LocalGamePreviewFrame'; +import { + RESOURCE_CANVAS_DRAG_THRESHOLD, + resourceCanvasSectionExtent, +} from './resourceCanvasLayoutModel'; +import { useProjectResourceCanvasLayout } from './useProjectResourceCanvasLayout'; type AttachmentResult = { fileName: string; @@ -50,28 +56,31 @@ type AttachmentResult = { error?: string; }; -type ResourceCategory = 'document' | 'version' | 'art' | 'audio'; -type ResourceSortMode = 'dependency' | 'type'; +type ResourceCategory = ProjectResourceCanvasSection; +type ResourceSortMode = ProjectResourceCanvasLayoutMode; type WorkbenchMode = 'resources' | 'run'; type ApprovalMode = 'strict' | 'risk' | 'none'; -type ResourceOrderState = Record< - ResourceSortMode, - Record ->; - -type ResourceDropTarget = { - resourceId: string; - placement: 'before' | 'after'; -}; type Point = { x: number; y: number; }; +type ResourceCardDrag = { + pointerId: number; + resourceId: string; + section: ResourceCategory; + startClientX: number; + startClientY: number; + startX: number; + startY: number; + moved: boolean; +}; + type ProjectResource = { id: string; category: ResourceCategory; + subtype: string; label: string; path: string; mediaType: string; @@ -295,6 +304,7 @@ function resourcesFromProject( resources.push({ id: `task:${task.id}:${path}`, category, + subtype: 'task-artifact', label: fileName(path), path, mediaType: category === 'document' ? '项目文档' : '项目产物', @@ -316,6 +326,7 @@ function resourcesFromProject( resources.push({ id: `asset:${asset.id}`, category: categoryFromResource(asset.localPath, asset.mediaType), + subtype: asset.kind, label: `${fileName(asset.localPath)}${ isPendingUiPrototype ? '(待视觉验收)' : '' }`, @@ -345,6 +356,7 @@ function resourcesFromProject( attachment.localPath, attachment.mediaType, ), + subtype: 'attachment', label: attachment.fileName, path: attachment.localPath, mediaType: attachment.mediaType || '未知媒体类型', @@ -359,6 +371,7 @@ function resourcesFromProject( resources.push({ id: `agent-result:${result.agentId}:${result.runId}`, category: 'document', + subtype: 'agent-result', label: result.title, path: `专业 Agent 文本回执 · ${result.label}`, mediaType: 'Agent 历史文本回执', @@ -380,83 +393,6 @@ function resourcesFromProject( return Array.from(uniqueByPath.values()); } -function sortCategoryResources( - resources: ProjectResource[], - mode: ResourceSortMode, -) { - return [...resources].sort((left, right) => { - if (mode === 'dependency') { - return ( - left.dependencyDepth - right.dependencyDepth || - left.label.localeCompare(right.label, 'zh-CN') - ); - } - return left.label.localeCompare(right.label, 'zh-CN'); - }); -} - -function buildResourceOrder(resources: ProjectResource[]): ResourceOrderState { - return { - dependency: Object.fromEntries( - categoryOrder.map((category) => [ - category, - sortCategoryResources( - resources.filter((resource) => resource.category === category), - 'dependency', - ).map((resource) => resource.id), - ]), - ) as Record, - type: Object.fromEntries( - categoryOrder.map((category) => [ - category, - sortCategoryResources( - resources.filter((resource) => resource.category === category), - 'type', - ).map((resource) => resource.id), - ]), - ) as Record, - }; -} - -function reconcileResourceOrder( - current: ResourceOrderState, - resources: ProjectResource[], -) { - const next: ResourceOrderState = { - dependency: { - document: [...current.dependency.document], - version: [...current.dependency.version], - art: [...current.dependency.art], - audio: [...current.dependency.audio], - }, - type: { - document: [...current.type.document], - version: [...current.type.version], - art: [...current.type.art], - audio: [...current.type.audio], - }, - }; - for (const mode of ['dependency', 'type'] as const) { - for (const category of categoryOrder) { - const categoryResources = resources.filter( - (resource) => resource.category === category, - ); - const validIds = new Set( - categoryResources.map((resource) => resource.id), - ); - const preservedIds = current[mode][category].filter((id) => - validIds.has(id), - ); - const preservedSet = new Set(preservedIds); - const appendedIds = sortCategoryResources(categoryResources, mode) - .map((resource) => resource.id) - .filter((id) => !preservedSet.has(id)); - next[mode][category] = [...preservedIds, ...appendedIds]; - } - } - return next; -} - function summarizeAgent( manifest: GameCreationAppManifest, group: AgentSummary['group'], @@ -504,22 +440,24 @@ function ResourceCard({ resource, selected, dragging, - dropPlacement, + x, + y, onSelect, - onDragStart, - onDragOver, - onDrop, - onDragEnd, + onPointerDown, + onPointerMove, + onPointerUp, + onPointerCancel, }: { resource: ProjectResource; selected: boolean; dragging: boolean; - dropPlacement: ResourceDropTarget['placement'] | null; + x: number; + y: number; onSelect: () => void; - onDragStart: (event: DragEvent) => void; - onDragOver: (event: DragEvent) => void; - onDrop: (event: DragEvent) => void; - onDragEnd: () => void; + onPointerDown: (event: ReactPointerEvent) => void; + onPointerMove: (event: ReactPointerEvent) => void; + onPointerUp: (event: ReactPointerEvent) => void; + onPointerCancel: (event: ReactPointerEvent) => void; }) { const Icon = categoryIcons[resource.category]; return ( @@ -527,21 +465,21 @@ function ResourceCard({ type="button" className={`game-resource-card${selected ? ' is-selected' : ''}${ dragging ? ' is-dragging' : '' - }${dropPlacement ? ` is-drop-${dropPlacement}` : ''}`} + }`} aria-pressed={selected} - draggable data-resource-id={resource.id} - title="拖动可调整当前会话中的排列" + title="拖动调整资源位置" style={ { - '--resource-depth': Math.min(6, resource.dependencyDepth), + '--resource-x': `${x}px`, + '--resource-y': `${y}px`, } as CSSProperties } onClick={onSelect} - onDragStart={onDragStart} - onDragOver={onDragOver} - onDrop={onDrop} - onDragEnd={onDragEnd} + onPointerDown={onPointerDown} + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + onPointerCancel={onPointerCancel} >