From 28a63eaf882e60db60c5a74e8474f743ff0fab77 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?=
Date: Mon, 17 Aug 2026 13:45:43 +0800
Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=20UI=20=E7=BB=84=E4=BB=B6?=
=?UTF-8?q?=E6=99=BA=E8=83=BD=E7=BB=91=E5=AE=9A?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
新增 Rust 组件绑定命令,向 LLM 提供真实界面与素材像素并接受完整 Component 参数。
拆分节点布局与组件状态及 LLM 编辑权限,允许根节点承载组件并保持 Transform 只读。
接入五个素材一批的前端绑定循环、增量结果合并和调试入口。
生成更新后的 TypeScript 契约,并补齐 UI Editor 状态测试。
---
.../src-tauri/src/main.rs | 10 +
.../src/ui_editor/commands/binding.rs | 330 ++++++++++++++++++
.../src-tauri/src/ui_editor/commands/merge.rs | 7 +-
.../src-tauri/src/ui_editor/commands/mod.rs | 4 +-
.../src/ui_editor/commands/recognition.rs | 18 +-
.../src/ui_editor/component/image.rs | 19 +-
.../src-tauri/src/ui_editor/component/mod.rs | 4 +-
.../src-tauri/src/ui_editor/component/text.rs | 16 +-
.../src-tauri/src/ui_editor/layout/node.rs | 8 +-
.../src/ui_editor/resource/sprite.rs | 12 +-
.../src/features/ui-editor/AGENTS.md | 2 +
.../src/features/ui-editor/binding.ts | 19 +
.../features/ui-editor/types/BindingChange.ts | 6 +
.../features/ui-editor/types/BindingDTO.ts | 4 +
.../features/ui-editor/types/NodeMetadata.ts | 4 +-
.../types/{NodeStatus.ts => StageStatus.ts} | 2 +-
.../features/ui-editor/useUiEditorState.ts | 42 ++-
.../components/Inspector/InspectorSidebar.tsx | 99 +++++-
.../src/view/ui-editor/index.tsx | 14 +
.../src/view/ui-editor/useUiEditorPage.ts | 47 +++
.../tests/uiEditorState.test.ts | 15 +-
21 files changed, 626 insertions(+), 56 deletions(-)
create mode 100644 apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/binding.rs
create mode 100644 apps/ai-game-creator-shell/src/features/ui-editor/binding.ts
create mode 100644 apps/ai-game-creator-shell/src/features/ui-editor/types/BindingChange.ts
create mode 100644 apps/ai-game-creator-shell/src/features/ui-editor/types/BindingDTO.ts
rename apps/ai-game-creator-shell/src/features/ui-editor/types/{NodeStatus.ts => StageStatus.ts} (55%)
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({
) : null}
+ {controller.bindingStatus ? (
+
+ {controller.bindingStatus}
+
+ ) : null}
+ void controller.bindComponents()}
+ disabled={controller.isBinding || controller.editor.isLocked}
+ title="调试:绑定组件"
+ >
+ {controller.isBinding ? '组件绑定中…' : '绑定组件'}
+
('input');
@@ -67,6 +71,8 @@ export function useUiEditorPage(projectPath: string) {
);
const [isMerging, setIsMerging] = useState(false);
const [mergeStatus, setMergeStatus] = useState(null);
+ const [isBinding, setIsBinding] = useState(false);
+ const [bindingStatus, setBindingStatus] = useState(null);
const images = editor.state.ui_design_images;
const sprites = editor.state.sprite_assets;
@@ -504,6 +510,44 @@ export function useUiEditorPage(projectPath: string) {
}
}
+ async function bindComponents() {
+ if (isBinding) return;
+ setBindingStatus(null);
+ setIsBinding(true);
+ try {
+ await editor.runWithStateLocked(async (snapshot) => {
+ const allSpriteIds = Object.keys(snapshot.sprite_assets);
+ const batches: string[][] = [];
+ for (
+ let index = 0;
+ index < allSpriteIds.length;
+ index += ASSET_BATCH_SIZE
+ ) {
+ batches.push(
+ allSpriteIds.slice(index, index + ASSET_BATCH_SIZE),
+ );
+ }
+ if (batches.length === 0) batches.push([]);
+ let current = snapshot;
+ for (const [index, spriteIds] of batches.entries()) {
+ setBindingStatus(`绑定组件中(${index + 1}/${batches.length})…`);
+ const result = await invoke('bind_components', {
+ projectPath,
+ state: current,
+ spriteIds,
+ });
+ current = applyBindingResult(current, result);
+ editor.replaceState(current);
+ }
+ setBindingStatus(`组件绑定完成(${batches.length}/${batches.length})。`);
+ });
+ } catch (cause) {
+ setBindingStatus(cause instanceof Error ? cause.message : String(cause));
+ } finally {
+ setIsBinding(false);
+ }
+ }
+
return {
projectPath,
editor,
@@ -573,6 +617,9 @@ export function useUiEditorPage(projectPath: string) {
isMerging,
mergeStatus,
mergeUi,
+ isBinding,
+ bindingStatus,
+ bindComponents,
};
}
diff --git a/apps/ai-game-creator-shell/tests/uiEditorState.test.ts b/apps/ai-game-creator-shell/tests/uiEditorState.test.ts
index b14b000fe..b72898eac 100644
--- a/apps/ai-game-creator-shell/tests/uiEditorState.test.ts
+++ b/apps/ai-game-creator-shell/tests/uiEditorState.test.ts
@@ -44,7 +44,15 @@ function nodeWithSprite(id: string): Node {
offset_min: [0, 0],
offset_max: [32, 32],
},
- metadata: { name: 'Image', description: '', status: 'Passed', source: 'Llm' },
+ metadata: {
+ name: 'Image',
+ description: '',
+ layout_status: 'Passed',
+ components_status: 'Pending',
+ allow_llm_edit_layout: true,
+ allow_llm_edit_component: true,
+ source: 'Llm',
+ },
components: [
{
Image: {
@@ -183,7 +191,10 @@ describe('useUiEditorState', () => {
metadata: {
name: '页面根节点',
description: '',
- status: 'Passed',
+ layout_status: 'Passed',
+ components_status: 'Pending',
+ allow_llm_edit_layout: true,
+ allow_llm_edit_component: true,
source: 'System',
},
components: [],