From 09eb9ee2020f05b954fd4020e809743e597f587c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 13 Aug 2026 15:44:10 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=20UI=20=E7=BC=96=E8=BE=91?= =?UTF-8?q?=E5=99=A8=E5=8F=82=E8=80=83=E5=9B=BE=E8=AF=AD=E4=B9=89=E5=BB=BA?= =?UTF-8?q?=E8=AE=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 Rust 参考图语义建议命令与严格 DTO 校验 支持名称、描述、角色和归属页面建议的前端合并 允许界面图与独立素材名称为空并增强导入错误详情 在 Inspector 暴露界面图描述编辑并保留调试入口 --- .../src-tauri/src/main.rs | 8 + .../src-tauri/src/ui_editor/commands/mod.rs | 6 +- .../commands/ui_design_suggestion.rs | 145 +++++++++++++++++- .../src/ui_editor/resource/ui_design_image.rs | 27 ++-- .../src-tauri/src/ui_editor/state/mod.rs | 8 +- .../src/features/ui-editor/AGENTS.md | 29 ++++ .../src/features/ui-editor/importAdapter.ts | 5 +- .../ui-editor/types/UIDesignImageMetadata.ts | 2 +- .../ui-editor/types/UIDesignSuggestion.ts | 5 + .../features/ui-editor/uiDesignSuggestions.ts | 37 +++++ .../features/ui-editor/useUiEditorState.ts | 91 ++++++++--- .../components/Inspector/InspectorSidebar.tsx | 11 ++ .../src/view/ui-editor/index.tsx | 16 ++ .../src/view/ui-editor/model.ts | 4 +- .../src/view/ui-editor/useUiEditorPage.ts | 35 +++++ 15 files changed, 382 insertions(+), 47 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/features/ui-editor/AGENTS.md create mode 100644 apps/ai-game-creator-shell/src/features/ui-editor/types/UIDesignSuggestion.ts create mode 100644 apps/ai-game-creator-shell/src/features/ui-editor/uiDesignSuggestions.ts 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 bb7edfb66..5e3245728 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -110,6 +110,13 @@ use runner::*; use swarm_cli::*; use user_input::*; use windows::*; +#[tauri::command] +async fn suggest_ui_design_semantic( + project_path: String, + state: ui_editor::state::State, +) -> Result, String> { + ui_editor::commands::suggest_ui_design_semantic_impl(project_path, state).await +} #[derive(Debug, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] @@ -2244,6 +2251,7 @@ fn main() { sync_canvas_project_assets, import_ui_editor_local_files, import_ui_editor_remote_assets, + suggest_ui_design_semantic, 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/commands/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/mod.rs index 37757c885..a13a9c852 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/mod.rs @@ -1 +1,5 @@ -mod ui_design_suggestion; +pub mod ui_design_suggestion; +pub mod recognition; + +pub(crate) use ui_design_suggestion::suggest_ui_design_semantic_impl; +pub use ui_design_suggestion::UIDesignSuggestion; diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/ui_design_suggestion.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/ui_design_suggestion.rs index 4f0403cae..040a395de 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/ui_design_suggestion.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/ui_design_suggestion.rs @@ -1,9 +1,146 @@ +use crate::config::build_game_creator_llm_client_from_config; use crate::ui_editor::resource::ui_design_image::UIDesignImageRole; +use crate::ui_editor::state::State; use crate::ui_editor::utils::UIDesignImageId; +use base64::Engine as _; +use platform_llm::{LlmMessage, LlmMessageContentPart, LlmRunRequest}; +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; +use std::path::Path; +use ts_rs::TS; +const SYSTEM_PROMPT: &str = r#" +请识别这些 UI 参考图的界面语义。你的响应必须是严格 JSON 数组;不要输出 Markdown、代码围栏、注释、额外字段或解释文字。 + +TypeScript 等价类型如下(仅用于说明契约,实际响应仍必须是 JSON): +type UIDesignSuggestion = { + ui_design_image_id: string; + name: string | null; + description: string | null; + role: "Page" | "Section" | "Modal" | "Drawer" | "Popover" | "State" | "Scrolled" | "Detail" | null; + slave_to: string | null; +}; + +字段含义和 null 规则: +- ui_design_image_id:必填,必须逐字匹配当前输入参考图的 id。它标识“这条建议属于哪张图”,不是新 ID,不能为 null。 +- name:要写入该图片 metadata 的简短、可读名称; +- description:要写入该图片 metadata 的简短语义描述,例如“带底部导航的主游戏页面”。 +- role:要写入该图片 metadata 的界面角色。只能使用 Page、Section、Modal、Drawer、Popover、State、Scrolled、Detail 之一;。Page 表示完整主页面,Section 表示同一主页面中的子界面或页签,Modal/Drawer/Popover 表示浮层或局部覆盖界面,State 表示同一界面的状态变体,Scrolled 表示滚动或分页后的内容,Detail 表示局部详情或补充证据。 +- slave_to:要写入该图片 metadata 的归属页面 id。只有当当前图片明显是另一张输入图的局部/子界面时才填写那个宿主图的 id;如果不应修改现有 slave_to,必须填 null。Page 不得设置宿主。不能填写自身 id,也不能填写不在输入参考图中的 id。 + +所有字段都必须出现;nullable 字段使用 JSON null 表示“不要修改该字段”,不要省略字段。 + +每张参考图最多返回一条建议,且不得重复 ui_design_image_id。示例: +[ + { + "ui_design_image_id": "image-main", + "name": "主游戏页面", + "description": "带顶部资源栏和底部导航的完整游戏主页面", + "role": "Page", + "slave_to": null + }, + { + "ui_design_image_id": "image-inventory", + "description": "从主页面进入的物品列表子界面", + "role": "Section", + "slave_to": "image-main" + }, +] +示例中的 image-main、image-inventory、image-detail 只是占位符;必须替换为本次输入中真实存在的 id。 + +以下UI设计图中的metadata部分信息已确定, 请补全未确定/为空的信息 +"#; +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))] pub struct UIDesignSuggestion { - ui_design_image_id: UIDesignImageId, - description: Option, - role: Option, - slave_to: Option, + pub ui_design_image_id: UIDesignImageId, + pub name: Option, + pub description: Option, + pub role: Option, + pub slave_to: Option, +} + +const MAX_REFERENCES: usize = 4; + +fn image_data_url(path: &Path, bytes: &[u8]) -> Result { + let mime = match path.extension().and_then(|value| value.to_str()) { + Some("png") => "image/png", + Some("jpg") | Some("jpeg") => "image/jpeg", + Some("webp") => "image/webp", + _ => return Err(format!("不支持的界面图格式:{}", path.display())), + }; + Ok(format!( + "data:{mime};base64,{}", + base64::engine::general_purpose::STANDARD.encode(bytes) + )) +} + +fn validate_suggestions( + suggestions: &[UIDesignSuggestion], + image_ids: &HashSet, +) -> Result<(), String> { + let mut seen = HashSet::new(); + for suggestion in suggestions { + if !image_ids.contains(&suggestion.ui_design_image_id) { + return Err("LLM 返回了未知界面图 ID".to_string()); + } + if !seen.insert(suggestion.ui_design_image_id.clone()) { + return Err("LLM 返回了重复界面图建议".to_string()); + } + if suggestion.role == Some(UIDesignImageRole::Page) && suggestion.slave_to.is_some() { + return Err("主页面不能设置归属页面".to_string()); + } + if let Some(host) = &suggestion.slave_to { + if host == &suggestion.ui_design_image_id || !image_ids.contains(host) { + return Err("界面图归属页面无效".to_string()); + } + } + } + Ok(()) +} + +#[tauri::command] +pub(crate) async fn suggest_ui_design_semantic_impl( + project_path: String, + state: State, +) -> Result, String> { + if state.ui_design_images.is_empty() { + return Err("请先导入界面图".to_string()); + } + if state.ui_design_images.len() > MAX_REFERENCES { + return Err("界面图最多 4 张".to_string()); + } + let root = Path::new(project_path.trim()); + let mut parts = Vec::new(); + + let mut ids = HashSet::new(); + for (id, image) in &state.ui_design_images { + ids.insert(id.clone()); + let absolute = crate::project::resolve_local_project_path(root, &image.path)?; + let bytes = std::fs::read(&absolute).map_err(|error| format!("读取界面图失败:{error}"))?; + parts.push(LlmMessageContentPart::InputText { + text: format!( + "REFERENCE id={} metadata={} pixel_size={:?}", + id.as_str(), + image.metadata, + image.pixel_size + ), + }); + parts.push(LlmMessageContentPart::InputImage { + image_url: image_data_url(&absolute, &bytes)?, + }); + } + let client = build_game_creator_llm_client_from_config()?; + let response = client + .run(LlmRunRequest::new(vec![ + LlmMessage::system(SYSTEM_PROMPT), + LlmMessage::user_multimodal(parts), + ])) + .await + .map_err(|error| format!("UI 参考图语义识别失败:{error}"))?; + let suggestions = serde_json::from_str::>(response.text.trim()) + .map_err(|error| format!("LLM 返回的 UI 语义 JSON 无效:{error}"))?; + validate_suggestions(&suggestions, &ids)?; + // TODO: future single-page policy may reject multiple Page suggestions. + Ok(suggestions) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/resource/ui_design_image.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/resource/ui_design_image.rs index 5928b1975..fff90af5b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/resource/ui_design_image.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/resource/ui_design_image.rs @@ -1,8 +1,9 @@ +use crate::ui_editor::utils::UIDesignImageId; use nalgebra::Vector2; use serde::{Deserialize, Serialize}; +use std::fmt::{Display, Formatter, Write}; use ts_rs::TS; use typed_floats::tf32::StrictlyPositiveFinite; -use crate::ui_editor::utils::UIDesignImageId; #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize, TS)] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))] @@ -20,18 +21,26 @@ pub enum UIDesignImageRole { #[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))] pub struct UIDesignImage { - metadata: UIDesignImageMetadata, - path: String, + pub(crate) metadata: UIDesignImageMetadata, + pub(crate) path: String, #[ts(as = "[f32; 2]")] - pixel_size: Vector2, + pub(crate) pixel_size: Vector2, #[ts(as = "f32")] - pixels_per_unit: StrictlyPositiveFinite, + pub(crate) pixels_per_unit: StrictlyPositiveFinite, } #[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))] pub struct UIDesignImageMetadata { - name: String, - description: String, - role: Option, - slave_to: Option, + pub(crate) name: String, + pub(crate) description: String, + pub(crate) role: Option, + pub(crate) slave_to: Option, +} +impl Display for UIDesignImageMetadata { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.write_str(&format!( + "name: {}, description: {}, role: {:?}, slave_to: {:?}", + self.name, self.description, self.role, self.slave_to + )) + } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/state/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/state/mod.rs index e33147bf7..2e7ce6c3a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/state/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/state/mod.rs @@ -17,8 +17,8 @@ pub struct UITree { #[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))] pub struct State { - ui_trees: Vec, - ui_design_images: HashMap, - sprite_assets: HashMap, - font_assets: HashMap, + pub(crate) ui_trees: Vec, + pub(crate) ui_design_images: HashMap, + pub(crate) sprite_assets: HashMap, + pub(crate) font_assets: HashMap, } 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 new file mode 100644 index 000000000..ab001e162 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/AGENTS.md @@ -0,0 +1,29 @@ +- 本文件采用 append-only:只在末尾追加新的共同原则,不改写或删除既有内容。 +- UI Editor 保持桌面端专用,当前不实现持久化。 +- 本阶段不新增 UI 测试,不修改或新增其它文档。 +- `demo/` 是设计师提供的旧 mock。只复用设计目的、页面布局和组件种类,不复用其数据结构或静态假数据实现。 +- 原始需求位于仓库根目录 `req_{n}.txt`;本文件记录已经确认的工程解释。两者冲突时先停止编码并向用户确认。 +- 正式状态是`State` +- 核心布局结果 `State.ui_trees`。 +- Rust 是 UI Editor 领域类型的唯一源。并通过 `ts-rs` 生成 + `src/features/ui-editor/types/` 下的 TypeScript 文件。 +- React 只保存选择项、缩放、面板开关等临时 UI 状态。组件树、节点内容、审阅状态和人工锁定状态必须体现在同一个内存 `State` 中。 + +- 纯结构容器可使用空 `components`;视觉节点使用 `Image`;文本和动态数值使用 `Text`。动态数值的语义可写入节点描述,本期不引入独立 Number 组件。 + +- AI 识别由 Tauri/Rust 中的专用命令实现,入口放在 + `src-tauri/src/ui_editor/commands/mod.rs`。前端不得直接调用模型。 +- 复用项目现有 LLM 配置和 provider 能力;前端不得传递或持有 API Key。 +- 最多四张 UI 参考图必须作为真实多模态像素输入发送。Rust 读取图片并使用 + `data:;base64,...` 形式交给现有 LLM provider,不得只把文件路径、文件名或尺寸写进文字提示词。 +- base64 图片正文不得写入日志、错误详情、State、测试快照或其它持久记录。 +- 模型输出先进入命令内部识别 DTO,经结构校验和标准化后再构造正式 `UITree`、`Node`、`Component` 和 `Transform`;模型输出 DTO 不是公开 State 契约。 +- 模型使用的临时父子引用不得直接成为正式实体身份;正式 `NodeId` 由 Rust 校验和分配,并保证 State 内唯一。 + +- `container`、`button`、`tab`、`modal`、`image`、`text` 等名称只用于帮助理解 UI,不是正式数据类型,也不规定节点与 `Component` 的映射。正式能力只由当前 Rust 类型表达。 +- 本期只保留当前正式渲染组件 `Component::Image` 和 `Component::Text`。按钮、页签、弹窗、头像、进度条等更多组件类型以后再设计,不在本阶段预建枚举或 variant。 +- 节点数量、待审阅数量及其它概览信息只通过递归查询和过滤 `State.ui_trees` 得出,不向 `State` 添加重复统计或阶段确认字段。 +- 当前范围不实现UI交互关系、圆形识别框、复制、粘贴或手工新增节点,也不为这些概念预建状态。 +- 各 AI 阶段使用独立的输入/输出契约;后续阶段显式消费前一阶段结果,不从隐含的临时上下文推断前置事实。 +- AI 阶段的结果通过命令返回并更新同一个内存 `State`;阶段内部 DTO 只用于当前调用,不成为额外持久状态或跨阶段隐式参数。 +- 阶段命令可以只返回该阶段的 DTO;调用方负责将 DTO 显式合并到同一个内存 `State`。仅用于调试的验证入口与正式工作流分离。 diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/importAdapter.ts b/apps/ai-game-creator-shell/src/features/ui-editor/importAdapter.ts index 9c83db4de..9b5523e82 100644 --- a/apps/ai-game-creator-shell/src/features/ui-editor/importAdapter.ts +++ b/apps/ai-game-creator-shell/src/features/ui-editor/importAdapter.ts @@ -58,7 +58,8 @@ export async function prepareDesignImageBatch( ); const image: UIDesignImage = { metadata: { - name: basename(asset.localPath), + name: '', + description: '', role: null, slave_to: null, }, @@ -86,7 +87,7 @@ export async function prepareSpriteAssetBatch( ); const resource: SpriteAsset = { asset_id: asset.id, - metadata: { name: basename(asset.localPath), asset_type: '' }, + metadata: { name: '', asset_type: '' }, path: asset.localPath, pixel_size: pixelSize, pixels_per_unit: 1, diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/types/UIDesignImageMetadata.ts b/apps/ai-game-creator-shell/src/features/ui-editor/types/UIDesignImageMetadata.ts index 88a03a79f..2e9df7fd3 100644 --- a/apps/ai-game-creator-shell/src/features/ui-editor/types/UIDesignImageMetadata.ts +++ b/apps/ai-game-creator-shell/src/features/ui-editor/types/UIDesignImageMetadata.ts @@ -2,4 +2,4 @@ import type { UIDesignImageId } from "./UIDesignImageId"; import type { UIDesignImageRole } from "./UIDesignImageRole"; -export type UIDesignImageMetadata = { name: string, role: UIDesignImageRole | null, slave_to: UIDesignImageId | null, }; +export type UIDesignImageMetadata = { name: string, description: string, role: UIDesignImageRole | null, slave_to: UIDesignImageId | null, }; diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/types/UIDesignSuggestion.ts b/apps/ai-game-creator-shell/src/features/ui-editor/types/UIDesignSuggestion.ts new file mode 100644 index 000000000..b33171126 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/types/UIDesignSuggestion.ts @@ -0,0 +1,5 @@ +// This file is generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { UIDesignImageId } from "./UIDesignImageId"; +import type { UIDesignImageRole } from "./UIDesignImageRole"; + +export type UIDesignSuggestion = { ui_design_image_id: UIDesignImageId, name: string | null, description: string | null, role: UIDesignImageRole | null, slave_to: UIDesignImageId | null, }; diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/uiDesignSuggestions.ts b/apps/ai-game-creator-shell/src/features/ui-editor/uiDesignSuggestions.ts new file mode 100644 index 000000000..e772f8870 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/uiDesignSuggestions.ts @@ -0,0 +1,37 @@ +import type { State } from './types/State'; +import type { UIDesignSuggestion } from './types/UIDesignSuggestion'; + +/** + * Applies semantic suggestions conservatively: null means "do not modify", + * an already curated value wins, and suggestions for resources that disappeared + * from the current State are ignored. + */ +export function applyUiDesignSuggestions( + state: State, + suggestions: readonly UIDesignSuggestion[], +): State { + const next = structuredClone(state); + + for (const suggestion of suggestions) { + const image = next.ui_design_images[suggestion.ui_design_image_id]; + if (!image) continue; + + if (image.metadata.name.trim().length === 0 && suggestion.name?.trim()) { + image.metadata.name = suggestion.name.trim(); + } + if (image.metadata.role === null && suggestion.role !== null) { + image.metadata.role = suggestion.role; + } + if (image.metadata.slave_to === null && suggestion.slave_to !== null) { + image.metadata.slave_to = suggestion.slave_to; + } + if ( + image.metadata.description.trim().length === 0 && + suggestion.description?.trim() + ) { + image.metadata.description = suggestion.description.trim(); + } + } + + return next; +} diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/useUiEditorState.ts b/apps/ai-game-creator-shell/src/features/ui-editor/useUiEditorState.ts index e3338ba59..087eb90e2 100644 --- a/apps/ai-game-creator-shell/src/features/ui-editor/useUiEditorState.ts +++ b/apps/ai-game-creator-shell/src/features/ui-editor/useUiEditorState.ts @@ -23,7 +23,8 @@ export type UiEditorOperationFailureReason = | 'duplicate' | 'limit' | 'missing' - | 'invalid'; + | 'invalid' + | `invalid:${string}`; export type UiEditorOperationResult = | { ok: true; value: T } @@ -101,30 +102,36 @@ export function spriteAssetRemovalImpact( }; } -function validImageResource(image: UIDesignImage) { - return ( - image.path.trim().length > 0 && - image.metadata.name.trim().length > 0 && - image.pixel_size.every( +function imageResourceValidationError(image: UIDesignImage) { + if (image.path.trim().length === 0) return '缺少图片路径'; + if ( + !image.pixel_size.every( (value) => Number.isFinite(value) && value > 0, - ) && - Number.isFinite(image.pixels_per_unit) && - image.pixels_per_unit > 0 - ); + ) + ) { + return `图片尺寸无效:${JSON.stringify(image.pixel_size)}`; + } + if (!Number.isFinite(image.pixels_per_unit) || image.pixels_per_unit <= 0) { + return `像素单位无效:${image.pixels_per_unit}`; + } + return null; } -function validSpriteResource(sprite: SpriteAsset) { - return ( - sprite.asset_id.trim().length > 0 && - sprite.path.trim().length > 0 && - sprite.metadata.name.trim().length > 0 && - sprite.pixel_size.every( +function spriteResourceValidationError(sprite: SpriteAsset) { + if (sprite.asset_id.trim().length === 0) return '缺少素材 ID'; + if (sprite.path.trim().length === 0) return '缺少素材路径'; + if ( + !sprite.pixel_size.every( (value) => Number.isFinite(value) && value > 0, - ) && - Number.isFinite(sprite.pixels_per_unit) && - sprite.pixels_per_unit > 0 && - validateSpriteBorder(sprite.pixel_size, sprite.border).ok - ); + ) + ) { + return `素材尺寸无效:${JSON.stringify(sprite.pixel_size)}`; + } + if (!Number.isFinite(sprite.pixels_per_unit) || sprite.pixels_per_unit <= 0) { + return `像素单位无效:${sprite.pixels_per_unit}`; + } + const border = validateSpriteBorder(sprite.pixel_size, sprite.border); + return border.ok ? null : border.message; } export function useUiEditorState( @@ -184,6 +191,22 @@ export function useUiEditorState( [commit, guard], ); + const setImageDescription = useCallback( + (id: UIDesignImageId, description: string): UiEditorOperationResult => { + const blocked = guard(); + if (blocked) return blocked; + const current = stateRef.current; + if (!(id in current.ui_design_images)) { + return { ok: false, reason: 'missing' }; + } + const next = cloneState(current); + next.ui_design_images[id]!.metadata.description = description; + commit(next); + return { ok: true, value: undefined }; + }, + [commit, guard], + ); + const setImageRole = useCallback( ( id: UIDesignImageId, @@ -240,8 +263,14 @@ export function useUiEditorState( if (Object.keys(current.ui_design_images).length + entries.length > 4) { return { ok: false, reason: 'limit' }; } - if (entries.some((entry) => !validImageResource(entry.image))) { - return { ok: false, reason: 'invalid' }; + const invalidImage = entries + .map((entry) => ({ entry, error: imageResourceValidationError(entry.image) })) + .find((item) => item.error); + if (invalidImage?.error) { + return { + ok: false, + reason: `invalid:${invalidImage.entry.id}:${invalidImage.error}`, + }; } const next = cloneState(current); for (const entry of entries) { @@ -265,8 +294,14 @@ export function useUiEditorState( ) { return { ok: false, reason: 'duplicate' }; } - if (assets.some((asset) => !validSpriteResource(asset))) { - return { ok: false, reason: 'invalid' }; + const invalidSprite = assets + .map((asset) => ({ asset, error: spriteResourceValidationError(asset) })) + .find((item) => item.error); + if (invalidSprite?.error) { + return { + ok: false, + reason: `invalid:${invalidSprite.asset.asset_id}:${invalidSprite.error}`, + }; } const next = cloneState(current); for (const asset of assets) { @@ -396,12 +431,17 @@ export function useUiEditorState( return { ok: true, value: undefined }; }, [commit, guard]); + const replaceState = useCallback((nextState: State) => { + commit(cloneState(nextState)); + }, [commit]); + return { state, isLocked, runWithStateLocked, setImageName, + setImageDescription, setImageRole, setImageSlaveTo, addDesignImages, @@ -412,5 +452,6 @@ export function useUiEditorState( removeDesignImage, removeSpriteAsset, clearState, + replaceState, }; } diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/InspectorSidebar.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/InspectorSidebar.tsx index 27a89c2db..68366e4aa 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/InspectorSidebar.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/InspectorSidebar.tsx @@ -85,6 +85,17 @@ export function InspectorSidebar({ onChange={(event) => controller.setImageName(event.target.value)} /> +