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 index 4585eee85..17979cbca 100644 --- 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 @@ -5,11 +5,9 @@ use crate::ui_editor::commands::utils::{ strict_json_schema, }; use crate::ui_editor::component::text::FontSource; -use crate::ui_editor::component::Component; +use crate::ui_editor::component::{Component, NodeComponent}; use crate::ui_editor::layout::node::{Node, StageStatus}; -use crate::ui_editor::persistence::{ - UI_DESIGN_STATE_MAX_COMPONENTS_PER_NODE, UI_DESIGN_STATE_MAX_NODES, -}; +use crate::ui_editor::persistence::UI_DESIGN_STATE_MAX_NODES; use crate::ui_editor::state::State; use crate::ui_editor::utils::{FontAssetId, NodeId, SpriteAssetId}; use platform_llm::{ @@ -31,10 +29,10 @@ const SYSTEM_PROMPT: &str = r#" 你是游戏 UI 组件绑定器。你会看到全部 UI 参考图、可编辑节点说明,以及本批独立素材的真实像素。 * 只对视觉上确实需要改变组件的节点返回 changes; -* 每个 change 的 components 是该节点完整的新渲染栈,空数组表示明确清空。数组顺序从底到顶渲染。 -* 对每个 Component,直接完整返回其全部参数. +* 每个 change 的 component 是该节点完整的新组件;纯结构节点返回 "PureNode",有组件返回 {"WithComponent": <完整 Component>}。 +* 对 Component,直接完整返回其全部参数. * 有任何困难或者不确定把状态设为 NeedReview,说明中文原因。 -* 纯结构节点可以返回空数组并标为 NoProblem。 +* 纯结构节点可以返回 "PureNode" 并标为 NoProblem。 * 容器背景等推荐使用Simple + preserve_aspect: false 实现与node大小一致 * 面向用户的 reason 使用中文。 @@ -53,8 +51,8 @@ enum DraftStatus { #[schemars(deny_unknown_fields)] struct BindingChangeDraft { node_id: NodeId, - components: Vec, - components_status: DraftStatus, + component: NodeComponent, + component_status: DraftStatus, } #[derive(Clone, Debug, Deserialize, JsonSchema)] @@ -68,8 +66,8 @@ struct BindingResponse { #[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, + pub component: NodeComponent, + pub component_status: StageStatus, } #[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)] @@ -83,7 +81,7 @@ struct EditableNodeContext<'a> { node_id: &'a NodeId, name: &'a str, description: &'a str, - components: &'a [Component], + component: Option<&'a Component>, } #[derive(Debug, Serialize)] @@ -106,7 +104,7 @@ fn collect_editable_nodes<'a>(node: &'a Node, output: &mut Vec UI_DESIGN_STATE_MAX_COMPONENTS_PER_NODE { - return Err(format!( - "单个组件绑定栈不能超过 {UI_DESIGN_STATE_MAX_COMPONENTS_PER_NODE} 个组件" - )); + if !change + .as_object() + .is_some_and(|object| object.contains_key("component")) + { + return Err("组件绑定 change 缺少 component 字段".to_string()); } } Ok(()) @@ -212,7 +207,7 @@ fn validate_and_materialize( if !changed_ids.insert(change.node_id.clone()) { return Err(format!("组件绑定重复返回节点:{}", change.node_id.as_str())); } - for component in &change.components { + if let NodeComponent::WithComponent(component) = &change.component { match component { Component::Image(image) => { if image @@ -232,18 +227,22 @@ fn validate_and_materialize( } } } - let components = change.components; - let components_status = match change.components_status { + let component_status = match change.component_status { DraftStatus::NoProblem => StageStatus::NoProblem, DraftStatus::NeedReview(reason) if reason.trim().is_empty() => { return Err("组件待审状态必须包含原因".to_string()) } - DraftStatus::NeedReview(reason) => StageStatus::NeedReview(reason), + DraftStatus::NeedReview(reason) => { + if matches!(&change.component, NodeComponent::PureNode) { + return Err("纯结构节点不能标记为组件待审".to_string()); + } + StageStatus::NeedReview(reason) + } }; materialized.push(BindingChange { node_id: change.node_id, - components, - components_status, + component: change.component, + component_status, }); } Ok(BindingDTO { @@ -440,8 +439,8 @@ mod tests { ]); let unapproved = BindingChangeDraft { node_id: id("other"), - components: Vec::new(), - components_status: DraftStatus::NoProblem, + component: NodeComponent::PureNode, + component_status: DraftStatus::NoProblem, }; assert!( validate_and_materialize(vec![unapproved], &editable, &known, &HashSet::new()).is_err() @@ -450,7 +449,7 @@ mod tests { // References to sprites from another batch are allowed once they exist in the project. let other_batch = BindingChangeDraft { node_id: id("editable"), - components: vec![Component::Image( + component: NodeComponent::WithComponent(Component::Image( crate::ui_editor::component::image::ImageComponent { target_graphic: Some( SpriteAssetId::new("other-batch-sprite").expect("valid sprite"), @@ -459,8 +458,8 @@ mod tests { preserve_aspect: false, }, }, - )], - components_status: DraftStatus::NoProblem, + )), + component_status: DraftStatus::NoProblem, }; assert!( validate_and_materialize(vec![other_batch], &editable, &known, &HashSet::new()).is_ok() @@ -469,15 +468,15 @@ mod tests { // References to sprites that do not exist in the project at all are still rejected. let unknown = BindingChangeDraft { node_id: id("editable"), - components: vec![Component::Image( + component: NodeComponent::WithComponent(Component::Image( crate::ui_editor::component::image::ImageComponent { target_graphic: Some(SpriteAssetId::new("unknown").expect("valid sprite")), image_type: crate::ui_editor::component::image::ImageType::Simple { preserve_aspect: false, }, }, - )], - components_status: DraftStatus::NoProblem, + )), + component_status: DraftStatus::NoProblem, }; assert!( validate_and_materialize(vec![unknown], &editable, &known, &HashSet::new()).is_err() @@ -491,8 +490,8 @@ mod tests { text.font = FontSource::Bound(FontAssetId::new("unknown-font").expect("valid font")); let change = BindingChangeDraft { node_id: id("editable"), - components: vec![Component::Text(text)], - components_status: DraftStatus::NoProblem, + component: NodeComponent::WithComponent(Component::Text(text)), + component_status: DraftStatus::NoProblem, }; let error = validate_and_materialize( @@ -506,13 +505,13 @@ mod tests { } #[test] - fn materialization_preserves_changed_only_empty_component_lists() { + fn materialization_preserves_pure_node_change() { let editable = HashSet::from([id("editable")]); let result = validate_and_materialize( vec![BindingChangeDraft { node_id: id("editable"), - components: Vec::new(), - components_status: DraftStatus::NoProblem, + component: NodeComponent::PureNode, + component_status: DraftStatus::NoProblem, }], &editable, &HashSet::new(), @@ -520,8 +519,28 @@ mod tests { ) .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::NoProblem); + assert!(matches!( + result.changes[0].component, + NodeComponent::PureNode + )); + assert_eq!(result.changes[0].component_status, StageStatus::NoProblem); + } + + #[test] + fn materialization_rejects_problematic_pure_node() { + let editable = HashSet::from([id("editable")]); + let error = validate_and_materialize( + vec![BindingChangeDraft { + node_id: id("editable"), + component: NodeComponent::PureNode, + component_status: DraftStatus::NeedReview("缺少可确认的组件".to_string()), + }], + &editable, + &HashSet::new(), + &HashSet::new(), + ) + .expect_err("pure node cannot carry a component review status"); + assert!(error.contains("纯结构节点")); } #[test] @@ -575,20 +594,26 @@ mod tests { } #[test] - fn binding_response_bounds_changes_and_each_component_stack() { + fn binding_response_bounds_changes_and_uses_single_component_shape() { let too_many_changes = serde_json::json!({ - "changes": [{"components": []}, {"components": []}] + "changes": [{"component": "PureNode"}, {"component": "PureNode"}] }); assert!(validate_binding_response_shape(&too_many_changes, 1).is_err()); - let too_many_components = serde_json::json!({ + let one_component = serde_json::json!({ "changes": [{ - "components": (0..=UI_DESIGN_STATE_MAX_COMPONENTS_PER_NODE) - .map(|_| serde_json::Value::Null) - .collect::>() + "node_id": "editable", + "component": "PureNode", + "component_status": "NoProblem" }] }); - assert!(validate_binding_response_shape(&too_many_components, 1).is_err()); + assert!(validate_binding_response_shape(&one_component, 1).is_ok()); + let parsed = parse_binding_response(&one_component.to_string(), 1) + .expect("explicit PureNode payload should parse"); + assert!(matches!( + parsed.changes[0].component, + NodeComponent::PureNode + )); } #[tokio::test] 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 e8a789948..9fe0ad71f 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 @@ -295,12 +295,12 @@ mod materialize { name: container_name, description: container_description, layout_status: StageStatus::NoProblem, - components_status: StageStatus::NoProblem, + component_status: StageStatus::NoProblem, allow_llm_edit_layout: true, allow_llm_edit_component: true, source: NodeSource::Llm, }, - components: Vec::new(), + component: None, children_display_mode: ChildrenDisplayMode::Exclusive, children: members.into_iter().map(|member| member.node).collect(), }, @@ -542,12 +542,12 @@ mod tests { name: id.to_string(), description: String::new(), layout_status: StageStatus::NoProblem, - components_status: StageStatus::NoProblem, + component_status: StageStatus::NoProblem, allow_llm_edit_layout: true, allow_llm_edit_component: true, source: NodeSource::Human, }, - components: Vec::::new(), + component: None, children_display_mode: ChildrenDisplayMode::Stack, children, } @@ -586,7 +586,7 @@ mod tests { ChildrenDisplayMode::Exclusive ); assert_eq!( - result.root.metadata.components_status, + result.root.metadata.component_status, StageStatus::NoProblem ); assert_eq!(result.root.children.len(), 2); 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 ccfb53ac3..49750b04e 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 @@ -4,7 +4,7 @@ use crate::ui_editor::commands::utils::{ parse_limited_llm_tool_arguments, read_ui_reference_image_data_url, request_ui_editor_llm, strict_json_schema, }; -use crate::ui_editor::component::Component; +use crate::ui_editor::component::{Component, NodeComponent}; use crate::ui_editor::layout::children_display_mode::ChildrenDisplayMode; use crate::ui_editor::layout::control_layout::ControlLayout; use crate::ui_editor::layout::dimension::UIRect; @@ -44,11 +44,11 @@ const SYSTEM_PROMPT: &str = r#" * 由于每个截图未必是完整的, 可能是局部的, 每棵树描述清楚每个截图上UI的层次结构即可 * 不同树的共用框架/层次/...请使用使用相同的名称描述. 不同状态/变体名称使用相同的前缀, 用后缀区别 * 粒度要求: 尽可能细致, 以可交互,方便程序化控制的最小单位为准. 包括不限于: icon, 进度条的底槽、填充和外框; slider的底槽, dragger等. -* 为每个节点直接返回完整 components. - 无背景的逻辑容器返回空数组. + * 为每个节点直接返回 component. + 无背景的逻辑容器返回 "PureNode",不要返回 null. 有背景的容器推荐使用Simple+不锁定宽高比的Image component. 目前我们只做识别, 不要求图片字体参数. - 每个节点当前最多返回一个 Image component 和一个 Text component。 + 每个节点最多返回一个 component;需要多个视觉层时拆成多个节点。 文字组件要求: 艺术字等作为图片组件, 其余正常文字要作为单独的节点识别. "#; @@ -113,7 +113,7 @@ struct RecognitionNode { description: String, children: Vec, confidence: Confidence, - components: Vec, + component: NodeComponent, } #[derive(Clone, Debug, Deserialize, PartialEq, Serialize, JsonSchema)] @@ -312,12 +312,12 @@ fn convert_node( name: source.name.clone(), description: source.description.clone(), layout_status: status, - components_status: StageStatus::NoProblem, + component_status: StageStatus::NoProblem, allow_llm_edit_layout: true, allow_llm_edit_component: true, source: NodeSource::Llm, }, - components: source.components.clone(), + component: source.component.clone().into_option(), children_display_mode: ChildrenDisplayMode::Stack, children, }) @@ -325,40 +325,25 @@ fn convert_node( fn validate_confidence(nodes: &[RecognitionNode]) -> Result<(), String> { for node in nodes { - let image_count = node - .components - .iter() - .filter(|component| matches!(component, Component::Image(_))) - .count(); - let text_count = node - .components - .iter() - .filter(|component| matches!(component, Component::Text(_))) - .count(); - if image_count > 1 || text_count > 1 { - return Err("单个节点当前最多包含一个 Image 和一个 Text component".to_string()); - } - if node.components.iter().any(|component| { - matches!( + if let NodeComponent::WithComponent(component) = &node.component { + if matches!( component, Component::Image(crate::ui_editor::component::image::ImageComponent { target_graphic: Some(_), .. }) - ) - }) { - return Err("识别阶段不能返回已绑定的 SpriteAssetId".to_string()); - } - if node.components.iter().any(|component| { - matches!( + ) { + return Err("识别阶段不能返回已绑定的 SpriteAssetId".to_string()); + } + if matches!( component, Component::Text(crate::ui_editor::component::text::TextComponent { font: crate::ui_editor::component::text::FontSource::Bound(_), .. }) - ) - }) { - return Err("识别阶段不能返回已绑定的字体素材".to_string()); + ) { + return Err("识别阶段不能返回已绑定的字体素材".to_string()); + } } if let Confidence::UnSure(reason) = &node.confidence { if reason.trim().is_empty() { @@ -424,7 +409,7 @@ mod tests { description: String::new(), children: Vec::new(), confidence: Confidence::Confident, - components: Vec::new(), + component: NodeComponent::PureNode, } } @@ -528,7 +513,7 @@ mod tests { description: String::new(), children: Vec::new(), confidence: Confidence::UnSure(String::new()), - components: Vec::new(), + component: NodeComponent::PureNode, }; assert!(validate_confidence(&[node]).is_err()); } @@ -552,7 +537,7 @@ mod tests { description: String::new(), children: Vec::new(), confidence: Confidence::Confident, - components: vec![Component::Text(text)], + component: NodeComponent::WithComponent(Component::Text(text)), }; assert!(validate_confidence(&[node]).is_err()); } @@ -569,7 +554,7 @@ mod tests { converted.layout.transform.resolve(&root_rect), UIRect::new(Point2::new(50.0, 25.0), Vector2::new(100.0, 50.0)), ); - assert_eq!(converted.metadata.components_status, StageStatus::NoProblem); + assert_eq!(converted.metadata.component_status, StageStatus::NoProblem); } #[test] @@ -850,12 +835,12 @@ pub(crate) async fn recognize_ui_impl_with_provider( name: "页面根节点".to_string(), description: String::new(), layout_status: StageStatus::NoProblem, - components_status: StageStatus::NoProblem, + component_status: StageStatus::NoProblem, allow_llm_edit_layout: true, allow_llm_edit_component: true, source: NodeSource::System, }, - components: Vec::new(), + component: None, children_display_mode: ChildrenDisplayMode::Stack, children, }; diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/mod.rs index a68f7ed13..4dadea3a6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/mod.rs @@ -16,8 +16,8 @@ pub(crate) use workflow::separate_ui_impl; mod tests { use super::*; use crate::ui_editor::component::image::{ImageComponent, ImageType}; - use crate::ui_editor::component::Component; use crate::ui_editor::component::text::TextComponent; + use crate::ui_editor::component::Component; use crate::ui_editor::layout::children_display_mode::ChildrenDisplayMode; use crate::ui_editor::layout::control_layout::ControlLayout; use crate::ui_editor::layout::node::Node; @@ -30,7 +30,7 @@ mod tests { use std::path::Path; use typed_floats::tf32::StrictlyPositiveFinite; - fn node(id: &str, components: Vec, children: Vec) -> Node { + fn node(id: &str, component: Option, children: Vec) -> Node { Node { id: NodeId::new(id).unwrap(), layout: ControlLayout::default(), @@ -38,12 +38,12 @@ mod tests { name: id.to_string(), description: String::new(), layout_status: StageStatus::NoProblem, - components_status: StageStatus::NoProblem, + component_status: StageStatus::NoProblem, allow_llm_edit_layout: true, allow_llm_edit_component: true, source: NodeSource::Llm, }, - components, + component, children_display_mode: ChildrenDisplayMode::Stack, children, } @@ -83,11 +83,11 @@ mod tests { }); let root = node( "root", - vec![], + None, vec![node( "container", - vec![], - vec![node("image", vec![image], vec![])], + None, + vec![node("image", Some(image), vec![])], )], ); let result = construct_separation_state(&state(root)); @@ -102,7 +102,7 @@ mod tests { preserve_aspect: false, }, }); - let root = node("root-image", vec![image], vec![]); + let root = node("root-image", Some(image), vec![]); let result = construct_separation_state(&state(root)); let tree = &result.trees[0]; assert_eq!(tree.root.id.as_str(), "root-image"); @@ -120,11 +120,11 @@ mod tests { let text = Component::Text(TextComponent::new("按钮")); let root = node( "root", - vec![], + None, vec![node( "outer-image", - vec![image.clone()], - vec![node("text", vec![text.clone()], vec![])], + Some(image.clone()), + vec![node("text", Some(text.clone()), vec![])], )], ); let result = construct_separation_state(&state(root)); @@ -135,14 +135,14 @@ mod tests { let nested_root = node( "root", - vec![], + None, vec![node( "outer-image", - vec![image.clone()], + Some(image.clone()), vec![node( "inner-image", - vec![image], - vec![node("text", vec![text], vec![])], + Some(image), + vec![node("text", Some(text), vec![])], )], )], ); @@ -156,15 +156,15 @@ mod tests { fn root_image_receives_text_mask() { let root = node( "root-image", - vec![Component::Image(ImageComponent { + Some(Component::Image(ImageComponent { target_graphic: None, image_type: ImageType::Simple { preserve_aspect: false, }, - })], + })), vec![node( "text", - vec![Component::Text(TextComponent::new("标题"))], + Some(Component::Text(TextComponent::new("标题"))), vec![], )], ); @@ -218,8 +218,8 @@ mod tests { }); let mut separation = construct_separation_state(&state(node( "root", - vec![], - vec![node("image", vec![image], vec![])], + None, + vec![node("image", Some(image), vec![])], ))); let id = NodeId::new("image").unwrap(); let paths = HashMap::new(); @@ -302,8 +302,8 @@ mod tests { }); let mut state = construct_separation_state(&state(node( "root", - vec![], - vec![node("image", vec![image], vec![])], + None, + vec![node("image", Some(image), vec![])], ))); let id = NodeId::new("image").unwrap(); let decisions = vec![BindingDecision::Ok { diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/tree.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/tree.rs index 56328dfef..6cd9e2be3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/tree.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/tree.rs @@ -7,27 +7,21 @@ use std::collections::HashMap; use std::collections::HashSet; fn is_unbound_image(node: &Node) -> bool { - node.components.iter().any(|component| { - matches!( - component, - Component::Image(ImageComponent { - target_graphic: None, - .. - }) - ) - }) + matches!( + node.component.as_ref(), + Some(Component::Image(ImageComponent { + target_graphic: None, + .. + })) + ) } fn has_image_component(node: &Node) -> bool { - node.components - .iter() - .any(|component| matches!(component, Component::Image(_))) + matches!(node.component.as_ref(), Some(Component::Image(_))) } fn has_text_component(node: &Node) -> bool { - node.components - .iter() - .any(|component| matches!(component, Component::Text(_))) + matches!(node.component.as_ref(), Some(Component::Text(_))) } fn node_pixel_rect( 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 14ab9c854..664f40e42 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 @@ -9,3 +9,27 @@ pub enum Component { Image(image::ImageComponent), Text(text::TextComponent), } + +/// LLM 工具返回的节点组件载荷。 +/// +/// 这里不能直接使用 `Option`:部分模型在严格工具 schema 下不会稳定地产生 +/// `null`。用显式的 `PureNode` / `WithComponent` 外部枚举表达两种情况,既保留纯结构节点 +/// 的语义,也让工具调用始终返回一个可判别的对象;落入编辑器 `Node` 时再映射为 +/// `Option`。 +#[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 NodeComponent { + PureNode, + WithComponent(Component), +} + +impl NodeComponent { + pub fn into_option(self) -> Option { + match self { + Self::PureNode => None, + Self::WithComponent(component) => Some(component), + } + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/mod.rs index e0c8199e7..e908a3117 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/mod.rs @@ -199,14 +199,13 @@ fn render_node_with_scale( json!({"childrenDisplayMode": "Exclusive", "childrenRendered": "all"}), ) }); - let components = node - .components - .iter() + let component = node + .component + .as_ref() .map(|component| render_component(state, component)) - .collect::, _>>()? - .into_iter() + .transpose()? .map(|fragment| fragment.into_string()) - .collect::>(); + .unwrap_or_default(); let children = node .children .iter() @@ -226,7 +225,7 @@ fn render_node_with_scale( (comment) @if let Some(group_comment) = exclusive_comment { (group_comment) } div ui-node-id=(node.id.as_str()) style=(style) { - (PreEscaped(components.concat())) + (PreEscaped(component)) (PreEscaped(children.concat())) } }) 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 9375e913f..be70cdca6 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 @@ -11,7 +11,7 @@ pub struct Node { pub id: NodeId, pub layout: ControlLayout, pub metadata: NodeMetadata, - pub components: Vec, + pub component: Option, pub children_display_mode: ChildrenDisplayMode, pub children: Vec, } @@ -50,7 +50,7 @@ pub struct NodeMetadata { pub name: String, pub description: String, pub layout_status: StageStatus, - pub components_status: StageStatus, + pub component_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/persistence.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/persistence.rs index 2384476b0..ab4c07c78 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/persistence.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/persistence.rs @@ -25,7 +25,6 @@ const UI_DESIGN_STATE_MAX_IMAGES: usize = 4; const UI_DESIGN_STATE_MAX_SPRITES: usize = 1_024; pub(crate) const UI_DESIGN_STATE_MAX_NODES: usize = 10_000; const UI_DESIGN_STATE_MAX_DEPTH: usize = 128; -pub(crate) 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)] @@ -747,12 +746,8 @@ fn validate_node( } } } - 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 { + validate_component_status(node.component.as_ref(), &node.metadata.component_status)?; + if let Some(component) = &node.component { match component { Component::Image(image) => { if image @@ -778,6 +773,22 @@ fn validate_node( Ok(()) } +fn validate_component_status( + component: Option<&Component>, + status: &crate::ui_editor::layout::node::StageStatus, +) -> Result<(), String> { + if component.is_none() + && matches!( + status, + crate::ui_editor::layout::node::StageStatus::NeedReview(_) + | crate::ui_editor::layout::node::StageStatus::Blocked(_) + ) + { + return Err("纯结构节点的 component_status 必须为 NoProblem".to_string()); + } + 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} 无效")); @@ -881,12 +892,12 @@ mod tests { "name": "页面根节点", "description": "", "layout_status": "NoProblem", - "components_status": "NoProblem", + "component_status": "NoProblem", "allow_llm_edit_layout": true, "allow_llm_edit_component": true, "source": "System" }, - "components": [], + "component": null, "children_display_mode": "Stack", "children": [{ "id": "dragged-node", @@ -907,17 +918,17 @@ mod tests { "name": "拖拽节点", "description": "", "layout_status": "NoProblem", - "components_status": "NoProblem", + "component_status": "NoProblem", "allow_llm_edit_layout": true, "allow_llm_edit_component": true, "source": "Human" }, - "components": [{ + "component": { "Image": { "target_graphic": "spirit", "image_type": { "Simple": { "preserve_aspect": false } } } - }], + }, "children_display_mode": "Stack", "children": [] }] @@ -1095,12 +1106,12 @@ mod tests { "name": "根节点", "description": "", "layout_status": "NoProblem", - "components_status": "NoProblem", + "component_status": "NoProblem", "allow_llm_edit_layout": true, "allow_llm_edit_component": true, "source": "System" }, - "components": [], + "component": null, "children_display_mode": "Stack", "children": [] } @@ -1296,12 +1307,12 @@ mod tests { "name": "根节点", "description": "", "layout_status": "NoProblem", - "components_status": "NoProblem", + "component_status": "NoProblem", "allow_llm_edit_layout": false, "allow_llm_edit_component": false, "source": "System" }, - "components": [{ + "component": { "Text": { "content": "标题", "font": {"Bound": "missing-font"}, @@ -1313,7 +1324,7 @@ mod tests { "vertical_overflow": "Truncate", "line_spacing": 1.0 } - }], + }, "children_display_mode": "Stack", "children": [] } @@ -1340,4 +1351,30 @@ mod tests { .expect_err("missing Text font reference must be rejected"); assert!(error.contains("Text 组件引用了不存在的字体素材")); } + + #[test] + fn component_status_matrix_keeps_pure_nodes_unproblematic() { + use crate::ui_editor::component::image::ImageComponent; + + assert!(validate_component_status( + None, + &crate::ui_editor::layout::node::StageStatus::NoProblem, + ) + .is_ok()); + assert!(validate_component_status( + None, + &crate::ui_editor::layout::node::StageStatus::NeedReview("原因".to_string()), + ) + .is_err()); + assert!(validate_component_status( + None, + &crate::ui_editor::layout::node::StageStatus::Blocked("原因".to_string()), + ) + .is_err()); + assert!(validate_component_status( + Some(&Component::Image(ImageComponent::new())), + &crate::ui_editor::layout::node::StageStatus::NeedReview("等待素材".to_string()), + ) + .is_ok()); + } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/workflow.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/workflow.rs index 829782148..ea905233e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/workflow.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/workflow.rs @@ -1145,8 +1145,8 @@ fn apply_binding_changes( ) -> usize { let mut changed = 0; if let Some(change) = changes.get(&node.id) { - node.components = change.components.clone(); - node.metadata.components_status = change.components_status.clone(); + node.component = change.component.clone().into_option(); + node.metadata.component_status = change.component_status.clone(); changed += 1; } for child in &mut node.children { @@ -1163,7 +1163,7 @@ fn apply_binding_changes( fn state_has_renderable_component(state: &crate::ui_editor::state::State) -> bool { fn has_component(node: &Node) -> bool { - !node.components.is_empty() || node.children.iter().any(has_component) + node.component.is_some() || node.children.iter().any(has_component) } state.ui_trees.iter().any(|tree| has_component(&tree.root)) } @@ -1315,14 +1315,14 @@ fn derive_page_status( } fn collect_binding_blockers(node: &Node, component_count: &mut usize, blockers: &mut Vec) { - *component_count += node.components.len(); + *component_count += usize::from(node.component.is_some()); if let StageStatus::NeedReview(reason) | StageStatus::Blocked(reason) = &node.metadata.layout_status { blockers.push(format!("{} 布局未通过:{reason}", node.metadata.name)); } if let StageStatus::NeedReview(reason) | StageStatus::Blocked(reason) = - &node.metadata.components_status + &node.metadata.component_status { blockers.push(format!("{} 组件未通过:{reason}", node.metadata.name)); } 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 index 47a8a3a17..3f3422b18 100644 --- a/apps/ai-game-creator-shell/src/features/ui-editor/binding.ts +++ b/apps/ai-game-creator-shell/src/features/ui-editor/binding.ts @@ -7,8 +7,10 @@ function applyChanges(node: Node, result: BindingDTO): void { (candidate) => candidate.node_id === node.id, ); if (change) { - node.components = structuredClone(change.components); - node.metadata.components_status = structuredClone(change.components_status); + node.component = structuredClone( + change.component === 'PureNode' ? null : change.component.WithComponent, + ); + node.metadata.component_status = structuredClone(change.component_status); } for (const child of node.children) applyChanges(child, result); } diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/bindingOverview.ts b/apps/ai-game-creator-shell/src/features/ui-editor/bindingOverview.ts index 84e6cc6ce..fe770e022 100644 --- a/apps/ai-game-creator-shell/src/features/ui-editor/bindingOverview.ts +++ b/apps/ai-game-creator-shell/src/features/ui-editor/bindingOverview.ts @@ -90,13 +90,13 @@ export function getBindingOverview( }; for (const { node } of collectUiTreeNodeTargets(uiTrees)) { - const status = node.metadata.components_status; + const status = node.metadata.component_status; if (isBlocked(status)) overview.blocked += 1; if (isBlocked(status) || isNeedReview(status)) { overview.needsAttention += 1; } - for (const component of node.components) { - addBindingCounts(overview, getComponentBindingCounts(component)); + if (node.component) { + addBindingCounts(overview, getComponentBindingCounts(node.component)); } } @@ -104,16 +104,17 @@ export function getBindingOverview( } export function nodeHasPendingBinding(target: UiTreeNodeTarget): boolean { - return target.node.components.some( - (component) => getComponentBindingCounts(component).pendingSlots > 0, + return ( + target.node.component !== null && + getComponentBindingCounts(target.node.component).pendingSlots > 0 ); } export function nodeNeedsComponentReview(target: UiTreeNodeTarget): boolean { - const status = target.node.metadata.components_status; + const status = target.node.metadata.component_status; return isBlocked(status) || isNeedReview(status); } export function nodeHasBlockedComponents(target: UiTreeNodeTarget): boolean { - return isBlocked(target.node.metadata.components_status); + return isBlocked(target.node.metadata.component_status); } diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/requisites.ts b/apps/ai-game-creator-shell/src/features/ui-editor/requisites.ts index af3e0c929..1fcd649c8 100644 --- a/apps/ai-game-creator-shell/src/features/ui-editor/requisites.ts +++ b/apps/ai-game-creator-shell/src/features/ui-editor/requisites.ts @@ -106,7 +106,8 @@ export function validateLayoutGenerationPrerequisites( const issues = validateAssetRecognitionPrerequisites(state); const visit = (nodes: State['ui_trees'][number]['root']['children']) => { for (const node of nodes) { - for (const component of node.components) { + const component = node.component; + if (component) { if ( 'Image' in component && component.Image.target_graphic !== null && @@ -219,10 +220,10 @@ export function validateVisualBindingResult( const issues: UiEditorPrerequisiteIssue[] = []; for (const tree of state.ui_trees) { visitNodes([tree.root], (node) => { - if (node.components.length == 0) { + if (node.component === null) { return; } - const issue = componentStatusIssue(node.metadata.components_status); + const issue = componentStatusIssue(node.metadata.component_status); if (issue) issues.push(issue); }); } diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/stageStatusOverview.ts b/apps/ai-game-creator-shell/src/features/ui-editor/stageStatusOverview.ts index c2f9e9c00..80dc27fac 100644 --- a/apps/ai-game-creator-shell/src/features/ui-editor/stageStatusOverview.ts +++ b/apps/ai-game-creator-shell/src/features/ui-editor/stageStatusOverview.ts @@ -6,7 +6,7 @@ import type { UITree } from './types/UITree'; export type StageStatusField = Extract< keyof NodeMetadata, - 'layout_status' | 'components_status' + 'layout_status' | 'component_status' >; export type UiTreeNodeTarget = { 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 index ec71edbfd..3bb2c5c4d 100644 --- 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 @@ -1,6 +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 { NodeComponent } from "./NodeComponent"; import type { NodeId } from "./NodeId"; import type { StageStatus } from "./StageStatus"; -export type BindingChange = { node_id: NodeId, components: Array, components_status: StageStatus, }; +export type BindingChange = { node_id: NodeId, component: NodeComponent, component_status: StageStatus, }; diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/types/Node.ts b/apps/ai-game-creator-shell/src/features/ui-editor/types/Node.ts index c05d8743a..bac60a9ca 100644 --- a/apps/ai-game-creator-shell/src/features/ui-editor/types/Node.ts +++ b/apps/ai-game-creator-shell/src/features/ui-editor/types/Node.ts @@ -5,4 +5,4 @@ import type { ControlLayout } from "./ControlLayout"; import type { NodeId } from "./NodeId"; import type { NodeMetadata } from "./NodeMetadata"; -export type Node = { id: NodeId, layout: ControlLayout, metadata: NodeMetadata, components: Array, children_display_mode: ChildrenDisplayMode, children: Array, }; +export type Node = { id: NodeId, layout: ControlLayout, metadata: NodeMetadata, component: Component | null, children_display_mode: ChildrenDisplayMode, children: Array, }; diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/types/NodeComponent.ts b/apps/ai-game-creator-shell/src/features/ui-editor/types/NodeComponent.ts new file mode 100644 index 000000000..b3116576c --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/types/NodeComponent.ts @@ -0,0 +1,12 @@ +// 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"; + +/** + * LLM 工具返回的节点组件载荷。 + * + * 这里不能直接使用 `Option`:部分模型在严格工具 schema 下不会稳定地产生 + * `null`。用显式的 `PureNode` / `WithComponent` 外部枚举表达两种情况,既保留纯结构节点 + * 的语义,也让工具调用始终返回一个可判别的对象;落入编辑器 `Node` 时再映射为 + * `Option`。 + */ +export type NodeComponent = "PureNode" | { "WithComponent": Component }; 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 43aee349e..f822b7d4b 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 @@ -2,4 +2,4 @@ import type { NodeSource } from "./NodeSource"; import type { StageStatus } from "./StageStatus"; -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, }; +export type NodeMetadata = { name: string, description: string, layout_status: StageStatus, component_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/UIRect.ts b/apps/ai-game-creator-shell/src/features/ui-editor/types/UIRect.ts new file mode 100644 index 000000000..d1db67fd7 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/types/UIRect.ts @@ -0,0 +1,9 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * UI 布局在父级坐标系中的轴对齐矩形。 + * + * `min` 是矩形的最小角,`size` 是沿两个坐标轴的尺寸。这里不规定 Y 轴方向, + * 因而既能用于 Y 轴向上的游戏坐标,也能用于 Y 轴向下的画布坐标。 + */ +export type UIRect = { min: [number, number], size: [number, number], }; 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 d04c52f93..a83ab93b4 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 @@ -64,14 +64,12 @@ export type NodeMetadataPatch = Partial< | 'name' | 'description' | 'layout_status' - | 'components_status' + | 'component_status' | 'allow_llm_edit_layout' | 'allow_llm_edit_component' > >; -export type ComponentIndex = number; - export type NodeTransformOptions = { keepChildrenUnchanged?: boolean; }; @@ -106,6 +104,12 @@ function visitNodes(node: Node, visit: (node: Node) => void): void { for (const child of node.children) visitNodes(child, visit); } +function isProblematicComponentStatus( + status: NodeMetadata['component_status'], +): boolean { + return typeof status !== 'string'; +} + function existingNodeIds(state: State): Set { const ids = new Set(); for (const tree of state.ui_trees) { @@ -144,12 +148,12 @@ function createPageRoot(state: State): Node { name: '页面根节点', description: '', layout_status: 'NoProblem', - components_status: 'NoProblem', + component_status: 'NoProblem', allow_llm_edit_layout: true, allow_llm_edit_component: true, source: 'System', }, - components: [], + component: null, children_display_mode: 'Stack', children: [], }; @@ -175,12 +179,12 @@ function createHumanNode(state: State): Node { name: '新节点', description: '', layout_status: 'NoProblem', - components_status: 'NoProblem', + component_status: 'NoProblem', allow_llm_edit_layout: true, allow_llm_edit_component: true, source: 'Human', }, - components: [], + component: null, children_display_mode: 'Stack', children: [], }; @@ -283,9 +287,7 @@ function sameResource( function visitComponents(nodes: Node[], visit: (component: Component) => void) { for (const node of nodes) { - for (const component of node.components) { - visit(component); - } + if (node.component) visit(node.component); visitComponents(node.children, visit); } } @@ -1121,15 +1123,15 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { [commit, guard], ); - const setNodeComponents = useCallback( + const setNodeComponent = useCallback( ( treeId: UIDesignImageId, nodeId: NodeId, - components: Component[], + component: Component | null, ): UiEditorOperationResult => { const blocked = guard(); if (blocked) return blocked; - if (!components.every(isValidComponent)) { + if (component && !isValidComponent(component)) { return { ok: false, reason: 'invalid' }; } const current = stateRef.current; @@ -1143,126 +1145,9 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { const nextTree = next.ui_trees.find( (candidate) => candidate.src_ui_design === treeId, )!; - findNodeLocation(nextTree.root, nodeId)!.node.components = - structuredClone(components); - commit(next); - return { ok: true, value: undefined }; - }, - [commit, guard], - ); - - const insertComponent = useCallback( - ( - treeId: UIDesignImageId, - nodeId: NodeId, - index: ComponentIndex, - component: Component, - ): UiEditorOperationResult => { - const blocked = guard(); - if (blocked) return blocked; - if ( - !isValidComponent(component) || - !Number.isInteger(index) || - index < 0 - ) { - return { ok: false, reason: 'invalid' }; - } - const current = stateRef.current; - const tree = current.ui_trees.find( - (candidate) => candidate.src_ui_design === treeId, - ); - if (!tree) return { ok: false, reason: 'missing' }; - const location = findNodeLocation(tree.root, nodeId); - if (!location) return { ok: false, reason: 'missing' }; - if (index > location.node.components.length) { - return { ok: false, reason: 'invalid' }; - } - const next = cloneState(current); - const nextNode = findNodeLocation( - next.ui_trees.find((candidate) => candidate.src_ui_design === treeId)! - .root, - nodeId, - )!.node; - nextNode.components.splice(index, 0, structuredClone(component)); - commit(next); - return { ok: true, value: undefined }; - }, - [commit, guard], - ); - - const deleteComponent = useCallback( - ( - treeId: UIDesignImageId, - nodeId: NodeId, - index: ComponentIndex, - ): UiEditorOperationResult => { - const blocked = guard(); - if (blocked) return blocked; - if (!Number.isInteger(index) || index < 0) - return { ok: false, reason: 'invalid' }; - const current = stateRef.current; - const tree = current.ui_trees.find( - (candidate) => candidate.src_ui_design === treeId, - ); - if (!tree) return { ok: false, reason: 'missing' }; - const location = findNodeLocation(tree.root, nodeId); - if (!location) return { ok: false, reason: 'missing' }; - if (index >= location.node.components.length) { - return { ok: false, reason: 'invalid' }; - } - const next = cloneState(current); - const nextNode = findNodeLocation( - next.ui_trees.find((candidate) => candidate.src_ui_design === treeId)! - .root, - nodeId, - )!.node; - nextNode.components.splice(index, 1); - commit(next); - return { ok: true, value: undefined }; - }, - [commit, guard], - ); - - const moveComponent = useCallback( - ( - treeId: UIDesignImageId, - nodeId: NodeId, - fromIndex: ComponentIndex, - toIndex: ComponentIndex, - ): UiEditorOperationResult => { - const blocked = guard(); - if (blocked) return blocked; - if ( - !Number.isInteger(fromIndex) || - !Number.isInteger(toIndex) || - fromIndex < 0 || - toIndex < 0 - ) { - return { ok: false, reason: 'invalid' }; - } - const current = stateRef.current; - const tree = current.ui_trees.find( - (candidate) => candidate.src_ui_design === treeId, - ); - if (!tree) return { ok: false, reason: 'missing' }; - const location = findNodeLocation(tree.root, nodeId); - if (!location) return { ok: false, reason: 'missing' }; - if ( - fromIndex >= location.node.components.length || - toIndex >= location.node.components.length - ) { - return { ok: false, reason: 'invalid' }; - } - if (fromIndex === toIndex) return { ok: true, value: undefined }; - const next = cloneState(current); - const nextNode = findNodeLocation( - next.ui_trees.find((candidate) => candidate.src_ui_design === treeId)! - .root, - nodeId, - )!.node; - const [component] = nextNode.components.splice(fromIndex, 1); - if (!component) return { ok: false, reason: 'invalid' }; - nextNode.components.splice(toIndex, 0, component); + const nextNode = findNodeLocation(nextTree.root, nodeId)!.node; + nextNode.component = structuredClone(component); + nextNode.metadata.component_status = 'NoProblem'; commit(next); return { ok: true, value: undefined }; }, @@ -1290,13 +1175,20 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { .root, nodeId, )!.node; + if ( + patch.component_status !== undefined && + node.component === null && + isProblematicComponentStatus(patch.component_status) + ) { + return { ok: false, reason: 'invalid' }; + } 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.component_status !== undefined) + node.metadata.component_status = patch.component_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) @@ -1575,10 +1467,7 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { deleteNode, setNodeTransform, setNodeLayout, - setNodeComponents, - insertComponent, - deleteComponent, - moveComponent, + setNodeComponent, setNodeMetadata, setNodeChildrenDisplayMode, moveNode, diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/ImportOverview.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/ImportOverview.tsx index cd76e9fec..0823299bd 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/ImportOverview.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/ImportOverview.tsx @@ -6,7 +6,7 @@ import type { UITree } from '../../../features/ui-editor/types/UITree'; function countUiComponents(nodes: UiNode[]): number { return nodes.reduce( (total, node) => - total + node.components.length + countUiComponents(node.children), + total + (node.component ? 1 : 0) + countUiComponents(node.children), 0, ); } diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/InputSidebar.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/InputSidebar.tsx index 495db5bd9..b473accb2 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/InputSidebar.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/InputSidebar.tsx @@ -62,12 +62,12 @@ export function InputSidebar({ input }: { input: UiEditorInputProjection }) { name: 'UI Trees', description: '', layout_status: 'NoProblem', - components_status: 'NoProblem', + component_status: 'NoProblem', allow_llm_edit_layout: false, allow_llm_edit_component: false, source: 'System', }, - components: [], + component: null, children_display_mode: 'Stack', children: uiTrees.map((tree) => tree.root), }; diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/ComponentPanel.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/ComponentPanel.tsx index ab030488d..89d20155c 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/ComponentPanel.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/ComponentPanel.tsx @@ -1,12 +1,5 @@ -import { - ArrowDown, - ArrowUp, - ChevronDown, - ChevronRight, - Plus, - Trash2, -} from 'lucide-react'; -import { useEffect, useState } from 'react'; +import { ChevronDown, ChevronRight, Plus, Trash2 } from 'lucide-react'; +import { useState } from 'react'; import type { Component } from '../../../../../features/ui-editor/types/Component'; import type { UiEditorOperationResult } from '../../../../../features/ui-editor/useUiEditorState'; @@ -18,111 +11,30 @@ import { TextPanel } from './TextPanel'; import { createDefaultTextComponent } from './TextPanelDefaults'; export function ComponentPanel(props: ComponentPanelProps) { - const { - components, - readOnly: propReadOnly, - onSetComponents, - onInsertComponent, - onDeleteComponent, - onMoveComponent, - } = props; + const { component, readOnly: propReadOnly, onSetComponent } = props; const inspectorReadOnly = useInspectorReadOnly(); const readOnly = propReadOnly || inspectorReadOnly; const [addKind, setAddKind] = useState<'Image' | 'Text'>('Image'); - const [expandedIndexes, setExpandedIndexes] = useState>( - () => new Set(), - ); + const [expanded, setExpanded] = useState(Boolean(component)); const [error, setError] = useState(null); - useEffect(() => { - setExpandedIndexes((current) => { - const next = new Set( - [...current].filter((index) => index >= 0 && index < components.length), - ); - if (next.size === current.size) return current; - return next; - }); - }, [components.length]); - - const updateComponent = (index: number, next: Component) => { + function setComponent(next: Component | null) { if (readOnly) return undefined; - const nextComponents = components.slice(); - nextComponents[index] = next; - const result = onSetComponents(nextComponents); - if (result && !result.ok) setError('组件字段无效,更新未应用。'); + const result = onSetComponent(next); + if (result && !result.ok) setError('组件更新失败。'); else setError(null); return result; - }; - - function addComponent() { - if (readOnly) return; - let component: Component; - switch (addKind) { - case 'Image': - component = { Image: createDefaultImageComponent() }; - break; - case 'Text': - component = { Text: createDefaultTextComponent() }; - break; - } - const result = onInsertComponent(components.length, component); - if (result?.ok) { - setExpandedIndexes((current) => new Set(current).add(components.length)); - setError(null); - } else if (result) { - setError('组件新增失败。'); - } } - function deleteComponentAt(index: number) { - if (readOnly) return; - const result = onDeleteComponent(index); - if (result?.ok) { - setExpandedIndexes((current) => { - const next = new Set(); - for (const expanded of current) { - if (expanded === index) continue; - if (expanded > index) next.add(expanded - 1); - else next.add(expanded); - } - return next; - }); - setError(null); - } else if (result) { - setError('组件删除失败。'); - } + function createComponent(): Component { + return addKind === 'Image' + ? { Image: createDefaultImageComponent() } + : { Text: createDefaultTextComponent() }; } - function moveComponent(index: number, direction: 'up' | 'down') { - if (readOnly) return; - // Components are rendered in array order. The last item therefore sits - // visually at the top of the stack. - let nextIndex: number; - switch (direction) { - case 'up': - nextIndex = index + 1; - break; - case 'down': - nextIndex = index - 1; - break; - } - if (nextIndex < 0 || nextIndex >= components.length) return; - const result = onMoveComponent(index, nextIndex); - if (result?.ok) { - setExpandedIndexes((current) => { - const next = new Set(current); - const wasCurrentExpanded = next.has(index); - const wasTargetExpanded = next.has(nextIndex); - next.delete(index); - next.delete(nextIndex); - if (wasCurrentExpanded) next.add(nextIndex); - if (wasTargetExpanded) next.add(index); - return next; - }); - setError(null); - } else if (result) { - setError('组件顺序更新失败。'); - } + function replaceComponent() { + const result = setComponent(createComponent()); + if (result?.ok) setExpanded(true); } return ( @@ -130,7 +42,7 @@ export function ComponentPanel(props: ComponentPanelProps) {

组件

- {components.length} 个 + {component ? componentKind(component) : '无'}
@@ -142,7 +54,7 @@ export function ComponentPanel(props: ComponentPanelProps) { onChange={(event) => setAddKind(event.target.value as 'Image' | 'Text') } - aria-label="新增组件类型" + aria-label="组件类型" > @@ -151,93 +63,49 @@ export function ComponentPanel(props: ComponentPanelProps) { type="button" className="flex h-8 items-center gap-1 rounded-lg bg-blue-600 px-3 text-xs font-semibold text-white disabled:cursor-not-allowed disabled:opacity-40" disabled={readOnly} - onClick={addComponent} - aria-label="新增组件" - title="新增组件" + onClick={replaceComponent} + aria-label={component ? '替换组件' : '新增组件'} + title={component ? '替换组件' : '新增组件'} > - {componentKindLabel(addKind)} + {component ? '替换' : '新增'} - {components.length > 0 && ( -
- {[...components].reverse().map((component, reverseIndex) => { - const index = components.length - reverseIndex - 1; - const expanded = expandedIndexes.has(index); - return ( -
-
- - - - -
- {expanded && ( -
- {renderComponentEditor( - component, - index, - props, - readOnly, - updateComponent, - )} -
- )} -
- ); - })} + {component ? ( +
+
+ + +
+ {expanded && ( +
+ {renderComponentEditor(component, props, readOnly, setComponent)} +
+ )}
- )} - {components.length === 0 && ( + ) : (

当前节点没有组件。

@@ -248,72 +116,45 @@ export function ComponentPanel(props: ComponentPanelProps) { } function componentKind(component: Component): string { - switch (true) { - case 'Image' in component: - return '图片'; - case 'Text' in component: - return '文本'; - default: - return '未知'; - } -} - -function componentKindLabel(kind: 'Image' | 'Text'): string { - switch (kind) { - case 'Image': - return '图片'; - case 'Text': - return '文本'; - } -} - -function componentLayerLabel(index: number, count: number): string { - if (index === count - 1) return '顶部'; - return `层级 ${index + 1}`; -} - -function ExpandIcon({ expanded }: { expanded: boolean }) { - if (expanded) return ; - return ; + if ('Image' in component) return '图片'; + if ('Text' in component) return '文本'; + return '未知'; } function renderComponentEditor( component: Component, - index: number, props: ComponentPanelProps, readOnly: boolean, updateComponent: ( - index: number, - next: Component, + next: Component | null, ) => UiEditorOperationResult | undefined, ) { - switch (true) { - case 'Image' in component: - return ( - updateComponent(index, { Image: next })} - /> - ); - case 'Text' in component: - return ( - updateComponent(index, { Text: next })} - /> - ); - default: - return ( -

- 当前组件类型暂不支持编辑。 -

- ); + if ('Image' in component) { + return ( + updateComponent({ Image: next })} + /> + ); } + if ('Text' in component) { + return ( + updateComponent({ Text: next })} + /> + ); + } + return ( +

+ 当前组件类型暂不支持编辑。 +

+ ); } diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/componentEditorTypes.ts b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/componentEditorTypes.ts index 9d3adc06a..0319745b5 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/componentEditorTypes.ts +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/componentEditorTypes.ts @@ -7,24 +7,15 @@ import type { UiEditorFontFaceState } from '../../../../../features/ui-editor/us import type { UiEditorOperationResult } from '../../../../../features/ui-editor/useUiEditorState'; export type ComponentPanelProps = { - components: Component[]; + component: Component | null; sprites: Record; previewUrls: Record; fonts: Record; fontFaces: Record; projectPath: string; readOnly: boolean; - onSetComponents: ( - components: Component[], - ) => UiEditorOperationResult | undefined; - onInsertComponent: ( - index: number, - component: Component, - ) => UiEditorOperationResult | undefined; - onDeleteComponent: (index: number) => UiEditorOperationResult | undefined; - onMoveComponent: ( - fromIndex: number, - toIndex: number, + onSetComponent: ( + component: Component | null, ) => UiEditorOperationResult | 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 7557c3727..97c962104 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 @@ -115,10 +115,7 @@ export function InspectorSidebar({ fonts={inspector.fonts} fontFaces={inspector.fontFaces} projectPath={inspector.projectPath} - onSetComponents={inspector.setNodeComponents} - onInsertComponent={inspector.insertNodeComponent} - onDeleteComponent={inspector.deleteNodeComponent} - onMoveComponent={inspector.moveNodeComponent} + onSetComponent={inspector.setNodeComponent} onDeleteNode={() => inspector.deleteNode(view.node.id)} deleteDisabled={ inspector.isLocked || view.node.id === inspector.tree?.root.id @@ -293,10 +290,7 @@ function NodeInspector({ fonts, fontFaces, projectPath, - onSetComponents, - onInsertComponent, - onDeleteComponent, - onMoveComponent, + onSetComponent, onDeleteNode, deleteDisabled, }: { @@ -323,10 +317,7 @@ function NodeInspector({ fonts: UiEditorInspectorProjection['fonts']; fontFaces: UiEditorInspectorProjection['fontFaces']; projectPath: string; - onSetComponents: UiEditorInspectorProjection['setNodeComponents']; - onInsertComponent: UiEditorInspectorProjection['insertNodeComponent']; - onDeleteComponent: UiEditorInspectorProjection['deleteNodeComponent']; - onMoveComponent: UiEditorInspectorProjection['moveNodeComponent']; + onSetComponent: UiEditorInspectorProjection['setNodeComponent']; onDeleteNode: () => void; deleteDisabled: boolean; }) { @@ -414,7 +405,7 @@ function NodeInspector({ 来源:{node.metadata.source}
- 组件:{node.components.length} + 组件:{node.component ? 1 : 0}
@@ -434,18 +425,18 @@ function NodeInspector({ }} /> { - if (!isReadOnly) onMetadataChange({ components_status }); + onChange={(component_status) => { + if (!isReadOnly) onMetadataChange({ component_status }); }} />
@@ -498,17 +489,14 @@ function NodeInspector({ onChange={onLayoutChange} />