From 37b15ffe9c6028a33c00f5f312435b5535037fe5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 17 Aug 2026 17:38:07 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0UI=E7=BC=96=E8=BE=91=E5=99=A8?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E6=8C=81=E4=B9=85=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 UI State 严格文件契约、CAS 保存与恢复副本 接入 Tauri 加载保存与项目资源创建事务 完善编辑器加载失败、保存冲突和预览恢复行为 补充持久化测试与工程约定文档 --- .../src-tauri/src/commands.rs | 57 +- .../src-tauri/src/main.rs | 20 + .../src-tauri/src/ui_editor/mod.rs | 1 + .../src-tauri/src/ui_editor/persistence.rs | 718 ++++++++++++++++++ .../src/features/ui-editor/AGENTS.md | 7 +- .../features/ui-editor/uiDesignStateStore.ts | 64 +- .../src/view/project-development/index.tsx | 1 + .../src/view/ui-editor/index.tsx | 103 ++- .../src/view/ui-editor/useUiEditorPage.ts | 90 ++- .../tests/uiDesignStateStore.test.ts | 67 +- ...¹案】AI游戏创作智能体App实施计划-2026-06-24.md | 7 + 11 files changed, 1062 insertions(+), 73 deletions(-) create mode 100644 apps/ai-game-creator-shell/src-tauri/src/ui_editor/persistence.rs 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 16679b08f..cbd4a3636 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -1277,12 +1277,12 @@ pub(crate) fn create_ui_design_resource( fs::create_dir_all(parent) .map_err(|error| format!("创建 UI 资源目录失败:{}: {error}", parent.display()))?; } - if !absolute_path.exists() { - fs::write(&absolute_path, "{}") - .map_err(|error| format!("创建 UI 资源失败:{}: {error}", absolute_path.display()))?; + if absolute_path.exists() { + return Err("UI 设计资源路径已存在,拒绝覆盖".to_string()); } - advance_agent_runtime_project_revision_locked(root)?; - let asset = register_local_asset_at( + fs::write(&absolute_path, "") + .map_err(|error| format!("创建 UI 资源失败:{}: {error}", absolute_path.display()))?; + let asset = match register_local_asset_at( root, &relative_path, "UI", @@ -1300,7 +1300,52 @@ pub(crate) fn create_ui_design_resource( generation_kind: None, reference_resource_ids: Vec::new(), }, - )?; + ) { + Ok(asset) => asset, + Err(error) => { + let rollback = (|| { + write_manifest(&root.join(".agent/manifest.json"), &manifest)?; + fs::remove_file(&absolute_path).map_err(|remove_error| { + format!( + "删除未完成 UI 设计资源失败:{}: {remove_error}", + absolute_path.display() + ) + }) + })(); + return match rollback { + Ok(()) => Err(error), + Err(rollback_error) => Err(format!( + "UI 设计资源登记失败:{error};reconciliation-required: 回滚未完成:{rollback_error}" + )), + }; + } + }; + if let Err(error) = ui_editor::persistence::initialize_ui_design_state_at( + root, + expected_project_id.trim(), + &asset.id, + ) { + let rollback = (|| { + let mut current = read_existing_manifest_for_project(root)?; + current.assets.retain(|entry| entry.id != asset.id); + write_manifest(&root.join(".agent/manifest.json"), ¤t)?; + fs::remove_file(&absolute_path).map_err(|remove_error| { + format!( + "删除未完成 UI 设计资源失败:{}: {remove_error}", + absolute_path.display() + ) + }) + })(); + return match rollback { + Ok(()) => Err(error), + Err(rollback_error) => Err(format!( + "UI 设计资源初始化失败:{error};reconciliation-required: 回滚未完成:{rollback_error}" + )), + }; + } + advance_agent_runtime_project_revision_locked(root).map_err(|error| { + format!("reconciliation-required: UI 设计资源已创建,但项目 revision 未能推进:{error}") + })?; let manifest = read_existing_manifest_for_project(root)?; let revision = read_game_creator_agent_runtime_project_revision(root)?.revision; Ok(CreateUiDesignResourceResult { 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 ada7c08ee..bffc2830c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -140,6 +140,24 @@ async fn bind_components( ui_editor::commands::bind_components_impl(project_path, state, sprite_ids).await } +#[tauri::command] +fn load_ui_design_state( + input: ui_editor::persistence::LoadUiDesignStateInput, +) -> Result { + let root = Path::new(input.project_path.trim()); + enforce_project_permission_policy(root, "asset.list")?; + ui_editor::persistence::load_ui_design_state_at(input) +} + +#[tauri::command] +fn save_ui_design_state( + input: ui_editor::persistence::SaveUiDesignStateInput, +) -> Result { + let root = Path::new(input.project_path.trim()); + enforce_project_permission_policy(root, "asset.register")?; + ui_editor::persistence::save_ui_design_state_at(input) +} + #[derive(Debug, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] struct InitLocalProjectResult { @@ -2286,6 +2304,8 @@ fn main() { recognize_ui, merge_ui, bind_components, + load_ui_design_state, + save_ui_design_state, generate_platform_art_asset, open_canvas_project, get_game_creation_agent_capabilities, diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/mod.rs index 55d2ae133..08848ee92 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/mod.rs @@ -1,6 +1,7 @@ pub mod commands; pub mod component; pub mod layout; +pub mod persistence; pub mod resource; pub mod state; mod utils; 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 new file mode 100644 index 000000000..efb9e8dae --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/persistence.rs @@ -0,0 +1,718 @@ +use crate::ui_editor::component::Component; +use crate::ui_editor::layout::node::Node; +use crate::ui_editor::state::State; +use crate::*; +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; +use std::fs::{self, File}; +use std::io::{Read, Write}; +use std::path::Path; +use std::time::{SystemTime, UNIX_EPOCH}; + +const UI_DESIGN_STATE_SCHEMA_VERSION: &str = "game-creator-ui-design-state.v1"; +const UI_DESIGN_STATE_MAX_BYTES: usize = 2 * 1024 * 1024; +const UI_DESIGN_STATE_MAX_IMAGES: usize = 4; +const UI_DESIGN_STATE_MAX_SPRITES: usize = 1_024; +const UI_DESIGN_STATE_MAX_NODES: usize = 10_000; +const UI_DESIGN_STATE_MAX_DEPTH: usize = 128; +const UI_DESIGN_STATE_MAX_COMPONENTS_PER_NODE: usize = 64; +const UI_DESIGN_STATE_MAX_SAFE_REVISION: u64 = 9_007_199_254_740_991; + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct PersistedUiDesignState { + schema_version: String, + project_id: String, + asset_id: String, + revision: u64, + state: State, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct LoadUiDesignStateInput { + pub(crate) project_path: String, + pub(crate) expected_project_id: String, + pub(crate) asset_id: String, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct SaveUiDesignStateInput { + pub(crate) project_path: String, + pub(crate) expected_project_id: String, + pub(crate) asset_id: String, + pub(crate) expected_revision: u64, + pub(crate) state: State, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct UiDesignStateSnapshot { + pub(crate) revision: u64, + pub(crate) state: State, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(tag = "status", rename_all = "kebab-case")] +pub(crate) enum SaveUiDesignStateResult { + Saved { + state: State, + revision: u64, + committed_project_revision: u64, + }, + Unchanged { + state: State, + revision: u64, + committed_project_revision: u64, + }, + Conflict { + current: UiDesignStateSnapshot, + }, +} + +pub(crate) fn initialize_ui_design_state_at( + root: &Path, + project_id: &str, + asset_id: &str, +) -> Result<(), String> { + let asset = ui_design_asset(root, project_id, asset_id)?; + let document = empty_document(project_id, asset_id); + write_ui_design_document(root, &asset.local_path, &document)?; + let installed = read_ui_design_document(root, &asset.local_path, project_id, asset_id)?; + validate_document(&installed, project_id, asset_id)?; + Ok(()) +} + +pub(crate) fn load_ui_design_state_at( + input: LoadUiDesignStateInput, +) -> Result { + let root = Path::new(input.project_path.trim()); + let expected_project_id = required_identifier(&input.expected_project_id, "expectedProjectId")?; + let asset_id = required_identifier(&input.asset_id, "assetId")?; + let asset = ui_design_asset(root, &expected_project_id, &asset_id)?; + let document = + read_ui_design_document(root, &asset.local_path, &expected_project_id, &asset_id)?; + validate_document(&document, &expected_project_id, &asset_id)?; + Ok(UiDesignStateSnapshot { + revision: document.revision, + state: document.state, + }) +} + +pub(crate) fn save_ui_design_state_at( + input: SaveUiDesignStateInput, +) -> Result { + if input.expected_revision > UI_DESIGN_STATE_MAX_SAFE_REVISION { + return Err("UI 设计 State revision 超出 JavaScript 安全整数范围".to_string()); + } + validate_state(&input.state)?; + let root = Path::new(input.project_path.trim()); + let expected_project_id = required_identifier(&input.expected_project_id, "expectedProjectId")?; + let asset_id = required_identifier(&input.asset_id, "assetId")?; + + let preflight_asset = ui_design_asset(root, &expected_project_id, &asset_id)?; + let _lock = acquire_project_write_lock(root, "ui_design.state_save")?; + let asset = ui_design_asset(root, &expected_project_id, &asset_id)?; + if asset.local_path != preflight_asset.local_path { + return Err("UI 设计资源在保存锁获取期间发生变化,请重试".to_string()); + } + let current = + read_ui_design_document(root, &asset.local_path, &expected_project_id, &asset_id)?; + validate_document(¤t, &expected_project_id, &asset_id)?; + if current.revision != input.expected_revision { + return Ok(SaveUiDesignStateResult::Conflict { + current: UiDesignStateSnapshot { + revision: current.revision, + state: current.state, + }, + }); + } + if current.state == input.state { + let committed_project_revision = + read_game_creator_agent_runtime_project_revision(root)?.revision; + return Ok(SaveUiDesignStateResult::Unchanged { + state: current.state, + revision: current.revision, + committed_project_revision, + }); + } + if current.revision == UI_DESIGN_STATE_MAX_SAFE_REVISION { + return Err( + "UI 设计 State revision 已达到 JavaScript 安全整数上限,无法继续保存".to_string(), + ); + } + let next = PersistedUiDesignState { + schema_version: UI_DESIGN_STATE_SCHEMA_VERSION.to_string(), + project_id: expected_project_id.clone(), + asset_id: asset_id.clone(), + revision: current.revision + 1, + state: input.state, + }; + validate_document(&next, &expected_project_id, &asset_id)?; + write_ui_design_document(root, &asset.local_path, &next)?; + let installed = + read_ui_design_document(root, &asset.local_path, &expected_project_id, &asset_id)?; + validate_document(&installed, &expected_project_id, &asset_id)?; + if installed != next { + return Err("UI 设计 State 安装后回读与待写内容不一致".to_string()); + } + let committed_project_revision = + advance_agent_runtime_project_revision_locked(root).map_err(|error| { + format!( + "reconciliation-required: UI 设计 State 已保存,但项目 revision 未能推进:{error}" + ) + })?; + Ok(SaveUiDesignStateResult::Saved { + state: installed.state, + revision: installed.revision, + committed_project_revision, + }) +} + +fn empty_document(project_id: &str, asset_id: &str) -> PersistedUiDesignState { + PersistedUiDesignState { + schema_version: UI_DESIGN_STATE_SCHEMA_VERSION.to_string(), + project_id: project_id.to_string(), + asset_id: asset_id.to_string(), + revision: 0, + state: State { + ui_trees: Vec::new(), + ui_design_images: Default::default(), + sprite_assets: Default::default(), + font_assets: Default::default(), + }, + } +} + +fn required_identifier(value: &str, label: &str) -> Result { + let value = value.trim(); + if value.is_empty() || value.chars().any(char::is_control) { + return Err(format!("UI 设计 State {label} 无效")); + } + Ok(value.to_string()) +} + +fn ui_design_asset( + root: &Path, + expected_project_id: &str, + asset_id: &str, +) -> Result { + let manifest = read_existing_manifest_for_project(root)?; + if manifest.project_id != expected_project_id { + return Err("UI 设计 State expectedProjectId 与当前项目不匹配".to_string()); + } + let asset = manifest + .assets + .into_iter() + .find(|asset| asset.id == asset_id) + .ok_or_else(|| "UI 设计资源不存在".to_string())?; + if asset.kind != "UI" || asset.media_type != "application/json" { + return Err("目标资源不是 UI 设计 JSON 资产".to_string()); + } + normalize_relative_path(&asset.local_path)?; + Ok(asset) +} + +fn read_ui_design_document( + root: &Path, + relative_path: &str, + project_id: &str, + asset_id: &str, +) -> Result { + let primary_path = resolve_local_project_path(root, relative_path)?; + let backup_path = agent_runtime_json_sidecar_backup_path(&primary_path); + let read_candidate = |path: &Path| { + read_ui_design_document_path(path).and_then(|document| { + validate_document(&document, project_id, asset_id)?; + Ok(document) + }) + }; + match read_candidate(&primary_path) { + Ok(document) => Ok(document), + Err(primary_error) => match read_candidate(&backup_path) { + Ok(document) => { + write_ui_design_document(root, relative_path, &document)?; + Ok(document) + } + Err(_) => Err(primary_error), + }, + } +} + +fn read_ui_design_document_path(path: &Path) -> Result { + let (mut file, metadata) = open_project_snapshot_regular_file(path, "UI 设计 State")?; + if metadata.len() > UI_DESIGN_STATE_MAX_BYTES as u64 { + return Err(format!( + "UI 设计 State 超过 {UI_DESIGN_STATE_MAX_BYTES} 字节上限" + )); + } + let mut bytes = Vec::with_capacity(metadata.len() as usize); + Read::by_ref(&mut file) + .take((UI_DESIGN_STATE_MAX_BYTES + 1) as u64) + .read_to_end(&mut bytes) + .map_err(|error| format!("读取 UI 设计 State 失败:{}: {error}", path.display()))?; + if bytes.len() > UI_DESIGN_STATE_MAX_BYTES { + return Err(format!( + "UI 设计 State 超过 {UI_DESIGN_STATE_MAX_BYTES} 字节上限" + )); + } + let value: serde_json::Value = serde_json::from_slice(&bytes) + .map_err(|error| format!("解析 UI 设计 State 失败:{}: {error}", path.display()))?; + let document: PersistedUiDesignState = serde_json::from_value(value.clone()) + .map_err(|error| format!("解析 UI 设计 State 契约失败:{}: {error}", path.display()))?; + let canonical = serde_json::to_value(&document) + .map_err(|error| format!("序列化 UI 设计 State 契约失败:{error}"))?; + if value != canonical { + return Err("UI 设计 State 包含不受当前 schema 支持的字段或值".to_string()); + } + Ok(document) +} + +fn write_ui_design_document( + root: &Path, + relative_path: &str, + document: &PersistedUiDesignState, +) -> Result<(), String> { + let primary_path = resolve_local_project_path(root, relative_path)?; + let backup_path = agent_runtime_json_sidecar_backup_path(&primary_path); + // Only a valid primary becomes a recovery copy. In particular, recovery + // must not replace the last known-good copy with a corrupt primary. + if let Ok(previous) = read_ui_design_document_path(&primary_path) { + write_ui_design_raw_file( + &backup_path, + "UI 设计 State 恢复副本", + &serialize_ui_design_document(&previous)?, + )?; + } + write_ui_design_raw_file( + &primary_path, + "UI 设计 State", + &serialize_ui_design_document(document)?, + ) +} + +fn serialize_ui_design_document(document: &PersistedUiDesignState) -> Result, String> { + let mut content = serde_json::to_vec_pretty(document) + .map_err(|error| format!("序列化 UI 设计 State 失败:{error}"))?; + content.push(b'\n'); + if content.len() > UI_DESIGN_STATE_MAX_BYTES { + return Err(format!( + "UI 设计 State 超过 {UI_DESIGN_STATE_MAX_BYTES} 字节上限" + )); + } + Ok(content) +} + +/// Installs a single JSON document without touching the sibling `.previous` +/// recovery copy. The shape follows the project's sidecar atomic-write +/// discipline while keeping a durable, non-recursive recovery generation. +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} 字节上限")); + } + let parent = path.parent().ok_or_else(|| format!("{label} 缺少父目录"))?; + fs::create_dir_all(parent).map_err(|error| format!("创建 {label} 目录失败:{error}"))?; + if fs::symlink_metadata(path).is_ok() { + open_project_snapshot_regular_file(path, label)?; + } + + let name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("ui-design-state.json"); + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .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 mut options = fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_NOFOLLOW).mode(0o600); + } + let mut file = options.open(&temp_path).map_err(|error| { + format!( + "创建 {label} 临时文件失败:{}: {error}", + temp_path.display() + ) + })?; + file.write_all(bytes) + .and_then(|_| file.sync_data()) + .map_err(|error| { + let _ = fs::remove_file(&temp_path); + format!( + "写入 {label} 临时文件失败:{}: {error}", + temp_path.display() + ) + })?; + drop(file); + + match fs::rename(&temp_path, path) { + Ok(()) => {} + Err(first_error) => { + let metadata = fs::symlink_metadata(path).map_err(|error| { + let _ = fs::remove_file(&temp_path); + format!("替换 {label} 失败:{first_error};读取原文件失败:{error}") + })?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + let _ = fs::remove_file(&temp_path); + return Err(format!("{label} 必须是普通文件")); + } + fs::rename(path, &replacement_path).map_err(|error| { + let _ = fs::remove_file(&temp_path); + format!("准备替换 {label} 失败:{first_error};{error}") + })?; + if let Err(error) = fs::rename(&temp_path, path) { + let restore_error = fs::rename(&replacement_path, path).err(); + let _ = fs::remove_file(&temp_path); + let restore_detail = restore_error + .map(|restore| format!(";恢复旧文件失败:{restore}")) + .unwrap_or_default(); + return Err(format!("替换 {label} 失败:{error}{restore_detail}")); + } + fs::remove_file(&replacement_path) + .map_err(|error| format!("清理 {label} 替换副本失败:{error}"))?; + } + } + #[cfg(unix)] + File::open(parent) + .and_then(|directory| directory.sync_all()) + .map_err(|error| format!("同步 {label} 目录失败:{}: {error}", parent.display()))?; + Ok(()) +} + +fn validate_document( + document: &PersistedUiDesignState, + project_id: &str, + asset_id: &str, +) -> Result<(), String> { + if document.schema_version != UI_DESIGN_STATE_SCHEMA_VERSION { + return Err(format!( + "不支持的 UI 设计 State schema:{}", + document.schema_version + )); + } + if document.project_id != project_id || document.asset_id != asset_id { + return Err("UI 设计 State 身份与当前资源不匹配".to_string()); + } + if document.revision > UI_DESIGN_STATE_MAX_SAFE_REVISION { + return Err("UI 设计 State revision 超出 JavaScript 安全整数范围".to_string()); + } + validate_state(&document.state) +} + +fn validate_state(state: &State) -> Result<(), String> { + if state.ui_design_images.len() > UI_DESIGN_STATE_MAX_IMAGES { + return Err(format!( + "UI 设计最多支持 {UI_DESIGN_STATE_MAX_IMAGES} 张界面图" + )); + } + if state.sprite_assets.len() > UI_DESIGN_STATE_MAX_SPRITES { + return Err(format!( + "UI 设计最多支持 {UI_DESIGN_STATE_MAX_SPRITES} 项独立素材" + )); + } + for (id, image) in &state.ui_design_images { + validate_id(id.as_str(), "界面图 ID")?; + normalize_relative_path(&image.path)?; + if !image + .pixel_size + .iter() + .all(|value| value.is_finite() && *value > 0.0) + { + return Err("界面图像素尺寸必须为正有限数值".to_string()); + } + if image + .metadata + .slave_to + .as_ref() + .is_some_and(|owner| !state.ui_design_images.contains_key(owner)) + { + return Err("界面图 slaveTo 引用了不存在的界面图".to_string()); + } + } + for (id, sprite) in &state.sprite_assets { + validate_id(id.as_str(), "独立素材 ID")?; + if sprite.asset_id != *id { + return Err("独立素材 map key 与 assetId 不一致".to_string()); + } + normalize_relative_path(&sprite.path)?; + if !sprite + .pixel_size + .iter() + .all(|value| value.is_finite() && *value > 0.0) + { + return Err("独立素材像素尺寸必须为正有限数值".to_string()); + } + } + let mut node_ids = HashSet::new(); + let mut node_count = 0usize; + for tree in &state.ui_trees { + if !state.ui_design_images.contains_key(&tree.src_ui_design) { + return Err("UI 树引用了不存在的界面图".to_string()); + } + validate_node(&tree.root, state, 1, &mut node_count, &mut node_ids)?; + } + Ok(()) +} + +fn validate_node( + node: &Node, + state: &State, + depth: usize, + node_count: &mut usize, + node_ids: &mut HashSet, +) -> Result<(), String> { + if depth > UI_DESIGN_STATE_MAX_DEPTH { + return Err(format!("UI 树最大深度不能超过 {UI_DESIGN_STATE_MAX_DEPTH}")); + } + *node_count += 1; + if *node_count > UI_DESIGN_STATE_MAX_NODES { + return Err(format!( + "UI 设计最多支持 {UI_DESIGN_STATE_MAX_NODES} 个节点" + )); + } + validate_id(node.id.as_str(), "节点 ID")?; + if !node_ids.insert(node.id.as_str().to_string()) { + return Err("UI 设计节点 ID 不能重复".to_string()); + } + if !node + .transform + .anchor_min + .iter() + .all(|value| value.is_finite()) + || !node + .transform + .anchor_max + .iter() + .all(|value| value.is_finite()) + || !node + .transform + .offset_min + .iter() + .all(|value| value.is_finite()) + || !node + .transform + .offset_max + .iter() + .all(|value| value.is_finite()) + { + return Err("UI 节点 Transform 必须是有限数值".to_string()); + } + if node.components.len() > UI_DESIGN_STATE_MAX_COMPONENTS_PER_NODE { + return Err(format!( + "单个 UI 节点最多支持 {UI_DESIGN_STATE_MAX_COMPONENTS_PER_NODE} 个组件" + )); + } + for component in &node.components { + if let Component::Image(image) = component { + if image + .target_graphic + .as_ref() + .is_some_and(|id| !state.sprite_assets.contains_key(id)) + { + return Err("Image 组件引用了不存在的独立素材".to_string()); + } + } + } + for child in &node.children { + validate_node(child, state, depth + 1, node_count, node_ids)?; + } + Ok(()) +} + +fn validate_id(value: &str, label: &str) -> Result<(), String> { + if value.is_empty() || value.trim() != value || value.chars().any(char::is_control) { + return Err(format!("{label} 无效")); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + const PROJECT_ID: &str = "ui-design-persistence-project"; + + fn fixture() -> (tempfile::TempDir, String) { + let directory = tempfile::tempdir().expect("create UI design persistence fixture"); + init_local_game_project_at(directory.path(), PROJECT_ID, "UI 设计持久化测试") + .expect("init project"); + let relative_path = "ui/design.json"; + let absolute_path = resolve_local_project_path(directory.path(), relative_path) + .expect("resolve UI design path"); + fs::create_dir_all(absolute_path.parent().expect("UI design parent")) + .expect("create UI design parent"); + fs::write(&absolute_path, b"").expect("create UI design file"); + let asset = register_local_asset_at( + directory.path(), + relative_path, + "UI", + "application/json", + "test", + GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Generated, + canvas_project_id: None, + resource_id: Some("ui:test".to_string()), + asset_object_id: None, + task_id: None, + prompt: None, + model: None, + generation_route: None, + generation_kind: None, + reference_resource_ids: Vec::new(), + }, + ) + .expect("register UI design asset"); + initialize_ui_design_state_at(directory.path(), PROJECT_ID, &asset.id) + .expect("initialize UI design state"); + (directory, asset.id) + } + + fn input(root: &Path, asset_id: &str, revision: u64, state: State) -> SaveUiDesignStateInput { + SaveUiDesignStateInput { + project_path: root.to_string_lossy().into_owned(), + expected_project_id: PROJECT_ID.to_string(), + asset_id: asset_id.to_string(), + expected_revision: revision, + state, + } + } + + fn state_with_unavailable_image(path: &str) -> State { + serde_json::from_value(serde_json::json!({ + "ui_trees": [], + "ui_design_images": { + "page": { + "metadata": { + "name": "主界面", + "description": "", + "role": "Page", + "slave_to": null + }, + "path": path, + "pixel_size": [1280.0, 720.0], + "pixels_per_unit": 1.0 + } + }, + "sprite_assets": {}, + "font_assets": {} + })) + .expect("deserialize valid UI design state") + } + + #[test] + fn saves_loads_and_compares_per_resource_revisions() { + let (directory, asset_id) = fixture(); + let initial = load_ui_design_state_at(LoadUiDesignStateInput { + project_path: directory.path().to_string_lossy().into_owned(), + expected_project_id: PROJECT_ID.to_string(), + asset_id: asset_id.clone(), + }) + .expect("load initialized state"); + assert_eq!(initial.revision, 0); + + let state = state_with_unavailable_image("assets/missing-reference.png"); + let saved = save_ui_design_state_at(input(directory.path(), &asset_id, 0, state.clone())) + .expect("save state"); + assert!(matches!( + saved, + SaveUiDesignStateResult::Saved { revision: 1, .. } + )); + + let unchanged = + save_ui_design_state_at(input(directory.path(), &asset_id, 1, state.clone())) + .expect("save identical state"); + assert!(matches!( + unchanged, + SaveUiDesignStateResult::Unchanged { revision: 1, .. } + )); + + let conflict = save_ui_design_state_at(input( + directory.path(), + &asset_id, + 0, + State { + ui_trees: Vec::new(), + ui_design_images: Default::default(), + sprite_assets: Default::default(), + font_assets: Default::default(), + }, + )) + .expect("return CAS conflict"); + assert!( + matches!(conflict, SaveUiDesignStateResult::Conflict { current } if current.revision == 1 && current.state == state) + ); + } + + #[test] + fn recovers_last_valid_state_when_primary_is_corrupt() { + let (directory, asset_id) = fixture(); + let first = state_with_unavailable_image("assets/first-missing.png"); + let second = state_with_unavailable_image("assets/second-missing.png"); + save_ui_design_state_at(input(directory.path(), &asset_id, 0, first.clone())) + .expect("save first state"); + save_ui_design_state_at(input(directory.path(), &asset_id, 1, second)) + .expect("save second state"); + + let asset = ui_design_asset(directory.path(), PROJECT_ID, &asset_id).expect("find asset"); + let primary = resolve_local_project_path(directory.path(), &asset.local_path) + .expect("resolve primary"); + fs::write(&primary, b"{broken").expect("corrupt primary"); + + let recovered = load_ui_design_state_at(LoadUiDesignStateInput { + project_path: directory.path().to_string_lossy().into_owned(), + expected_project_id: PROJECT_ID.to_string(), + asset_id, + }) + .expect("recover previous state"); + assert_eq!(recovered.revision, 1); + assert_eq!(recovered.state, first); + assert_eq!( + read_ui_design_document_path(&primary) + .expect("restored primary") + .revision, + 1 + ); + } + + #[test] + fn rejects_legacy_empty_object_and_unknown_envelope_fields() { + let (directory, asset_id) = fixture(); + let asset = ui_design_asset(directory.path(), PROJECT_ID, &asset_id).expect("find asset"); + let path = + resolve_local_project_path(directory.path(), &asset.local_path).expect("resolve asset"); + let backup = agent_runtime_json_sidecar_backup_path(&path); + if backup.exists() { + fs::remove_file(&backup).expect("remove recovery copy for strict schema test"); + } + + fs::write(&path, b"{}\n").expect("write legacy empty object"); + assert!(load_ui_design_state_at(LoadUiDesignStateInput { + project_path: directory.path().to_string_lossy().into_owned(), + expected_project_id: PROJECT_ID.to_string(), + asset_id: asset_id.clone(), + }) + .is_err()); + + let mut value = serde_json::to_value(empty_document(PROJECT_ID, &asset_id)) + .expect("serialize envelope"); + value["unexpected"] = serde_json::json!(true); + fs::write( + &path, + serde_json::to_vec(&value).expect("serialize bad envelope"), + ) + .expect("write unknown field"); + assert!(load_ui_design_state_at(LoadUiDesignStateInput { + project_path: directory.path().to_string_lossy().into_owned(), + expected_project_id: PROJECT_ID.to_string(), + asset_id, + }) + .is_err()); + } +} diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/AGENTS.md b/apps/ai-game-creator-shell/src/features/ui-editor/AGENTS.md index 3e171131c..f3a8eb82d 100644 --- a/apps/ai-game-creator-shell/src/features/ui-editor/AGENTS.md +++ b/apps/ai-game-creator-shell/src/features/ui-editor/AGENTS.md @@ -30,6 +30,7 @@ - 节点布局与组件是独立通道:`layout_status` / `components_status` 分别记录阶段状态,`allow_llm_edit_layout` / `allow_llm_edit_component` 分别授权 LLM 修改;人工编辑不隐式改变状态或授权。页面根节点可承载组件,但其 Transform 不可修改。 -- “组件结构”阶段显示“识别概览”,递归统计 `State.ui_trees` 中全部 Node,不按 `source` 过滤;“已识别 Node”为全部节点数,“待用户检查”为 `layout_status` 是 `NeedReview` 或 `Blocked` 的数量,“已通过”为 `Passed` 数量,“必须修复”为 `Blocked` 数量。`components_status` 的阶段概览属于后续“绑定概览”。 -- “待用户检查”和“必须修复”分别按 `ui_trees` 顺序及树的前序遍历循环定位下一项;各自游标只属于概览组件的临时 UI 状态,树变化后重置,不写入正式 `State`。 -- 概览定位 Node 时必须统一完成所属界面图激活、节点选择、Inspector 展示、UI Tree 祖先展开与滚动,以及预览画布对节点的带留白适配;状态查询与定位行为应可复用,不在统计卡点击处理器内复制树遍历和跨面板协调。 +- UI Editor 的项目资源持久化复用 manifest `kind: "UI"` 和 `application/json`,正式文件为严格的 `game-creator-ui-design-state.v1` envelope,绑定 `projectId`、`assetId`、资源 revision 与 Rust `State`;旧 `{}` 文件、未知字段、身份错误、超限或无效引用均失败关闭。 +- load/save 只能经专用 Tauri command 按 `expectedProjectId + manifest assetId` 解析受控 `localPath`;保存使用每资源 revision CAS。内容相同不推进项目 revision;安装、回读成功后才推进项目 revision,推进失败返回 `reconciliation-required`。 +- 每次非空保存保留一个最近有效的 `.previous` 恢复副本。主文件损坏时只从该副本恢复,外部图片丢失或未登记不使 `State` 无效;预览缺失显示占位且不阻断编辑。 +- `imageOrder`、当前选择、面板开关、缩放和 preview URL 仍是临时 React 状态,加载后从 `State` 派生;加载失败时禁编辑与保存。持久化代码必须覆盖 round-trip、CAS、严格 schema、恢复和缺失外部素材的定向测试。 diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/uiDesignStateStore.ts b/apps/ai-game-creator-shell/src/features/ui-editor/uiDesignStateStore.ts index 79ba85235..cb8509c79 100644 --- a/apps/ai-game-creator-shell/src/features/ui-editor/uiDesignStateStore.ts +++ b/apps/ai-game-creator-shell/src/features/ui-editor/uiDesignStateStore.ts @@ -1,31 +1,63 @@ +import { invoke } from '@tauri-apps/api/core'; + import type { State } from './types/State'; import { EMPTY_UI_EDITOR_STATE } from './useUiEditorState'; +export type UiDesignStateSnapshot = { revision: number; state: State }; + +export type UiDesignStateSaveResult = + | { + status: 'saved' | 'unchanged'; + state: State; + revision: number; + committedProjectRevision: number; + } + | { status: 'conflict'; current: UiDesignStateSnapshot }; + export type UiDesignStateStore = { - load(resourceId: string): Promise; - save(resourceId: string, state: State): Promise; + load(assetId: string): Promise; + save( + assetId: string, + expectedRevision: number, + state: State, + ): Promise; }; -function emptyState(): State { +export function createTauriUiDesignStateStore( + projectPath: string, + expectedProjectId: string, +): UiDesignStateStore { return { - ui_trees: [], - ui_design_images: {}, - sprite_assets: {}, - font_assets: {}, + load(assetId) { + return invoke('load_ui_design_state', { + input: { projectPath, expectedProjectId, assetId }, + }); + }, + save(assetId, expectedRevision, state) { + return invoke('save_ui_design_state', { + input: { + projectPath, + expectedProjectId, + assetId, + expectedRevision, + state, + }, + }); + }, }; } -/** - * TODO(ui-design-persistence): replace this adapter with project-file backed - * State serialization at the UI resource's registered localPath. - */ export const mockUiDesignStateStore: UiDesignStateStore = { - async load(_resourceId) { - return emptyState(); + async load(_assetId) { + return { revision: 0, state: structuredClone(EMPTY_UI_EDITOR_STATE) }; }, - async save(_resourceId, _state) { - // The resource and its manifest entry are durable. UI State persistence is - // deliberately deferred until the file format is ready. + async save(_assetId, _expectedRevision, state) { + return { + status: 'saved', + state: structuredClone(state), + revision: 1, + committedProjectRevision: 0, + }; }, }; 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 3726dd0d8..1da0ba139 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 @@ -3106,6 +3106,7 @@ export default function ProjectDevelopmentView({ setUiEditorRoute(null)} /> diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx index 483cbf4b4..e81ac60b1 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx @@ -1,8 +1,9 @@ import { ChevronLeft } from 'lucide-react'; -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { ThemedModal } from '../../components/modal/ThemedModal'; import { + createTauriUiDesignStateStore, mockUiDesignStateStore, type UiDesignStateStore, } from '../../features/ui-editor/uiDesignStateStore'; @@ -16,17 +17,27 @@ import { useUiEditorPage } from './useUiEditorPage'; export default function UiEditorPage({ projectPath = '/tmp/ui_editor', resourceId, + projectId, resourceLabel, onBack, - stateStore = mockUiDesignStateStore, + stateStore, }: { projectPath?: string; resourceId?: string; + projectId?: string; resourceLabel?: string; onBack?: () => void; stateStore?: UiDesignStateStore; }) { - const controller = useUiEditorPage(projectPath, resourceId, stateStore); + const resolvedStore = useMemo( + () => + stateStore ?? + (resourceId && projectId + ? createTauriUiDesignStateStore(projectPath, projectId) + : mockUiDesignStateStore), + [projectId, projectPath, resourceId, stateStore], + ); + const controller = useUiEditorPage(projectPath, resourceId, resolvedStore); const [savedStateSignature, setSavedStateSignature] = useState( null, ); @@ -37,13 +48,13 @@ export default function UiEditorPage({ const stateSignature = JSON.stringify(controller.editor.state); useEffect(() => { - if (!resourceId || controller.isLoading) return; + if (!resourceId || controller.isLoading || controller.loadError) return; if (loadedResourceIdRef.current !== resourceId) { loadedResourceIdRef.current = resourceId; setSavedStateSignature(stateSignature); setSaveError(null); } - }, [controller.isLoading, resourceId, stateSignature]); + }, [controller.isLoading, controller.loadError, resourceId, stateSignature]); const isDirty = resourceId !== undefined && @@ -51,27 +62,47 @@ export default function UiEditorPage({ savedStateSignature !== stateSignature; async function save() { - if (!resourceId || isSaving) return; + if ( + !resourceId || + isSaving || + controller.isLoading || + controller.loadError || + controller.persistedRevision === null || + controller.editor.isLocked + ) { + return false; + } setSaveError(null); setIsSaving(true); + const snapshot = structuredClone(controller.editor.state); + const snapshotSignature = JSON.stringify(snapshot); try { - await stateStore.save(resourceId, controller.editor.state); - setSavedStateSignature(JSON.stringify(controller.editor.state)); + const result = await resolvedStore.save( + resourceId, + controller.persistedRevision, + snapshot, + ); + if (result.status === 'conflict') { + setSaveError('资源已在别处更新;请重新加载后再保存。'); + return false; + } + controller.setPersistedRevision(result.revision); + if (JSON.stringify(controller.editor.state) === snapshotSignature) { + setSavedStateSignature(snapshotSignature); + } + return true; } catch (error) { setSaveError(error instanceof Error ? error.message : String(error)); - throw error; + return false; } finally { setIsSaving(false); } } async function saveAndReturn() { - try { - await save(); + if (await save()) { setReturnConfirmOpen(false); onBack?.(); - } catch { - // The save error stays visible in the editor. } } @@ -99,23 +130,42 @@ export default function UiEditorPage({ ) : null} - -
- - - +
+ +
+ + + +
+
- + {saveError || controller.loadError ? ( +

+ {saveError ?? `无法加载 UI 设计:${controller.loadError}`} +

+ ) : null} setReturnConfirmOpen(false)} @@ -149,7 +199,12 @@ export default function UiEditorPage({