实现 UI 组件智能绑定

新增 Rust 组件绑定命令,向 LLM 提供真实界面与素材像素并接受完整 Component 参数。

拆分节点布局与组件状态及 LLM 编辑权限,允许根节点承载组件并保持 Transform 只读。

接入五个素材一批的前端绑定循环、增量结果合并和调试入口。

生成更新后的 TypeScript 契约,并补齐 UI Editor 状态测试。
This commit is contained in:
2026-08-17 13:45:43 +08:00
parent 6d93bbe457
commit 28a63eaf88
21 changed files with 626 additions and 56 deletions
@@ -131,6 +131,15 @@ async fn merge_ui(state: ui_editor::state::State) -> Result<ui_editor::commands:
ui_editor::commands::merge_ui_impl(state).await
}
#[tauri::command]
async fn bind_components(
project_path: String,
state: ui_editor::state::State,
sprite_ids: Vec<String>,
) -> Result<ui_editor::commands::BindingDTO, String> {
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,
@@ -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<Component>,
components_status: DraftStatus,
}
#[derive(Clone, Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
#[schemars(deny_unknown_fields)]
struct BindingResponse {
changes: Vec<BindingChangeDraft>,
}
#[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<Component>,
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<BindingChange>,
}
#[derive(Serialize)]
struct EditableNodeContext<'a> {
node_id: &'a NodeId,
name: &'a str,
description: &'a str,
components: &'a [Component],
}
fn binding_json_schema() -> Result<serde_json::Value, String> {
strict_json_schema::<BindingResponse>()
}
fn image_data_url(path: &Path, bytes: &[u8]) -> Result<String, String> {
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<EditableNodeContext<'a>>) {
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<BindingChangeDraft>,
editable_ids: &HashSet<NodeId>,
batch_sprite_ids: &HashSet<SpriteAssetId>,
) -> Result<BindingDTO, String> {
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<String>,
) -> Result<BindingDTO, String> {
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::<Result<Vec<_>, _>>()
.map_err(|error| format!("独立素材 ID 无效:{error}"))?;
let batch_sprite_ids = sprite_ids.iter().cloned().collect::<HashSet<_>>();
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::<HashSet<_>>();
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::<BindingResponse>(&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");
}
}
@@ -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(),
@@ -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;
@@ -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.
@@ -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<SpriteAssetId>,
@@ -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),
@@ -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,
}
@@ -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,
}
@@ -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<f32>,
pub(crate) pixel_size: Vector2<f32>,
#[ts(as = "f32")]
pixels_per_unit: StrictlyPositiveFinite,
border: SpriteBorder,
@@ -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 不可修改。
@@ -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;
}
@@ -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<Component>, components_status: StageStatus, };
@@ -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<BindingChange>, };
@@ -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, };
@@ -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";
@@ -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<T = undefined> =
type UiEditorOperationFailure = Extract<UiEditorOperationResult, { ok: false }>;
export type NodeMetadataPatch = Partial<
Pick<NodeMetadata, 'name' | 'description'>
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 };
},
@@ -67,7 +67,8 @@ export function InspectorSidebar({
<NodeInspector
node={view.node}
parentSize={view.parentSize}
readOnly={
readOnly={controller.editor.isLocked}
transformReadOnly={
controller.editor.isLocked ||
view.node.id === controller.treeForActiveImage?.root.id
}
@@ -189,6 +190,7 @@ function NodeInspector({
node,
parentSize,
readOnly,
transformReadOnly,
keepChildrenUnchanged,
onKeepChildrenUnchangedChange,
onMetadataChange,
@@ -203,6 +205,7 @@ function NodeInspector({
node: SelectedNode;
parentSize: UiEditorPageController['selectedNodeParentSize'];
readOnly: boolean;
transformReadOnly: boolean;
keepChildrenUnchanged: boolean;
onKeepChildrenUnchangedChange: (value: boolean) => void;
onMetadataChange: UiEditorPageController['setNodeMetadata'];
@@ -224,7 +227,7 @@ function NodeInspector({
<button
type="button"
aria-pressed={!keepChildrenUnchanged}
disabled={readOnly}
disabled={transformReadOnly}
onClick={() => onKeepChildrenUnchangedChange(false)}
className={`flex-1 rounded-md px-2 py-1.5 transition ${!keepChildrenUnchanged ? 'bg-blue-600 text-white' : 'text-(--platform-text-soft) hover:bg-black/5'} disabled:cursor-not-allowed disabled:opacity-50`}
>
@@ -233,7 +236,7 @@ function NodeInspector({
<button
type="button"
aria-pressed={keepChildrenUnchanged}
disabled={readOnly}
disabled={transformReadOnly}
onClick={() => onKeepChildrenUnchangedChange(true)}
className={`flex-1 rounded-md px-2 py-1.5 transition ${keepChildrenUnchanged ? 'bg-orange-500 text-white' : 'text-(--platform-text-soft) hover:bg-black/5'} disabled:cursor-not-allowed disabled:opacity-50`}
>
@@ -260,10 +263,52 @@ function NodeInspector({
{node.components.length}
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<NodeStageSelect
label="布局状态"
value={node.metadata.layout_status}
disabled={readOnly}
onChange={(layout_status) => onMetadataChange({ layout_status })}
/>
<NodeStageSelect
label="组件状态"
value={node.metadata.components_status}
disabled={readOnly}
onChange={(components_status) =>
onMetadataChange({ components_status })
}
/>
</div>
<div className="grid grid-cols-2 gap-2 text-xs">
<label className="flex items-center gap-2 rounded-lg border border-(--platform-subpanel-border) bg-white/35 p-2">
<input
type="checkbox"
checked={node.metadata.allow_llm_edit_layout}
disabled={readOnly}
onChange={(event) =>
onMetadataChange({ allow_llm_edit_layout: event.target.checked })
}
/>
LLM
</label>
<label className="flex items-center gap-2 rounded-lg border border-(--platform-subpanel-border) bg-white/35 p-2">
<input
type="checkbox"
checked={node.metadata.allow_llm_edit_component}
disabled={readOnly}
onChange={(event) =>
onMetadataChange({
allow_llm_edit_component: event.target.checked,
})
}
/>
LLM
</label>
</div>
<TransformEditor
transform={node.transform}
parentSize={parentSize}
readOnly={readOnly}
readOnly={transformReadOnly}
onChange={onTransformChange}
/>
<ComponentPanel
@@ -281,6 +326,52 @@ function NodeInspector({
);
}
function NodeStageSelect({
label,
value,
disabled,
onChange,
}: {
label: string;
value: SelectedNode['metadata']['layout_status'];
disabled: boolean;
onChange: (value: SelectedNode['metadata']['layout_status']) => void;
}) {
const kind =
typeof value === 'string'
? value
: 'NeedReview' in value
? 'NeedReview'
: 'Blocked';
return (
<label className="text-[10px] text-(--platform-text-soft)">
{label}
<select
className={INSPECTOR_INPUT_CLASS_NAME}
value={kind}
disabled={disabled}
onChange={(event) => {
const next = event.target.value;
onChange(
next === 'NeedReview'
? { NeedReview: '待审' }
: next === 'Blocked'
? 'Blocked'
: next === 'Pending'
? 'Pending'
: 'Passed',
);
}}
>
<option value="Pending"></option>
<option value="Passed"></option>
<option value="NeedReview"></option>
<option value="Blocked"></option>
</select>
</label>
);
}
function SpriteInspector({
sprite,
spriteId,
@@ -40,6 +40,20 @@ export default function UiEditorPage({
{controller.mergeStatus}
</p>
) : null}
{controller.bindingStatus ? (
<p className="rounded-lg border border-(--platform-subpanel-border) bg-(--platform-subpanel-fill) px-3 py-2 text-xs shadow-lg">
{controller.bindingStatus}
</p>
) : null}
<button
type="button"
className="rounded-full bg-emerald-600 px-4 py-3 text-sm font-semibold text-white shadow-lg transition hover:bg-emerald-700 disabled:cursor-wait disabled:opacity-60"
onClick={() => void controller.bindComponents()}
disabled={controller.isBinding || controller.editor.isLocked}
title="调试:绑定组件"
>
{controller.isBinding ? '组件绑定中…' : '绑定组件'}
</button>
<button
type="button"
className="rounded-full bg-violet-600 px-4 py-3 text-sm font-semibold text-white shadow-lg transition hover:bg-violet-700 disabled:cursor-wait disabled:opacity-60"
@@ -14,6 +14,8 @@ import {
} from '../../features/ui-editor/prerequisites';
import { applyMergeResult } from '../../features/ui-editor/merge';
import { applyRecognitionResult } from '../../features/ui-editor/recognition';
import { applyBindingResult } from '../../features/ui-editor/binding';
import type { BindingDTO } from '../../features/ui-editor/types/BindingDTO';
import type { Component } from '../../features/ui-editor/types/Component';
import type { MergeDTO } from '../../features/ui-editor/types/MergeDTO';
import type { Node as UiNode } from '../../features/ui-editor/types/Node';
@@ -39,6 +41,8 @@ import {
type UiEditorToolId,
} from './model';
const ASSET_BATCH_SIZE = 5;
export function useUiEditorPage(projectPath: string) {
const editor = useUiEditorState(EMPTY_UI_EDITOR_STATE);
const [activeTool, setActiveTool] = useState<UiEditorToolId>('input');
@@ -67,6 +71,8 @@ export function useUiEditorPage(projectPath: string) {
);
const [isMerging, setIsMerging] = useState(false);
const [mergeStatus, setMergeStatus] = useState<string | null>(null);
const [isBinding, setIsBinding] = useState(false);
const [bindingStatus, setBindingStatus] = useState<string | null>(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<BindingDTO>('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,
};
}
@@ -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: [],