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 fdc0c34f6..ebee36b45 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -4,6 +4,9 @@ use crate::ui_editor::resource::font::FontAsset; const UI_EDITOR_FONT_MAX_FILE_SIZE: u64 = 8 * 1024 * 1024; const UI_EDITOR_FONT_MAX_TOTAL_SIZE: u64 = 32 * 1024 * 1024; const UI_EDITOR_FONT_MAX_COUNT: usize = 64; +const UI_EDITOR_IMAGE_MAX_FILE_SIZE: u64 = 20 * 1024 * 1024; +const UI_EDITOR_IMAGE_MAX_TOTAL_SIZE: u64 = 256 * 1024 * 1024; +const UI_EDITOR_IMAGE_MAX_COUNT: usize = 100; #[derive(Clone, Debug, Deserialize)] #[serde(rename_all = "camelCase")] @@ -183,42 +186,12 @@ fn validate_remote_asset_import_requirements( if assets.len() > requirements.max_items { return Err(format!("最多导入 {} 个文件", requirements.max_items)); } - let mut total_size = 0u64; - for asset in assets { - let bytes = asset - .get("bytes") - .cloned() - .ok_or_else(|| "远端素材缺少文件内容".to_string()) - .and_then(|value| { - serde_json::from_value::>(value).map_err(|_| "远端素材内容无效".to_string()) - })?; - let size = bytes.len() as u64; - if let Some(max_size) = requirements.max_file_size_bytes { - if size > max_size { - return Err(format!("文件超过 {} 字节限制", max_size)); - } - } - total_size = total_size.saturating_add(size); - } - if let Some(max_total_size) = requirements.max_total_size_bytes { - if total_size > max_total_size { - return Err(format!("文件总量超过 {} 字节限制", max_total_size)); - } - } Ok(()) } #[tauri::command] pub(crate) async fn import_ui_editor_assets( request: UiEditorAssetImportRequest, -) -> Result { - tokio::task::spawn_blocking(move || import_ui_editor_assets_blocking(request)) - .await - .map_err(|error| format!("UI 编辑器素材导入任务意外终止:{error}"))? -} - -fn import_ui_editor_assets_blocking( - request: UiEditorAssetImportRequest, ) -> Result { match request { UiEditorAssetImportRequest::Local { @@ -229,15 +202,15 @@ fn import_ui_editor_assets_blocking( } => { let is_font = validate_asset_import_policy(&local_policy)?; validate_local_asset_import_requirements(&local_source_paths, &local_requirements)?; - if is_font { - import_ui_editor_local_fonts(project_path, local_source_paths) - } else { - import_ui_editor_local_files( - project_path, - local_source_paths, - local_requirements.max_file_size_bytes.unwrap_or(u64::MAX), - ) - } + tokio::task::spawn_blocking(move || { + if is_font { + import_ui_editor_local_fonts(project_path, local_source_paths) + } else { + import_ui_editor_local_files(project_path, local_source_paths) + } + }) + .await + .map_err(|error| format!("UI 编辑器素材导入任务意外终止:{error}"))? } UiEditorAssetImportRequest::Remote { project_path, @@ -250,11 +223,7 @@ fn import_ui_editor_assets_blocking( return Err("字体导入不支持云端素材".to_string()); } validate_remote_asset_import_requirements(&remote_assets, &remote_requirements)?; - import_ui_editor_remote_assets( - project_path, - remote_assets, - remote_requirements.max_file_size_bytes.unwrap_or(u64::MAX), - ) + import_ui_editor_remote_assets(project_path, remote_assets).await } } } @@ -1742,25 +1711,45 @@ pub(crate) async fn sync_canvas_project_assets( pub(crate) fn import_ui_editor_local_files( project_path: String, source_paths: Vec, - max_file_size: u64, ) -> Result { let root = Path::new(project_path.trim()); enforce_project_permission_policy(root, "asset.upload")?; let _lock = acquire_project_write_lock(root, "asset.upload")?; - advance_agent_runtime_project_revision_locked(root)?; + if source_paths.len() > UI_EDITOR_IMAGE_MAX_COUNT { + return Err(format!("一次最多选择 {UI_EDITOR_IMAGE_MAX_COUNT} 张图片")); + } // V1 有意采用增量导入:每个文件独立写入并登记,后续失败不回滚此前成功项。 // 调用方必须以返回结果和 manifest 为准重建已提交集合;整批原子语义需另行定义 transaction/reconciliation 合同。 let mut inputs = Vec::new(); + let mut total_size = 0u64; for source in source_paths { let path = Path::new(source.trim()); let metadata = fs::symlink_metadata(path).map_err(|e| format!("读取本地图片失败:{e}"))?; if metadata.file_type().is_symlink() || !metadata.is_file() { return Err("只能导入普通图片文件".to_string()); } - if metadata.len() > max_file_size { - return Err(format!("图片超过 {} 字节限制", max_file_size)); + if metadata.len() > UI_EDITOR_IMAGE_MAX_FILE_SIZE { + return Err(format!( + "图片超过 {} 字节限制", + UI_EDITOR_IMAGE_MAX_FILE_SIZE + )); } let bytes = fs::read(path).map_err(|e| format!("读取本地图片失败:{e}"))?; + if bytes.len() as u64 > UI_EDITOR_IMAGE_MAX_FILE_SIZE { + return Err(format!( + "图片超过 {} 字节限制", + UI_EDITOR_IMAGE_MAX_FILE_SIZE + )); + } + total_size = total_size + .checked_add(bytes.len() as u64) + .filter(|size| *size <= UI_EDITOR_IMAGE_MAX_TOTAL_SIZE) + .ok_or_else(|| { + format!( + "图片批次总量超过 {} 字节限制", + UI_EDITOR_IMAGE_MAX_TOTAL_SIZE + ) + })?; let media_type = if bytes.starts_with(b"\x89PNG\r\n\x1a\n") { "image/png" } else if bytes.starts_with(&[0xff, 0xd8, 0xff]) { @@ -1777,17 +1766,20 @@ pub(crate) fn import_ui_editor_local_files( .to_string(); inputs.push((name, media_type.to_string(), bytes)); } - let assets = inputs - .into_iter() - .map(|(name, media, bytes)| upload_local_asset_at(root, &name, &media, &bytes)) - .collect::, _>>()? - .into_iter() - .map(|asset| ImportedAsset { + let mut assets = Vec::with_capacity(inputs.len()); + for (name, media, bytes) in inputs { + let asset = upload_local_asset_at(root, &name, &media, &bytes)?; + // 参考 save_ui_design_state_at:只有文件和 manifest 写入成功后才推进 revision。 + // 每个成功项目独立提交,后续失败不会让已落盘项目停留在旧 revision。 + advance_agent_runtime_project_revision_locked(root).map_err(|error| { + format!("reconciliation-required: 图片已导入,但项目 revision 未能推进:{error}") + })?; + assets.push(ImportedAsset { id: asset.id, local_path: asset.local_path, asset_kind: None, - }) - .collect(); + }); + } Ok(LocalImportResult { assets }) } @@ -1945,7 +1937,6 @@ pub(crate) fn import_ui_editor_local_fonts( } if !new_inputs.is_empty() { - advance_agent_runtime_project_revision_locked(root)?; let font_root = root.join("assets/fonts"); fs::create_dir_all(&font_root).map_err(|_| "创建项目字体目录失败".to_string())?; } @@ -1996,6 +1987,10 @@ pub(crate) fn import_ui_editor_local_fonts( )?; existing_by_hash.insert(font.content_sha256.clone(), font.clone()); result.push(font); + // 参考 save_ui_design_state_at:登记成功后才推进 revision;失败项不会伪造提交版本。 + advance_agent_runtime_project_revision_locked(root).map_err(|error| { + format!("reconciliation-required: 字体已复制并登记,但项目 revision 未能推进:{error}") + })?; } Ok(LocalImportResult { assets: result @@ -2210,29 +2205,35 @@ mod ui_editor_font_tests { pub(crate) fn import_ui_editor_remote_assets( project_path: String, assets: Vec, - max_file_size: u64, ) -> Result { let root = Path::new(project_path.trim()); enforce_project_permission_policy(root, "canvas.asset_import")?; let _lock = acquire_project_write_lock(root, "canvas.asset_import")?; - advance_agent_runtime_project_revision_locked(root)?; + if assets.len() > UI_EDITOR_IMAGE_MAX_COUNT { + return Err(format!("一次最多选择 {UI_EDITOR_IMAGE_MAX_COUNT} 张图片")); + } // 远程素材导入保持与本地图片/字体相同的增量语义,不对已成功项目文件做整批回滚。 - let mut downloads = Vec::new(); + let mut total_size = 0u64; + let mut imported = Vec::with_capacity(assets.len()); for asset in assets { let asset_id = json_string_field(&asset, "assetId") .or_else(|| json_string_field(&asset, "objectKey")) .ok_or_else(|| "平台素材缺少稳定 assetId/objectKey,拒绝导入".to_string())?; - let bytes = asset - .get("bytes") - .cloned() - .ok_or_else(|| format!("平台素材 {asset_id} 缺少图片内容")) - .and_then(|value| { - serde_json::from_value::>(value) - .map_err(|_| format!("平台素材 {asset_id} 图片内容无效")) + // 参考资源同步流程:IPC 只传稳定引用/签名 URL,由 Rust 流式下载并限制响应体, + // 避免把不受控的 Vec 在 IPC 反序列化阶段一次性放入内存。 + let download_url = json_string_field(&asset, "downloadUrl") + .ok_or_else(|| format!("平台素材 {asset_id} 缺少 downloadUrl"))?; + let remaining = UI_EDITOR_IMAGE_MAX_TOTAL_SIZE.saturating_sub(total_size); + let bytes = download_ui_editor_remote_asset(&download_url, remaining).await?; + total_size = total_size + .checked_add(bytes.len() as u64) + .filter(|size| *size <= UI_EDITOR_IMAGE_MAX_TOTAL_SIZE) + .ok_or_else(|| { + format!( + "图片批次总量超过 {} 字节限制", + UI_EDITOR_IMAGE_MAX_TOTAL_SIZE + ) })?; - if bytes.len() as u64 > max_file_size { - return Err(format!("图片超过 {} 字节限制", max_file_size)); - } let media_type = if bytes.starts_with(b"\x89PNG\r\n\x1a\n") { "image/png" } else if bytes.starts_with(&[0xff, 0xd8, 0xff]) { @@ -2242,10 +2243,6 @@ pub(crate) fn import_ui_editor_remote_assets( } else { return Err("平台素材不是受支持的 PNG/JPEG/WEBP 图片".to_string()); }; - downloads.push((asset, asset_id, media_type.to_string(), bytes)); - } - let mut imported = Vec::new(); - for (asset, asset_id, media_type, bytes) in downloads { let extension = infer_file_extension( json_string_field(&asset, "objectKey") .or_else(|| json_string_field(&asset, "imageSrc")) @@ -2283,10 +2280,63 @@ pub(crate) fn import_ui_editor_remote_assets( local_path: registered.local_path, asset_kind: json_string_field(&asset, "assetKind"), }); + // 参考 save_ui_design_state_at:远程文件与 manifest 成功后才推进 revision。 + advance_agent_runtime_project_revision_locked(root).map_err(|error| { + format!("reconciliation-required: 远程素材已导入,但项目 revision 未能推进:{error}") + })?; } Ok(RemoteImportResult { assets: imported }) } +async fn download_ui_editor_remote_asset(url: &str, max_bytes: u64) -> Result, String> { + if max_bytes == 0 { + return Err("图片批次总量已达到服务端限制".to_string()); + } + let parsed = validate_external_asset_download_url(url, "", false)?; + let client = build_external_asset_download_client(&parsed, "", false).await?; + let mut response = client + .get(parsed) + .send() + .await + .map_err(|error| format!("下载平台素材失败:{error}"))?; + if response.status().is_redirection() { + return Err("平台素材下载地址发生重定向,已拒绝继续请求".to_string()); + } + if !response.status().is_success() { + return Err(format!( + "下载平台素材失败:HTTP {}", + response.status().as_u16() + )); + } + if response + .content_length() + .is_some_and(|size| size > UI_EDITOR_IMAGE_MAX_FILE_SIZE || size > max_bytes) + { + return Err(format!( + "平台素材超过单文件 {} 或批次剩余 {} 字节限制", + UI_EDITOR_IMAGE_MAX_FILE_SIZE, max_bytes + )); + } + let capacity = response + .content_length() + .and_then(|size| usize::try_from(size).ok()) + .unwrap_or_default() + .min(UI_EDITOR_IMAGE_MAX_FILE_SIZE as usize); + let mut bytes = Vec::with_capacity(capacity); + while let Some(chunk) = response + .chunk() + .await + .map_err(|error| format!("读取平台素材失败:{error}"))? + { + let next_size = bytes.len().saturating_add(chunk.len()); + if next_size as u64 > UI_EDITOR_IMAGE_MAX_FILE_SIZE || next_size as u64 > max_bytes { + return Err("平台素材超过服务端下载限制".to_string()); + } + bytes.extend_from_slice(&chunk); + } + Ok(bytes) +} + #[tauri::command] pub(crate) async fn generate_platform_art_asset( project_path: String, diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/persistence.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/persistence.rs index b3e735359..4992ea849 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/persistence.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/persistence.rs @@ -329,8 +329,9 @@ fn serialize_ui_design_document(document: &PersistedUiDesignState) -> Result.` file. fn write_ui_design_raw_file(path: &Path, label: &str, bytes: &[u8]) -> Result<(), String> { if bytes.len() > UI_DESIGN_STATE_MAX_BYTES { return Err(format!("{label} 超过 {UI_DESIGN_STATE_MAX_BYTES} 字节上限")); @@ -350,8 +351,7 @@ fn write_ui_design_raw_file(path: &Path, label: &str, bytes: &[u8]) -> Result<() .map_err(|error| format!("生成 {label} 临时文件名失败:{error}"))? .as_nanos(); let temp_path = path.with_file_name(format!(".{name}.tmp.{}.{}", std::process::id(), nonce)); - let replacement_path = - path.with_file_name(format!(".{name}.replace.{}.{}", std::process::id(), nonce)); + let replacement_path = agent_runtime_json_sidecar_backup_path(path); let mut options = fs::OpenOptions::new(); options.write(true).create_new(true); #[cfg(unix)] @@ -377,6 +377,8 @@ fn write_ui_design_raw_file(path: &Path, label: &str, bytes: &[u8]) -> Result<() drop(file); match fs::rename(&temp_path, path) { + // `.previous` is prepared by write_ui_design_document before this call; + // preserve it on the normal atomic-rename path for startup recovery. Ok(()) => {} Err(first_error) => { let metadata = fs::symlink_metadata(path).map_err(|error| { @@ -387,6 +389,9 @@ fn write_ui_design_raw_file(path: &Path, label: &str, bytes: &[u8]) -> Result<() let _ = fs::remove_file(&temp_path); return Err(format!("{label} 必须是普通文件")); } + // 与 agent/runtime_protocol/json_sidecar.rs 的通用写入器保持一致: + // 先清理旧恢复副本,再把旧主文件放到稳定 `.previous`,使崩溃窗口可恢复。 + remove_agent_runtime_json_sidecar_backup(&replacement_path, label)?; fs::rename(path, &replacement_path).map_err(|error| { let _ = fs::remove_file(&temp_path); format!("准备替换 {label} 失败:{first_error};{error}") @@ -399,8 +404,7 @@ fn write_ui_design_raw_file(path: &Path, label: &str, bytes: &[u8]) -> Result<() .unwrap_or_default(); return Err(format!("替换 {label} 失败:{error}{restore_detail}")); } - fs::remove_file(&replacement_path) - .map_err(|error| format!("清理 {label} 替换副本失败:{error}"))?; + remove_agent_runtime_json_sidecar_backup(&replacement_path, label)?; } } #[cfg(unix)] diff --git a/apps/ai-game-creator-shell/src/components/AssetImporter/index.tsx b/apps/ai-game-creator-shell/src/components/AssetImporter/index.tsx index d166d560a..431fabd9e 100644 --- a/apps/ai-game-creator-shell/src/components/AssetImporter/index.tsx +++ b/apps/ai-game-creator-shell/src/components/AssetImporter/index.tsx @@ -8,7 +8,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { loadEditorAssetLibrary, - readClientAssetBytes, + resolveClientAssetReadUrl, } from '../../services/clientApi'; import { ThemedModal } from '../modal/ThemedModal'; import { @@ -223,8 +223,9 @@ export function AssetImporter({ }); const sourcePaths = Array.isArray(paths) ? paths : paths ? [paths] : []; if (!sourcePaths.length) return; - if (sourcePaths.length > maxItems) + if (sourcePaths.length > maxItems) { throw new Error(`最多选择 ${maxItems} 个文件`); + } const result = await invoke( 'import_ui_editor_assets', { @@ -271,18 +272,13 @@ export function AssetImporter({ remoteAssets: await Promise.all( remoteChosen.map(async (item) => { const asset = item.asset!; - const response = asset.localPath.startsWith('http') - ? await fetch(asset.localPath) - : await readClientAssetBytes(asset.localPath); - if (!response.ok) - throw new Error( - `读取平台素材失败(HTTP ${response.status})`, - ); - const bytes = new Uint8Array(await response.arrayBuffer()); + const downloadUrl = asset.localPath.startsWith('http') + ? asset.localPath + : await resolveClientAssetReadUrl(asset.localPath); return { assetId: asset.id, objectKey: asset.localPath, - bytes: Array.from(bytes), + downloadUrl, }; }), ),