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 ab551e5fa..5418d1929 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -131,6 +131,15 @@ async fn merge_ui(state: ui_editor::state::State) -> Result, +) -> Result { + ui_editor::commands::bind_components_impl(project_path, state, sprite_ids).await +} + #[derive(Debug, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] struct InitLocalProjectResult { @@ -2267,6 +2276,7 @@ fn main() { suggest_ui_design_semantic, recognize_ui, merge_ui, + bind_components, 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/binding.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/binding.rs new file mode 100644 index 000000000..676fea4ef --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/binding.rs @@ -0,0 +1,330 @@ +use crate::config::build_game_creator_llm_client_from_config; +use crate::ui_editor::commands::utils::strict_json_schema; +use crate::ui_editor::component::Component; +use crate::ui_editor::layout::node::{Node, StageStatus}; +use crate::ui_editor::state::State; +use crate::ui_editor::utils::{NodeId, SpriteAssetId}; +use base64::Engine as _; +use platform_llm::{ + LlmFunctionTool, LlmMessage, LlmMessageContentPart, LlmRunRequest, LlmToolChoice, +}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; +use std::path::Path; +use ts_rs::TS; + +pub const ASSET_BATCH_SIZE: usize = 5; + +const SYSTEM_PROMPT: &str = r#" +你是游戏 UI 组件绑定器。你会看到全部 UI 参考图、可编辑节点说明,以及本批独立素材的真实像素。 + +只对视觉上确实需要改变组件的节点返回 changes;未返回的节点必须保持不变。每个 change 的 components 是该节点完整的新渲染栈,空数组表示明确清空。数组顺序从底到顶渲染。 + +只能返回当前正式 Component 契约中的 Image 和 Text。对每个 Component,直接完整返回其全部参数;不要省略、默认化或由你之外的代码补齐参数。Image 的 target_graphic 必须是本批输入的素材 id;无法唯一匹配时返回 null,并把状态设为 NeedReview,说明中文原因。Text 写可见文本;动态数值也使用 Text。纯结构节点可以返回空数组并标为 Passed。 + +不得返回坐标、Transform、children、节点名称、权限或任何未定义字段。面向用户的 reason 使用中文。 +"#; + +#[derive(Clone, Debug, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +#[schemars(deny_unknown_fields)] +enum DraftStatus { + Passed, + NeedReview(String), +} + +#[derive(Clone, Debug, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +#[schemars(deny_unknown_fields)] +struct BindingChangeDraft { + node_id: NodeId, + components: Vec, + components_status: DraftStatus, +} + +#[derive(Clone, Debug, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +#[schemars(deny_unknown_fields)] +struct BindingResponse { + changes: Vec, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))] +pub struct BindingChange { + pub node_id: NodeId, + pub components: Vec, + pub components_status: StageStatus, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))] +pub struct BindingDTO { + pub changes: Vec, +} + +#[derive(Serialize)] +struct EditableNodeContext<'a> { + node_id: &'a NodeId, + name: &'a str, + description: &'a str, + components: &'a [Component], +} + +fn binding_json_schema() -> Result { + strict_json_schema::() +} + +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 collect_editable_nodes<'a>(node: &'a Node, output: &mut Vec>) { + if node.metadata.allow_llm_edit_component { + output.push(EditableNodeContext { + node_id: &node.id, + name: &node.metadata.name, + description: &node.metadata.description, + components: &node.components, + }); + } + for child in &node.children { + collect_editable_nodes(child, output); + } +} + +fn validate_and_materialize( + changes: Vec, + editable_ids: &HashSet, + batch_sprite_ids: &HashSet, +) -> Result { + let mut changed_ids = HashSet::new(); + let mut materialized = Vec::with_capacity(changes.len()); + for change in changes { + if !editable_ids.contains(&change.node_id) { + return Err(format!( + "组件绑定返回了未授权节点:{}", + change.node_id.as_str() + )); + } + if !changed_ids.insert(change.node_id.clone()) { + return Err(format!("组件绑定重复返回节点:{}", change.node_id.as_str())); + } + for component in &change.components { + if let Component::Image(image) = component { + if image + .target_graphic + .as_ref() + .is_some_and(|id| !batch_sprite_ids.contains(id)) + { + return Err("组件绑定引用了不属于当前批次的独立素材".to_string()); + } + } + } + let components = change.components; + let components_status = match change.components_status { + DraftStatus::Passed => StageStatus::Passed, + DraftStatus::NeedReview(reason) if reason.trim().is_empty() => { + return Err("组件待审状态必须包含原因".to_string()) + } + DraftStatus::NeedReview(reason) => StageStatus::NeedReview(reason), + }; + materialized.push(BindingChange { + node_id: change.node_id, + components, + components_status, + }); + } + Ok(BindingDTO { + changes: materialized, + }) +} + +pub(crate) async fn bind_components_impl( + project_path: String, + state: State, + sprite_ids: Vec, +) -> Result { + if sprite_ids.is_empty() { + return Err("请先导入至少一个独立素材".to_string()); + } + if sprite_ids.len() > ASSET_BATCH_SIZE { + return Err(format!("单次组件绑定最多 {} 个独立素材", ASSET_BATCH_SIZE)); + } + let sprite_ids = sprite_ids + .into_iter() + .map(SpriteAssetId::new) + .collect::, _>>() + .map_err(|error| format!("独立素材 ID 无效:{error}"))?; + let batch_sprite_ids = sprite_ids.iter().cloned().collect::>(); + if batch_sprite_ids.len() != sprite_ids.len() { + return Err("独立素材批次包含重复 ID".to_string()); + } + if state.ui_design_images.is_empty() || state.ui_design_images.len() > 4 { + return Err("组件绑定需要 1 至 4 张界面图".to_string()); + } + let mut editable_nodes = Vec::new(); + for tree in &state.ui_trees { + if !state.ui_design_images.contains_key(&tree.src_ui_design) { + return Err("UI 树引用的界面图不存在".to_string()); + } + collect_editable_nodes(&tree.root, &mut editable_nodes); + } + if editable_nodes.is_empty() { + return Err("当前没有允许 LLM 修改组件的节点".to_string()); + } + let editable_ids = editable_nodes + .iter() + .map(|node| node.node_id.clone()) + .collect::>(); + if editable_ids.len() != editable_nodes.len() { + return Err("UI 树包含重复节点 ID".to_string()); + } + let root = Path::new(project_path.trim()); + let mut parts = Vec::with_capacity(state.ui_design_images.len() * 2 + sprite_ids.len() * 2 + 1); + let node_context = serde_json::to_string(&editable_nodes) + .map_err(|error| format!("序列化可编辑节点失败:{error}"))?; + parts.push(LlmMessageContentPart::InputText { + text: format!("可编辑节点:{node_context}"), + }); + for (id, image) in &state.ui_design_images { + 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!( + "UI_REFERENCE id={} pixel_size={:?}", + id.as_str(), + image.pixel_size + ), + }); + parts.push(LlmMessageContentPart::InputImage { + image_url: image_data_url(&absolute, &bytes)?, + }); + } + for id in &sprite_ids { + let sprite = state + .sprite_assets + .get(id) + .ok_or_else(|| format!("独立素材不存在:{}", id.as_str()))?; + let absolute = crate::project::resolve_local_project_path(root, &sprite.path)?; + let bytes = + std::fs::read(&absolute).map_err(|error| format!("读取独立素材失败:{error}"))?; + parts.push(LlmMessageContentPart::InputText { + text: format!( + "SPRITE id={} name={} asset_type={} pixel_size={:?}", + id.as_str(), + sprite.metadata.name, + sprite.metadata.asset_type, + sprite.pixel_size + ), + }); + parts.push(LlmMessageContentPart::InputImage { + image_url: image_data_url(&absolute, &bytes)?, + }); + } + let client = build_game_creator_llm_client_from_config()?; + let tool = LlmFunctionTool::new( + "bind_ui_components", + "根据 UI 参考图和当前批次独立素材,返回需要修改的节点组件", + binding_json_schema()?, + ) + .with_strict(true); + let response = client + .run( + LlmRunRequest::new(vec![ + LlmMessage::system(SYSTEM_PROMPT), + LlmMessage::user_multimodal(parts), + ]) + .with_function_tools(vec![tool]) + .with_tool_choice(LlmToolChoice::Required), + ) + .await + .map_err(|error| format!("组件绑定失败:{error}"))?; + let call = response + .tool_calls + .iter() + .find(|call| call.name == "bind_ui_components") + .ok_or_else(|| "LLM 未返回 bind_ui_components 工具调用".to_string())?; + let parsed = serde_json::from_str::(&call.arguments) + .map_err(|error| format!("组件绑定工具参数无效:{error}"))?; + let result = validate_and_materialize(parsed.changes, &editable_ids, &batch_sprite_ids)?; + eprintln!( + "ui_binding.completed ui_images={} sprites={} editable_nodes={} changes={}", + state.ui_design_images.len(), + sprite_ids.len(), + editable_nodes.len(), + result.changes.len() + ); + Ok(result) +} + +#[cfg(test)] +mod tests { + use super::*; + use ts_rs::{Config, TS}; + + fn id(value: &str) -> NodeId { + NodeId::new(value).expect("valid id") + } + + #[test] + fn materialization_rejects_unapproved_node_and_foreign_sprite() { + let editable = HashSet::from([id("editable")]); + let sprites = HashSet::from([SpriteAssetId::new("sprite").expect("valid sprite")]); + let unapproved = BindingChangeDraft { + node_id: id("other"), + components: Vec::new(), + components_status: DraftStatus::Passed, + }; + assert!(validate_and_materialize(vec![unapproved], &editable, &sprites).is_err()); + + let foreign = BindingChangeDraft { + node_id: id("editable"), + components: vec![Component::Image( + crate::ui_editor::component::image::ImageComponent { + target_graphic: Some(SpriteAssetId::new("foreign").expect("valid sprite")), + image_type: crate::ui_editor::component::image::ImageType::Simple { + preserve_aspect: false, + }, + }, + )], + components_status: DraftStatus::Passed, + }; + assert!(validate_and_materialize(vec![foreign], &editable, &sprites).is_err()); + } + + #[test] + fn materialization_preserves_changed_only_empty_component_lists() { + let editable = HashSet::from([id("editable")]); + let result = validate_and_materialize( + vec![BindingChangeDraft { + node_id: id("editable"), + components: Vec::new(), + components_status: DraftStatus::Passed, + }], + &editable, + &HashSet::new(), + ) + .expect("valid changed-only clear"); + assert_eq!(result.changes.len(), 1); + assert!(result.changes[0].components.is_empty()); + assert_eq!(result.changes[0].components_status, StageStatus::Passed); + } + + #[test] + fn exports_ui_editor_types() { + let config = Config::from_env(); + BindingDTO::export_all(&config).expect("BindingDTO TypeScript export succeeds"); + Node::export_all(&config).expect("Node TypeScript export succeeds"); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/merge.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/merge.rs index 94dbd6742..dffd376f4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/merge.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/merge.rs @@ -160,7 +160,7 @@ mod priority { mod materialize { use super::llm_contract::Node; use crate::ui_editor::layout::node::{ - Node as LayoutNode, NodeMetadata, NodeSource, NodeStatus, + Node as LayoutNode, NodeMetadata, NodeSource, StageStatus, }; use crate::ui_editor::layout::transform::Transform; use crate::ui_editor::state::{State, UITree}; @@ -281,7 +281,10 @@ mod materialize { metadata: NodeMetadata { name: container_name, description: container_description, - status: NodeStatus::Passed, + layout_status: StageStatus::Passed, + components_status: StageStatus::Pending, + allow_llm_edit_layout: true, + allow_llm_edit_component: true, source: NodeSource::Llm, }, components: Vec::new(), 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 0b67edb53..b355afebb 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,9 +1,11 @@ +pub mod binding; pub mod merge; pub mod recognition; pub mod ui_design_suggestion; pub mod utils; -pub mod binding; +pub(crate) use binding::bind_components_impl; +pub use binding::BindingDTO; pub(crate) use merge::merge_ui_impl; pub use merge::MergeDTO; pub(crate) use recognition::recognize_ui_impl; diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/recognition.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/recognition.rs index ac433b09e..39a73f9d4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/recognition.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/recognition.rs @@ -1,7 +1,7 @@ use crate::config::build_game_creator_llm_client_from_config; use crate::ui_editor::commands::utils::strict_json_schema; use crate::ui_editor::layout::dimension::UIRect; -use crate::ui_editor::layout::node::{Node as LayoutNode, NodeMetadata, NodeSource, NodeStatus}; +use crate::ui_editor::layout::node::{Node as LayoutNode, NodeMetadata, NodeSource, StageStatus}; use crate::ui_editor::layout::transform::Transform; use crate::ui_editor::resource::ui_design_image::UIDesignImage; use crate::ui_editor::state::{State, UITree}; @@ -252,11 +252,11 @@ fn convert_node( let mut transform = Transform::new(anchor_min, anchor_max, Vector2::zeros(), Vector2::zeros()); transform.set_resolved_rect(&parent_rect, target_rect); let status = if blocked { - NodeStatus::Blocked + StageStatus::Blocked } else { match &source.confidence { - Confidence::Confident => NodeStatus::Passed, - Confidence::UnSure(reason) => NodeStatus::NeedReview(reason.clone()), + Confidence::Confident => StageStatus::Passed, + Confidence::UnSure(reason) => StageStatus::NeedReview(reason.clone()), } }; let children = source @@ -270,7 +270,10 @@ fn convert_node( metadata: NodeMetadata { name: source.name.clone(), description: source.description.clone(), - status, + layout_status: status, + components_status: StageStatus::Pending, + allow_llm_edit_layout: true, + allow_llm_edit_component: true, source: NodeSource::Llm, }, // TODO: 等识别 DTO 增加 type 字段后,再映射 Image/Text Component。 @@ -631,7 +634,10 @@ pub(crate) async fn recognize_ui_impl( metadata: NodeMetadata { name: "页面根节点".to_string(), description: String::new(), - status: NodeStatus::Passed, + layout_status: StageStatus::Passed, + components_status: StageStatus::Pending, + allow_llm_edit_layout: true, + allow_llm_edit_component: true, source: NodeSource::System, }, // TODO: root components can host page-level background content once component preview is supported. diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/component/image.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/component/image.rs index 1038142c6..a037cb357 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/component/image.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/component/image.rs @@ -5,25 +5,26 @@ use crate::ui_editor::layout::transform::Transform; use crate::ui_editor::resource::sprite::SpriteAsset; use crate::ui_editor::utils::SpriteAssetId; use nalgebra::Vector2; +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use ts_rs::TS; use typed_floats::tf32::StrictlyPositiveFinite; -#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize, TS)] +#[derive(Clone, Copy, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize, TS)] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))] pub enum HorizontalFillOrigin { Left, Right, } -#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize, TS)] +#[derive(Clone, Copy, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize, TS)] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))] pub enum VerticalFillOrigin { Bottom, Top, } -#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize, TS)] +#[derive(Clone, Copy, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize, TS)] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))] pub enum Radial90Origin { BottomLeft, @@ -32,7 +33,7 @@ pub enum Radial90Origin { BottomRight, } -#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize, TS)] +#[derive(Clone, Copy, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize, TS)] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))] pub enum Radial180Origin { Bottom, @@ -41,7 +42,7 @@ pub enum Radial180Origin { Right, } -#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize, TS)] +#[derive(Clone, Copy, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize, TS)] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))] pub enum Radial360Origin { Bottom, @@ -50,7 +51,7 @@ pub enum Radial360Origin { Left, } -#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize, TS)] +#[derive(Clone, Copy, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize, TS)] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))] pub enum FillMethod { Horizontal(HorizontalFillOrigin), @@ -79,7 +80,7 @@ fn validate_fill_amount(amount: f32) -> Result<(), ComponentValueError> { Ok(()) } -#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Serialize, TS)] +#[derive(Clone, Copy, Debug, Deserialize, JsonSchema, PartialEq, Serialize, TS)] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))] pub enum ImageType { Simple { @@ -87,11 +88,13 @@ pub enum ImageType { }, Sliced { fill_center: bool, + #[schemars(with = "f32")] #[ts(as = "f32")] pixels_per_unit_multiplier: StrictlyPositiveFinite, }, Tiled { fill_center: bool, + #[schemars(with = "f32")] #[ts(as = "f32")] pixels_per_unit_multiplier: StrictlyPositiveFinite, }, @@ -184,7 +187,7 @@ impl fmt::Display for SetNativeSizeError { impl Error for SetNativeSizeError {} /// Image 渲染组件;布局由同一 Entity 上独立的 `Control` 提供。 -#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)] +#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize, TS)] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))] pub struct ImageComponent { pub target_graphic: Option, diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/component/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/component/mod.rs index e31b384ba..14ab9c854 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/component/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/component/mod.rs @@ -1,7 +1,9 @@ pub mod common; pub mod image; pub mod text; -#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize, ts_rs::TS)] +#[derive( + Clone, Debug, PartialEq, schemars::JsonSchema, serde::Deserialize, serde::Serialize, ts_rs::TS, +)] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))] pub enum Component { Image(image::ImageComponent), diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/component/text.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/component/text.rs index f99b7a26e..05b4335c3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/component/text.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/component/text.rs @@ -1,12 +1,13 @@ use crate::ui_editor::component::common::error::ComponentValueError; use crate::ui_editor::resource::sprite::{Color, WHITE}; use crate::ui_editor::utils::FontAssetId; +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use std::num::NonZeroU32; use ts_rs::TS; use typed_floats::tf32::StrictlyPositiveFinite; -#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize, TS)] +#[derive(Clone, Copy, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize, TS)] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))] pub enum FontStyle { Normal, @@ -15,7 +16,7 @@ pub enum FontStyle { BoldItalic, } -#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize, TS)] +#[derive(Clone, Copy, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize, TS)] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))] pub enum TextAlignment { UpperLeft, @@ -29,21 +30,21 @@ pub enum TextAlignment { LowerRight, } -#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize, TS)] +#[derive(Clone, Copy, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize, TS)] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))] pub enum HorizontalTextOverflow { Wrap, Overflow, } -#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize, TS)] +#[derive(Clone, Copy, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize, TS)] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))] pub enum VerticalTextOverflow { Truncate, Overflow, } -#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Serialize, TS)] +#[derive(Clone, Copy, Debug, Deserialize, JsonSchema, PartialEq, Serialize, TS)] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))] pub struct BestFitFontSize { min: NonZeroU32, @@ -67,7 +68,7 @@ impl BestFitFontSize { } } -#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Serialize, TS)] +#[derive(Clone, Copy, Debug, Deserialize, JsonSchema, PartialEq, Serialize, TS)] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))] pub enum FontSizing { Fixed(NonZeroU32), @@ -81,7 +82,7 @@ impl Default for FontSizing { } /// Text / Label 渲染组件;布局由同一 Entity 上独立的 `Control` 提供。 -#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)] +#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize, TS)] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))] pub struct TextComponent { pub content: String, @@ -93,6 +94,7 @@ pub struct TextComponent { pub alignment: TextAlignment, pub horizontal_overflow: HorizontalTextOverflow, pub vertical_overflow: VerticalTextOverflow, + #[schemars(with = "f32")] #[ts(as = "f32")] pub line_spacing: StrictlyPositiveFinite, } diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/node.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/node.rs index 66fadf84e..bd0746fd0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/node.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/node.rs @@ -24,7 +24,8 @@ pub enum NodeSource { #[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))] -pub enum NodeStatus { +pub enum StageStatus { + Pending, Passed, NeedReview(String), // reason inside Blocked, @@ -35,6 +36,9 @@ pub enum NodeStatus { pub struct NodeMetadata { pub name: String, pub description: String, - pub status: NodeStatus, + pub layout_status: StageStatus, + pub components_status: StageStatus, + pub allow_llm_edit_layout: bool, + pub allow_llm_edit_component: bool, pub source: NodeSource, } diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/resource/sprite.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/resource/sprite.rs index abe5e171f..290a93ff4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/resource/sprite.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/resource/sprite.rs @@ -65,19 +65,19 @@ impl Default for SpriteBorder { #[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))] pub struct SpriteAssetMetadata { - name: String, + pub(crate) name: String, // TODO - asset_type: String, + pub(crate) asset_type: String, } #[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))] pub struct SpriteAsset { - asset_id: SpriteAssetId, - metadata: SpriteAssetMetadata, + pub(crate) asset_id: SpriteAssetId, + pub(crate) metadata: SpriteAssetMetadata, // TODO - path: String, + pub(crate) path: String, #[ts(as = "[f32; 2]")] - pixel_size: Vector2, + pub(crate) pixel_size: Vector2, #[ts(as = "f32")] pixels_per_unit: StrictlyPositiveFinite, border: SpriteBorder, 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 ab001e162..982fed804 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 @@ -27,3 +27,5 @@ - 各 AI 阶段使用独立的输入/输出契约;后续阶段显式消费前一阶段结果,不从隐含的临时上下文推断前置事实。 - AI 阶段的结果通过命令返回并更新同一个内存 `State`;阶段内部 DTO 只用于当前调用,不成为额外持久状态或跨阶段隐式参数。 - 阶段命令可以只返回该阶段的 DTO;调用方负责将 DTO 显式合并到同一个内存 `State`。仅用于调试的验证入口与正式工作流分离。 + +- 节点布局与组件是独立通道:`layout_status` / `components_status` 分别记录阶段状态,`allow_llm_edit_layout` / `allow_llm_edit_component` 分别授权 LLM 修改;人工编辑不隐式改变状态或授权。页面根节点可承载组件,但其 Transform 不可修改。 diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/binding.ts b/apps/ai-game-creator-shell/src/features/ui-editor/binding.ts new file mode 100644 index 000000000..0c936fc1c --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/binding.ts @@ -0,0 +1,19 @@ +import type { BindingDTO } from './types/BindingDTO'; +import type { Node } from './types/Node'; +import type { State } from './types/State'; + +function applyChanges(node: Node, result: BindingDTO): void { + const change = result.changes.find((candidate) => candidate.node_id === node.id); + if (change) { + node.components = structuredClone(change.components); + node.metadata.components_status = structuredClone(change.components_status); + } + for (const child of node.children) applyChanges(child, result); +} + +/** Applies only explicit component changes; omitted nodes remain untouched. */ +export function applyBindingResult(state: State, result: BindingDTO): State { + const next = structuredClone(state); + for (const tree of next.ui_trees) applyChanges(tree.root, result); + return next; +} diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/types/BindingChange.ts b/apps/ai-game-creator-shell/src/features/ui-editor/types/BindingChange.ts new file mode 100644 index 000000000..ec71edbfd --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/types/BindingChange.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Component } from "./Component"; +import type { NodeId } from "./NodeId"; +import type { StageStatus } from "./StageStatus"; + +export type BindingChange = { node_id: NodeId, components: Array, components_status: StageStatus, }; diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/types/BindingDTO.ts b/apps/ai-game-creator-shell/src/features/ui-editor/types/BindingDTO.ts new file mode 100644 index 000000000..beb340894 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/types/BindingDTO.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { BindingChange } from "./BindingChange"; + +export type BindingDTO = { changes: Array, }; diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/types/NodeMetadata.ts b/apps/ai-game-creator-shell/src/features/ui-editor/types/NodeMetadata.ts index 012fe1fa0..43aee349e 100644 --- a/apps/ai-game-creator-shell/src/features/ui-editor/types/NodeMetadata.ts +++ b/apps/ai-game-creator-shell/src/features/ui-editor/types/NodeMetadata.ts @@ -1,5 +1,5 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { NodeSource } from "./NodeSource"; -import type { NodeStatus } from "./NodeStatus"; +import type { StageStatus } from "./StageStatus"; -export type NodeMetadata = { name: string, description: string, status: NodeStatus, source: NodeSource, }; +export type NodeMetadata = { name: string, description: string, layout_status: StageStatus, components_status: StageStatus, allow_llm_edit_layout: boolean, allow_llm_edit_component: boolean, source: NodeSource, }; diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/types/NodeStatus.ts b/apps/ai-game-creator-shell/src/features/ui-editor/types/StageStatus.ts similarity index 55% rename from apps/ai-game-creator-shell/src/features/ui-editor/types/NodeStatus.ts rename to apps/ai-game-creator-shell/src/features/ui-editor/types/StageStatus.ts index cd837ba75..074fcfc26 100644 --- a/apps/ai-game-creator-shell/src/features/ui-editor/types/NodeStatus.ts +++ b/apps/ai-game-creator-shell/src/features/ui-editor/types/StageStatus.ts @@ -1,3 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type NodeStatus = "Passed" | { "NeedReview": string } | "Blocked"; +export type StageStatus = "Pending" | "Passed" | { "NeedReview": string } | "Blocked"; 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 f6c9973d5..757e5062e 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 @@ -5,6 +5,7 @@ import type { Component } from './types/Component'; import type { Node } from './types/Node'; import type { NodeId } from './types/NodeId'; import type { NodeMetadata } from './types/NodeMetadata'; +import type { StageStatus } from './types/StageStatus'; import type { SpriteAsset } from './types/SpriteAsset'; import type { SpriteAssetId } from './types/SpriteAssetId'; import type { SpriteBorder } from './types/SpriteBorder'; @@ -35,7 +36,15 @@ export type UiEditorOperationResult = type UiEditorOperationFailure = Extract; export type NodeMetadataPatch = Partial< - Pick + Pick< + NodeMetadata, + | 'name' + | 'description' + | 'layout_status' + | 'components_status' + | 'allow_llm_edit_layout' + | 'allow_llm_edit_component' + > >; export type ComponentIndex = number; @@ -90,7 +99,10 @@ function createPageRoot(state: State): Node { metadata: { name: '页面根节点', description: '', - status: 'Passed', + layout_status: 'Passed', + components_status: 'Pending', + allow_llm_edit_layout: true, + allow_llm_edit_component: true, source: 'System', }, components: [], @@ -110,7 +122,10 @@ function createHumanNode(state: State): Node { metadata: { name: '新节点', description: '', - status: { NeedReview: '手动新增,待编辑' }, + layout_status: 'Passed', + components_status: 'Pending', + allow_llm_edit_layout: true, + allow_llm_edit_component: true, source: 'Human', }, components: [], @@ -882,8 +897,6 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { if (!tree) return { ok: false, reason: 'missing' }; const location = findNodeLocation(tree.root, nodeId); if (!location) return { ok: false, reason: 'missing' }; - if (location.node.id === tree.root.id) - return { ok: false, reason: 'invalid' }; const next = cloneState(current); const nextTree = next.ui_trees.find( (candidate) => candidate.src_ui_design === treeId, @@ -919,10 +932,7 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { if (!tree) return { ok: false, reason: 'missing' }; const location = findNodeLocation(tree.root, nodeId); if (!location) return { ok: false, reason: 'missing' }; - if ( - location.node.id === tree.root.id || - index > location.node.components.length - ) { + if (index > location.node.components.length) { return { ok: false, reason: 'invalid' }; } const next = cloneState(current); @@ -955,10 +965,7 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { if (!tree) return { ok: false, reason: 'missing' }; const location = findNodeLocation(tree.root, nodeId); if (!location) return { ok: false, reason: 'missing' }; - if ( - location.node.id === tree.root.id || - index >= location.node.components.length - ) { + if (index >= location.node.components.length) { return { ok: false, reason: 'invalid' }; } const next = cloneState(current); @@ -999,7 +1006,6 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { const location = findNodeLocation(tree.root, nodeId); if (!location) return { ok: false, reason: 'missing' }; if ( - location.node.id === tree.root.id || fromIndex >= location.node.components.length || toIndex >= location.node.components.length ) { @@ -1045,6 +1051,14 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { if (patch.name !== undefined) node.metadata.name = patch.name; if (patch.description !== undefined) node.metadata.description = patch.description; + if (patch.layout_status !== undefined) + node.metadata.layout_status = patch.layout_status; + if (patch.components_status !== undefined) + node.metadata.components_status = patch.components_status; + if (patch.allow_llm_edit_layout !== undefined) + node.metadata.allow_llm_edit_layout = patch.allow_llm_edit_layout; + if (patch.allow_llm_edit_component !== undefined) + node.metadata.allow_llm_edit_component = patch.allow_llm_edit_component; commit(next); return { ok: true, value: undefined }; }, 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 22ba567eb..281f268cd 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 @@ -67,7 +67,8 @@ export function InspectorSidebar({ void; onMetadataChange: UiEditorPageController['setNodeMetadata']; @@ -224,7 +227,7 @@ function NodeInspector({