退役界面图参考语义建议的 Rust 链路
- 删除 ui_editor/commands/ui_design_suggestion.rs 与 main.rs 的 suggest_ui_design_semantic 命令注册 - 收敛 UIDesignImage 为 path/pixel_size/pixels_per_unit,删除 UIDesignImageRole 与 UIDesignImageMetadata - 结构识别改为每张界面图各自一棵树,删除 recognition_root_image_ids 与 slave_image_ids 及 ROOT/SLAVE 上下文标注 - 多树合并删除 slave_to 祖先链优先级,输入树优先级恒置 0 并留 TODO - 持久化校验删除 slave_to 引用与环校验,html 片段注释不再携带界面图 name/description - 页面级工作流构造页面设计图时不再写入界面图元数据
This commit is contained in:
@@ -197,14 +197,6 @@ use swarm_cli::*;
|
||||
use template_library::*;
|
||||
use user_input::*;
|
||||
use windows::*;
|
||||
#[tauri::command]
|
||||
async fn suggest_ui_design_semantic(
|
||||
project_path: String,
|
||||
state: ui_editor::state::State,
|
||||
) -> Result<Vec<ui_editor::commands::UIDesignSuggestionTreeNode>, String> {
|
||||
ui_editor::commands::suggest_ui_design_semantic_impl(project_path, state).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn recognize_ui(
|
||||
project_path: String,
|
||||
@@ -2665,7 +2657,6 @@ fn main() {
|
||||
prepare_ui_editor_project_fonts,
|
||||
read_ui_editor_font_bytes,
|
||||
check_ui_editor_font_glyph_coverage,
|
||||
suggest_ui_design_semantic,
|
||||
recognize_ui,
|
||||
separate_ui,
|
||||
inspect_separation_recovery,
|
||||
|
||||
@@ -114,59 +114,6 @@ mod llm_contract {
|
||||
}
|
||||
}
|
||||
|
||||
mod priority {
|
||||
use crate::ui_editor::resource::ui_design_image::UIDesignImage;
|
||||
use crate::ui_editor::state::State;
|
||||
use crate::ui_editor::utils::UIDesignImageId;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
fn ancestor_chain(
|
||||
image_id: &UIDesignImageId,
|
||||
images: &HashMap<UIDesignImageId, UIDesignImage>,
|
||||
) -> Result<Vec<UIDesignImageId>, String> {
|
||||
let mut chain = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
let mut current = image_id.clone();
|
||||
loop {
|
||||
if !seen.insert(current.clone()) {
|
||||
return Err(format!(
|
||||
"界面图 slave_to 关系存在循环:{}",
|
||||
current.as_str()
|
||||
));
|
||||
}
|
||||
chain.push(current.clone());
|
||||
let image = images
|
||||
.get(¤t)
|
||||
.ok_or_else(|| format!("界面图 slave_to 引用了缺失界面图:{}", current.as_str()))?;
|
||||
match &image.metadata.slave_to {
|
||||
Some(parent) => current = parent.clone(),
|
||||
None => return Ok(chain),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn for_state(state: &State) -> Result<Vec<u32>, String> {
|
||||
let source_ids = state
|
||||
.ui_trees
|
||||
.iter()
|
||||
.map(|tree| tree.src_ui_design.clone())
|
||||
.collect::<Vec<_>>();
|
||||
let chains = source_ids
|
||||
.iter()
|
||||
.map(|source_id| ancestor_chain(source_id, &state.ui_design_images))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
Ok(source_ids
|
||||
.iter()
|
||||
.map(|candidate| {
|
||||
chains
|
||||
.iter()
|
||||
.filter(|chain| chain.contains(candidate))
|
||||
.count() as u32
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
mod materialize {
|
||||
use super::llm_contract::Node;
|
||||
use crate::ui_editor::layout::children_display_mode::ChildrenDisplayMode;
|
||||
@@ -217,6 +164,7 @@ mod materialize {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// TODO(ui-merge): 优先级恒 0 时该函数固定取 merged_from 的首个成员;优先级来源待重新设计。
|
||||
fn highest_priority_member_index(members: &[BuiltNode]) -> Result<usize, String> {
|
||||
if members.is_empty() {
|
||||
return Err("MergedNode.merged_from 不能为空".to_string());
|
||||
@@ -412,10 +360,8 @@ pub(crate) async fn merge_ui_impl_with_provider(
|
||||
app_log!("ui_merge.error stage=validate_input error={error}");
|
||||
error
|
||||
})?;
|
||||
let priorities = priority::for_state(&state).map_err(|error| {
|
||||
app_log!("ui_merge.error stage=build_priority error={error}");
|
||||
error
|
||||
})?;
|
||||
// TODO(ui-merge): 输入树优先级来源待重新设计,当前所有输入树等权(恒 0)。
|
||||
let priorities = vec![0u32; state.ui_trees.len()];
|
||||
let trees = llm_contract::input_trees(&state, &priorities).map_err(|error| {
|
||||
app_log!("ui_merge.error stage=build_input error={error}");
|
||||
error
|
||||
|
||||
@@ -2,7 +2,6 @@ pub mod binding;
|
||||
pub mod merge;
|
||||
pub mod recognition;
|
||||
pub mod separation;
|
||||
pub mod ui_design_suggestion;
|
||||
pub mod utils;
|
||||
|
||||
pub use binding::BindingDTO;
|
||||
@@ -13,5 +12,3 @@ pub use recognition::RecognitionDTO;
|
||||
pub(crate) use recognition::{recognize_ui_impl, recognize_ui_impl_with_provider};
|
||||
pub(crate) use separation::separate_ui_impl;
|
||||
pub use separation::{SeparationDTO, SeparationRecoveryDTO};
|
||||
pub(crate) use ui_design_suggestion::suggest_ui_design_semantic_impl;
|
||||
pub use ui_design_suggestion::UIDesignSuggestionTreeNode;
|
||||
|
||||
@@ -32,7 +32,7 @@ const SYSTEM_PROMPT: &str = r#"
|
||||
任务:
|
||||
同时分析同一 UI 系统的全部参考图,建立UI树
|
||||
|
||||
用户会给你一些UI截图(它们从属于同一个UI系统)和对应的元数据, 请用给定的工具描述UI结构
|
||||
用户会给你一些UI截图(它们从属于同一个UI系统), 请用给定的工具描述UI结构
|
||||
|
||||
识别规则:
|
||||
* 只识别 UI元素. 要区分动态内容, 不要白费力气识别应该由程序生成/绘制的内容.(此类内容应该用一个整体节点+自然语言描述) 除此之外必须完整包含所有元素,结构.
|
||||
@@ -386,12 +386,6 @@ mod tests {
|
||||
|
||||
fn test_image() -> UIDesignImage {
|
||||
UIDesignImage {
|
||||
metadata: crate::ui_editor::resource::ui_design_image::UIDesignImageMetadata {
|
||||
name: "测试图".to_string(),
|
||||
description: String::new(),
|
||||
role: None,
|
||||
slave_to: None,
|
||||
},
|
||||
path: "test.png".to_string(),
|
||||
pixel_size: Vector2::new(1000.0, 500.0),
|
||||
pixels_per_unit: typed_floats::tf32::StrictlyPositiveFinite::new(2.0)
|
||||
@@ -548,7 +542,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn conversion_stays_in_the_tree_image_coordinate_system() {
|
||||
let image_id = UIDesignImageId::new("slave").expect("valid image id");
|
||||
let image_id = UIDesignImageId::new("second").expect("valid image id");
|
||||
let image = test_image();
|
||||
let root_rect = UIRect::new(Point2::origin(), image_layout_size(&image).unwrap());
|
||||
let converted =
|
||||
@@ -591,56 +585,29 @@ mod tests {
|
||||
#[test]
|
||||
fn tree_validation_requires_exactly_one_tree_per_context_image() {
|
||||
let page = UIDesignImageId::new("page").expect("valid image id");
|
||||
let slave = UIDesignImageId::new("slave").expect("valid image id");
|
||||
let second = UIDesignImageId::new("second").expect("valid image id");
|
||||
let tree = |id: UIDesignImageId| RecognitionTree {
|
||||
src_ui_design_image_id: id,
|
||||
root: test_node(),
|
||||
};
|
||||
|
||||
assert!(validate_tree_image_ids(
|
||||
&[tree(page.clone()), tree(slave.clone())],
|
||||
&[page.clone(), slave.clone()],
|
||||
&[tree(page.clone()), tree(second.clone())],
|
||||
&[page.clone(), second.clone()],
|
||||
)
|
||||
.is_ok());
|
||||
assert!(
|
||||
validate_tree_image_ids(&[tree(page.clone())], &[page.clone(), slave.clone()]).is_err()
|
||||
);
|
||||
assert!(
|
||||
validate_tree_image_ids(&[tree(page.clone()), tree(page.clone())], &[page, slave],)
|
||||
validate_tree_image_ids(&[tree(page.clone())], &[page.clone(), second.clone()])
|
||||
.is_err()
|
||||
);
|
||||
assert!(validate_tree_image_ids(
|
||||
&[tree(page.clone()), tree(page.clone())],
|
||||
&[page, second],
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
}
|
||||
|
||||
fn recognition_root_image_ids(state: &State) -> Vec<UIDesignImageId> {
|
||||
state
|
||||
.ui_design_images
|
||||
.iter()
|
||||
.filter_map(|(id, image)| {
|
||||
let is_page = image.metadata.role
|
||||
== Some(crate::ui_editor::resource::ui_design_image::UIDesignImageRole::Page);
|
||||
let is_direct_slave_of_page = image.metadata.slave_to.as_ref().is_some_and(|parent| {
|
||||
state
|
||||
.ui_design_images
|
||||
.get(parent)
|
||||
.and_then(|parent_image| parent_image.metadata.role)
|
||||
== Some(crate::ui_editor::resource::ui_design_image::UIDesignImageRole::Page)
|
||||
});
|
||||
(is_page || !is_direct_slave_of_page).then(|| id.clone())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn slave_image_ids(state: &State, root_id: &UIDesignImageId) -> Vec<UIDesignImageId> {
|
||||
state
|
||||
.ui_design_images
|
||||
.iter()
|
||||
.filter_map(|(id, image)| {
|
||||
(image.metadata.slave_to.as_ref() == Some(root_id)).then(|| id.clone())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) async fn recognize_ui_impl_with_provider(
|
||||
project_path: String,
|
||||
state: State,
|
||||
@@ -657,11 +624,7 @@ pub(crate) async fn recognize_ui_impl_with_provider(
|
||||
);
|
||||
return Err("界面图最多 4 张".to_string());
|
||||
}
|
||||
let root_ids = recognition_root_image_ids(&state);
|
||||
if root_ids.is_empty() {
|
||||
app_log!("ui_recognition.error stage=validate reason=no_root_image");
|
||||
return Err("至少需要一张可作为识别上下文根的界面图".to_string());
|
||||
}
|
||||
let root_ids = state.ui_design_images.keys().cloned().collect::<Vec<_>>();
|
||||
let (llm, client) = if provider_identity.is_none() {
|
||||
let llm = load_game_creator_app_config()
|
||||
.map_err(|error| {
|
||||
@@ -685,12 +648,9 @@ pub(crate) async fn recognize_ui_impl_with_provider(
|
||||
let root = Path::new(project_path.trim());
|
||||
let mut ui_trees = Vec::with_capacity(root_ids.len());
|
||||
for root_id in root_ids {
|
||||
let mut context_ids = vec![root_id.clone()];
|
||||
context_ids.extend(slave_image_ids(&state, &root_id));
|
||||
// Page 仍可把直接 slave 参考图放在同一识别上下文;没有 Page
|
||||
// 归属关系的其它界面图各自作为独立上下文根。
|
||||
let context_ids = vec![root_id.clone()];
|
||||
let mut parts = Vec::with_capacity(context_ids.len() * 2);
|
||||
for (index, context_id) in context_ids.iter().enumerate() {
|
||||
for context_id in context_ids.iter() {
|
||||
let image = state
|
||||
.ui_design_images
|
||||
.get(context_id)
|
||||
@@ -716,8 +676,7 @@ pub(crate) async fn recognize_ui_impl_with_provider(
|
||||
})?;
|
||||
parts.push(LlmMessageContentPart::InputText {
|
||||
text: format!(
|
||||
"{} id={} pixel_size={:?}",
|
||||
if index == 0 { "ROOT" } else { "SLAVE" },
|
||||
"ROOT id={} pixel_size={:?}",
|
||||
context_id.as_str(),
|
||||
image.pixel_size
|
||||
),
|
||||
|
||||
@@ -60,12 +60,6 @@ mod tests {
|
||||
ui_design_images: HashMap::from([(
|
||||
image_id,
|
||||
UIDesignImage {
|
||||
metadata: crate::ui_editor::resource::ui_design_image::UIDesignImageMetadata {
|
||||
name: "page".to_string(),
|
||||
description: String::new(),
|
||||
role: None,
|
||||
slave_to: None,
|
||||
},
|
||||
path: "page.png".to_string(),
|
||||
pixel_size: Vector2::new(100.0, 100.0),
|
||||
pixels_per_unit: StrictlyPositiveFinite::new(1.0).unwrap(),
|
||||
|
||||
@@ -1,349 +0,0 @@
|
||||
use crate::config::build_game_creator_llm_client_from_llm_config;
|
||||
use crate::config::load_game_creator_app_config;
|
||||
use crate::ui_editor::commands::utils::{
|
||||
parse_limited_llm_tool_arguments, read_ui_reference_image_data_url, request_ui_editor_llm,
|
||||
required_tool_call_arguments, strict_json_schema,
|
||||
};
|
||||
use crate::ui_editor::resource::ui_design_image::UIDesignImageRole;
|
||||
use crate::ui_editor::state::State;
|
||||
use crate::ui_editor::utils::UIDesignImageId;
|
||||
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;
|
||||
|
||||
const SYSTEM_PROMPT: &str = r#"
|
||||
请识别这些 UI 参考图的界面语义,并调用 suggest_ui_design_semantics 工具返回结果。
|
||||
不要在工具调用外输出 JSON、Markdown、代码围栏、注释、额外字段或解释文字。
|
||||
|
||||
字段含义:
|
||||
- id:必填,必须逐字匹配当前输入参考图的 id。它标识“这条建议属于哪张图”,不是新 ID,不能为 null。
|
||||
- name:要写入该图片 metadata 的简短、可读名称;
|
||||
- description:要写入该图片 metadata 的简短语义描述,例如“带底部导航的主游戏页面”。
|
||||
- role:要写入该图片 metadata 的界面角色。
|
||||
Page 表示完整主页面,
|
||||
Section 表示同一主页面中的子界面或页签,
|
||||
Modal/Drawer/Popover 表示浮层或局部覆盖界面,
|
||||
State 表示同一界面的状态变体,
|
||||
Scrolled 表示滚动或分页后的内容,
|
||||
Detail 表示局部详情或补充证据。
|
||||
- children:该节点直接包含的子界面图。
|
||||
|
||||
- 根节点必须是 Page,子节点不能是 Page
|
||||
|
||||
所有字段都必须出现,禁止省略字段、使用 null 或返回空字符串。每张参考图最多出现一次;id 必须逐字匹配输入图片 id。
|
||||
以下 UI 设计图中的 metadata 部分信息已确定,请根据已有信息补全语义.
|
||||
"#;
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))]
|
||||
pub struct UIDesignSuggestionTreeNode {
|
||||
pub id: UIDesignImageId,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub role: UIDesignImageRole,
|
||||
pub children: Vec<UIDesignSuggestionTreeNode>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct UIDesignSuggestion {
|
||||
ui_designs: Vec<UIDesignSuggestionTreeNode>,
|
||||
}
|
||||
|
||||
fn ui_design_suggestion_json_schema() -> Result<serde_json::Value, String> {
|
||||
strict_json_schema::<UIDesignSuggestion>()
|
||||
}
|
||||
|
||||
const MAX_REFERENCES: usize = 4;
|
||||
const MAX_SUGGESTION_TREE_DEPTH: usize = 4;
|
||||
|
||||
fn validate_suggestion_response_shape(value: &serde_json::Value) -> Result<(), String> {
|
||||
let roots = value
|
||||
.get("ui_designs")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.ok_or_else(|| "UI 语义建议工具参数缺少 ui_designs 数组".to_string())?;
|
||||
let mut stack = roots.iter().map(|node| (node, 1usize)).collect::<Vec<_>>();
|
||||
let mut node_count = 0usize;
|
||||
while let Some((node, depth)) = stack.pop() {
|
||||
if depth > MAX_SUGGESTION_TREE_DEPTH {
|
||||
return Err(format!(
|
||||
"UI 语义建议树最大深度不能超过 {MAX_SUGGESTION_TREE_DEPTH}"
|
||||
));
|
||||
}
|
||||
node_count += 1;
|
||||
if node_count > MAX_REFERENCES {
|
||||
return Err(format!("UI 语义建议最多包含 {MAX_REFERENCES} 个节点"));
|
||||
}
|
||||
let children = node
|
||||
.get("children")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.ok_or_else(|| "UI 语义建议节点缺少 children 数组".to_string())?;
|
||||
stack.extend(children.iter().map(|child| (child, depth + 1)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_suggestions(
|
||||
suggestions: &[UIDesignSuggestionTreeNode],
|
||||
image_ids: &HashSet<UIDesignImageId>,
|
||||
) -> Result<(), String> {
|
||||
let mut seen = HashSet::new();
|
||||
|
||||
fn visit(
|
||||
nodes: &[UIDesignSuggestionTreeNode],
|
||||
is_root: bool,
|
||||
image_ids: &HashSet<UIDesignImageId>,
|
||||
seen: &mut HashSet<UIDesignImageId>,
|
||||
) -> Result<(), String> {
|
||||
for node in nodes {
|
||||
if !image_ids.contains(&node.id) {
|
||||
return Err("LLM 返回了未知界面图 ID".to_string());
|
||||
}
|
||||
if !seen.insert(node.id.clone()) {
|
||||
return Err("LLM 返回了重复界面图建议".to_string());
|
||||
}
|
||||
if node.name.trim().is_empty() || node.description.trim().is_empty() {
|
||||
return Err("界面图语义字段不能是空字符串".to_string());
|
||||
}
|
||||
if is_root {
|
||||
if node.role != UIDesignImageRole::Page {
|
||||
return Err("界面图树根节点必须是 Page".to_string());
|
||||
}
|
||||
} else if node.role == UIDesignImageRole::Page {
|
||||
return Err("Page 不能作为其它界面图的子节点".to_string());
|
||||
}
|
||||
visit(&node.children, false, image_ids, seen)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
visit(suggestions, true, image_ids, &mut seen)?;
|
||||
if seen != *image_ids {
|
||||
return Err("LLM 未为每张界面图返回语义建议".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn suggest_ui_design_semantic_impl(
|
||||
project_path: String,
|
||||
state: State,
|
||||
) -> Result<Vec<UIDesignSuggestionTreeNode>, String> {
|
||||
if state.ui_design_images.is_empty() {
|
||||
app_log!("ui_design_suggestion.error stage=validate reason=no_images");
|
||||
return Err("请先导入界面图".to_string());
|
||||
}
|
||||
if state.ui_design_images.len() > MAX_REFERENCES {
|
||||
app_log!(
|
||||
"ui_design_suggestion.error stage=validate reason=too_many_images count={}",
|
||||
state.ui_design_images.len()
|
||||
);
|
||||
return Err("界面图最多 4 张".to_string());
|
||||
}
|
||||
let root = Path::new(project_path.trim());
|
||||
let mut parts = Vec::new();
|
||||
let mut ids = HashSet::new();
|
||||
for (id, image) in &state.ui_design_images {
|
||||
ids.insert(id.clone());
|
||||
let absolute =
|
||||
crate::project::resolve_local_project_path(root, &image.path).map_err(|error| {
|
||||
app_log!(
|
||||
"ui_design_suggestion.error stage=resolve_image id={} error={error}",
|
||||
id.as_str()
|
||||
);
|
||||
error
|
||||
})?;
|
||||
let image_url = read_ui_reference_image_data_url(absolute)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
app_log!(
|
||||
"ui_design_suggestion.error stage=read_image id={} error={error}",
|
||||
id.as_str()
|
||||
);
|
||||
error
|
||||
})?;
|
||||
parts.push(LlmMessageContentPart::InputText {
|
||||
text: format!("REFERENCE id:{} metadata:{}", id.as_str(), image.metadata,),
|
||||
});
|
||||
parts.push(LlmMessageContentPart::InputImage { image_url });
|
||||
}
|
||||
let llm = load_game_creator_app_config()
|
||||
.map_err(|error| {
|
||||
eprintln!("ui_design_suggestion.error stage=build_client error={error}");
|
||||
error
|
||||
})?
|
||||
.llm;
|
||||
let client = build_game_creator_llm_client_from_llm_config(&llm, "llm").map_err(|error| {
|
||||
app_log!("ui_design_suggestion.error stage=build_client error={error}");
|
||||
error
|
||||
})?;
|
||||
let schema = ui_design_suggestion_json_schema().map_err(|error| {
|
||||
app_log!("ui_design_suggestion.error stage=build_schema error={error}");
|
||||
error
|
||||
})?;
|
||||
let tool = LlmFunctionTool::new(
|
||||
"suggest_ui_design_semantics",
|
||||
"为所有 UI 参考图补全界面语义 metadata",
|
||||
schema,
|
||||
)
|
||||
.with_strict(true);
|
||||
let response = request_ui_editor_llm(
|
||||
&client,
|
||||
&llm,
|
||||
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| {
|
||||
app_log!("ui_design_suggestion.error stage=llm_request error={error}");
|
||||
format!("UI 参考图语义识别失败:{error}")
|
||||
})?;
|
||||
app_log!(
|
||||
"ui_design_suggestion.llm_output text_present={} tool_call_count={}",
|
||||
!response.text.trim().is_empty(),
|
||||
response.tool_calls.len()
|
||||
);
|
||||
let arguments = required_tool_call_arguments(&response, "suggest_ui_design_semantics")
|
||||
.map_err(|error| {
|
||||
app_log!(
|
||||
"ui_design_suggestion.error stage=parse_tool_call reason=missing_tool_call error={error}"
|
||||
);
|
||||
"LLM 响应无效(详情:未返回 suggest_ui_design_semantics 工具调用)".to_string()
|
||||
})?;
|
||||
let arguments = parse_limited_llm_tool_arguments(arguments).map_err(|error| {
|
||||
app_log!("ui_design_suggestion.error stage=parse_arguments error={error}");
|
||||
format!("LLM 响应无效(详情:UI 语义建议工具参数无效:{error})")
|
||||
})?;
|
||||
validate_suggestion_response_shape(&arguments).map_err(|error| {
|
||||
app_log!("ui_design_suggestion.error stage=validate_arguments error={error}");
|
||||
format!("LLM 响应无效(详情:{error})")
|
||||
})?;
|
||||
let suggestions = serde_json::from_value::<UIDesignSuggestion>(arguments)
|
||||
.map_err(|error| {
|
||||
app_log!("ui_design_suggestion.error stage=parse_arguments error={error}");
|
||||
format!("LLM 响应无效(详情:UI 语义建议工具参数无效:{error})")
|
||||
})?
|
||||
.ui_designs;
|
||||
validate_suggestions(&suggestions, &ids).map_err(|error| {
|
||||
app_log!("ui_design_suggestion.error stage=validate_result error={error}");
|
||||
format!("LLM 响应无效(详情:{error})")
|
||||
})?;
|
||||
Ok(suggestions)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn id(value: &str) -> UIDesignImageId {
|
||||
UIDesignImageId::new(value).expect("valid image id")
|
||||
}
|
||||
|
||||
fn node(
|
||||
value: &str,
|
||||
role: UIDesignImageRole,
|
||||
children: Vec<UIDesignSuggestionTreeNode>,
|
||||
) -> UIDesignSuggestionTreeNode {
|
||||
UIDesignSuggestionTreeNode {
|
||||
id: id(value),
|
||||
name: value.to_string(),
|
||||
description: format!("{value} description"),
|
||||
role,
|
||||
children,
|
||||
}
|
||||
}
|
||||
|
||||
fn image_ids(values: &[&str]) -> HashSet<UIDesignImageId> {
|
||||
values.iter().map(|value| id(value)).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_page_tree() {
|
||||
let suggestions = vec![node(
|
||||
"page",
|
||||
UIDesignImageRole::Page,
|
||||
vec![node("section", UIDesignImageRole::Section, Vec::new())],
|
||||
)];
|
||||
assert!(validate_suggestions(&suggestions, &image_ids(&["page", "section"])).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suggestion_shape_rejects_more_than_four_nodes_or_four_levels() {
|
||||
let oversized = (0..=MAX_REFERENCES)
|
||||
.map(|_| serde_json::json!({"children": []}))
|
||||
.collect::<Vec<_>>();
|
||||
assert!(validate_suggestion_response_shape(&serde_json::json!({
|
||||
"ui_designs": oversized
|
||||
}))
|
||||
.is_err());
|
||||
|
||||
let mut nested = serde_json::json!({"children": []});
|
||||
for _ in 0..MAX_SUGGESTION_TREE_DEPTH {
|
||||
nested = serde_json::json!({"children": [nested]});
|
||||
}
|
||||
assert!(validate_suggestion_response_shape(&serde_json::json!({
|
||||
"ui_designs": [nested]
|
||||
}))
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_duplicate_and_unknown_ids() {
|
||||
let duplicate = vec![
|
||||
node(
|
||||
"page",
|
||||
UIDesignImageRole::Page,
|
||||
vec![node("section", UIDesignImageRole::Section, Vec::new())],
|
||||
),
|
||||
node("page", UIDesignImageRole::Page, Vec::new()),
|
||||
];
|
||||
assert!(validate_suggestions(&duplicate, &image_ids(&["page", "section"])).is_err());
|
||||
|
||||
let unknown = vec![node("missing", UIDesignImageRole::Page, Vec::new())];
|
||||
assert!(validate_suggestions(&unknown, &image_ids(&["page"])).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_root_roles_and_nested_pages() {
|
||||
let non_page_root = vec![node("section", UIDesignImageRole::Section, Vec::new())];
|
||||
assert!(validate_suggestions(&non_page_root, &image_ids(&["section"])).is_err());
|
||||
|
||||
let nested_page = vec![node(
|
||||
"page",
|
||||
UIDesignImageRole::Page,
|
||||
vec![node("nested", UIDesignImageRole::Page, Vec::new())],
|
||||
)];
|
||||
assert!(validate_suggestions(&nested_page, &image_ids(&["page", "nested"])).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_schema_requires_tree_fields() {
|
||||
let schema = ui_design_suggestion_json_schema().expect("schema");
|
||||
assert_eq!(schema["required"], serde_json::json!(["ui_designs"]));
|
||||
let node_schema = &schema["properties"]["ui_designs"]["items"];
|
||||
let required = node_schema["required"]
|
||||
.as_array()
|
||||
.expect("node required")
|
||||
.iter()
|
||||
.filter_map(serde_json::Value::as_str)
|
||||
.collect::<HashSet<_>>();
|
||||
assert_eq!(
|
||||
required,
|
||||
["id", "name", "description", "role", "children"]
|
||||
.into_iter()
|
||||
.collect()
|
||||
);
|
||||
assert_eq!(node_schema["additionalProperties"], false);
|
||||
}
|
||||
}
|
||||
@@ -84,8 +84,6 @@ pub(crate) fn render_ui_design_state_js(
|
||||
"genarrative-ui-tree",
|
||||
json!({
|
||||
"srcUiDesign": tree.src_ui_design.as_str(),
|
||||
"name": image.metadata.name,
|
||||
"description": image.metadata.description,
|
||||
}),
|
||||
)
|
||||
.into_string();
|
||||
|
||||
@@ -2,9 +2,7 @@ use crate::ui_editor::component::text::FontSource;
|
||||
use crate::ui_editor::component::Component;
|
||||
use crate::ui_editor::html_renderer::render_ui_design_state_js;
|
||||
use crate::ui_editor::layout::node::Node;
|
||||
use crate::ui_editor::resource::ui_design_image::{
|
||||
UIDesignImage, UIDesignImageMetadata, UIDesignImageRole,
|
||||
};
|
||||
use crate::ui_editor::resource::ui_design_image::UIDesignImage;
|
||||
use crate::ui_editor::state::State;
|
||||
use crate::ui_editor::utils::UIDesignImageId;
|
||||
use crate::*;
|
||||
@@ -139,12 +137,6 @@ pub(crate) fn initialize_ui_design_state_with_source_image_at(
|
||||
document.state.ui_design_images.insert(
|
||||
source_image_id,
|
||||
UIDesignImage {
|
||||
metadata: UIDesignImageMetadata {
|
||||
name: "游戏界面原型".to_string(),
|
||||
description: "由画布 UI 原型桥接载入".to_string(),
|
||||
role: Some(UIDesignImageRole::Page),
|
||||
slave_to: None,
|
||||
},
|
||||
path: source_image_path,
|
||||
pixel_size: Vector2::new(pixel_size.0 as f32, pixel_size.1 as f32),
|
||||
pixels_per_unit,
|
||||
@@ -614,16 +606,7 @@ fn validate_state(state: &State) -> Result<(), String> {
|
||||
{
|
||||
return Err("界面图像素尺寸必须为正有限数值".to_string());
|
||||
}
|
||||
if image
|
||||
.metadata
|
||||
.slave_to
|
||||
.as_ref()
|
||||
.is_some_and(|owner| !state.ui_design_images.contains_key(owner))
|
||||
{
|
||||
return Err("界面图 slaveTo 引用了不存在的界面图".to_string());
|
||||
}
|
||||
}
|
||||
validate_slave_to_acyclic(state)?;
|
||||
for (id, sprite) in &state.sprite_assets {
|
||||
validate_id(id.as_str(), "独立素材 ID")?;
|
||||
if sprite.asset_id != *id {
|
||||
@@ -649,23 +632,6 @@ fn validate_state(state: &State) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_slave_to_acyclic(state: &State) -> Result<(), String> {
|
||||
for start in state.ui_design_images.keys() {
|
||||
let mut current = Some(start);
|
||||
let mut visited = HashSet::new();
|
||||
while let Some(id) = current {
|
||||
if !visited.insert(id) {
|
||||
return Err("界面图 slaveTo 不能形成循环".to_string());
|
||||
}
|
||||
current = state
|
||||
.ui_design_images
|
||||
.get(id)
|
||||
.and_then(|image| image.metadata.slave_to.as_ref());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_node(
|
||||
node: &Node,
|
||||
state: &State,
|
||||
@@ -1050,12 +1016,6 @@ mod tests {
|
||||
"ui_trees": [],
|
||||
"ui_design_images": {
|
||||
"page": {
|
||||
"metadata": {
|
||||
"name": "主界面",
|
||||
"description": "",
|
||||
"role": "Page",
|
||||
"slave_to": null
|
||||
},
|
||||
"path": path,
|
||||
"pixel_size": [1280.0, 720.0],
|
||||
"pixels_per_unit": 1.0
|
||||
@@ -1136,12 +1096,6 @@ mod tests {
|
||||
}],
|
||||
"ui_design_images": {
|
||||
"page": {
|
||||
"metadata": {
|
||||
"name": "主界面",
|
||||
"description": "",
|
||||
"role": "Page",
|
||||
"slave_to": null
|
||||
},
|
||||
"path": "assets/page.png",
|
||||
"pixel_size": [1280.0, 720.0],
|
||||
"pixels_per_unit": 1.0
|
||||
@@ -1319,12 +1273,6 @@ mod tests {
|
||||
}],
|
||||
"ui_design_images": {
|
||||
"page": {
|
||||
"metadata": {
|
||||
"name": "主界面",
|
||||
"description": "",
|
||||
"role": "Page",
|
||||
"slave_to": null
|
||||
},
|
||||
"path": "missing.png",
|
||||
"pixel_size": [1280.0, 720.0],
|
||||
"pixels_per_unit": 1.0
|
||||
@@ -1341,45 +1289,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_cyclic_slave_to_relationships() {
|
||||
let state: State = serde_json::from_value(serde_json::json!({
|
||||
"ui_trees": [],
|
||||
"ui_design_images": {
|
||||
"page": {
|
||||
"metadata": {
|
||||
"name": "主界面",
|
||||
"description": "",
|
||||
"role": "Page",
|
||||
"slave_to": "section"
|
||||
},
|
||||
"path": "page.png",
|
||||
"pixel_size": [1280.0, 720.0],
|
||||
"pixels_per_unit": 1.0
|
||||
},
|
||||
"section": {
|
||||
"metadata": {
|
||||
"name": "子页面",
|
||||
"description": "",
|
||||
"role": "Section",
|
||||
"slave_to": "page"
|
||||
},
|
||||
"path": "section.png",
|
||||
"pixel_size": [1280.0, 720.0],
|
||||
"pixels_per_unit": 1.0
|
||||
}
|
||||
},
|
||||
"sprite_assets": {},
|
||||
"font_assets": {}
|
||||
}))
|
||||
.expect("deserialize cyclic state");
|
||||
|
||||
assert_eq!(
|
||||
validate_state(&state),
|
||||
Err("界面图 slaveTo 不能形成循环".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovers_last_valid_state_when_primary_is_corrupt() {
|
||||
let (directory, asset_id) = fixture();
|
||||
@@ -1554,12 +1463,6 @@ mod tests {
|
||||
}],
|
||||
"ui_design_images": {
|
||||
"page": {
|
||||
"metadata": {
|
||||
"name": "主界面",
|
||||
"description": "",
|
||||
"role": "Page",
|
||||
"slave_to": null
|
||||
},
|
||||
"path": "assets/page.png",
|
||||
"pixel_size": [1280.0, 720.0],
|
||||
"pixels_per_unit": 1.0
|
||||
|
||||
@@ -1,48 +1,14 @@
|
||||
use crate::ui_editor::utils::UIDesignImageId;
|
||||
use nalgebra::Vector2;
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt::{Display, Formatter};
|
||||
use ts_rs::TS;
|
||||
use typed_floats::tf32::StrictlyPositiveFinite;
|
||||
|
||||
#[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 UIDesignImageRole {
|
||||
Page,
|
||||
Section,
|
||||
Modal,
|
||||
Drawer,
|
||||
Popover,
|
||||
State,
|
||||
Scrolled,
|
||||
Detail,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)]
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))]
|
||||
pub struct UIDesignImage {
|
||||
pub(crate) metadata: UIDesignImageMetadata,
|
||||
pub(crate) path: String,
|
||||
#[ts(as = "[f32; 2]")]
|
||||
pub(crate) pixel_size: Vector2<f32>,
|
||||
#[ts(as = "f32")]
|
||||
pub(crate) pixels_per_unit: StrictlyPositiveFinite,
|
||||
}
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)]
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))]
|
||||
pub struct UIDesignImageMetadata {
|
||||
pub(crate) name: String,
|
||||
pub(crate) description: String,
|
||||
pub(crate) role: Option<UIDesignImageRole>,
|
||||
pub(crate) slave_to: Option<UIDesignImageId>,
|
||||
}
|
||||
impl Display for UIDesignImageMetadata {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"name: {}, description: {}, role: {:?}, slave_to: {:?}",
|
||||
self.name, self.description, self.role, self.slave_to
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,9 +11,7 @@ use crate::ui_editor::persistence::{
|
||||
};
|
||||
use crate::ui_editor::resource::font::FontAsset;
|
||||
use crate::ui_editor::resource::sprite::{SpriteAsset, SpriteAssetMetadata, SpriteBorder};
|
||||
use crate::ui_editor::resource::ui_design_image::{
|
||||
UIDesignImage, UIDesignImageMetadata, UIDesignImageRole,
|
||||
};
|
||||
use crate::ui_editor::resource::ui_design_image::UIDesignImage;
|
||||
use crate::ui_editor::utils::{SpriteAssetId, UIDesignImageId};
|
||||
use crate::*;
|
||||
use image::GenericImageView as _;
|
||||
@@ -801,7 +799,7 @@ fn ensure_page_ui_resource(
|
||||
})?;
|
||||
let image_id = UIDesignImageId::new(page.page_id.clone())
|
||||
.map_err(|error| format!("页面 image ID 无效:{error}"))?;
|
||||
let expected_image = workflow_design_image(root, page, design_asset)?;
|
||||
let expected_image = workflow_design_image(root, design_asset)?;
|
||||
let mut state = snapshot.state;
|
||||
match state.ui_design_images.get(&image_id) {
|
||||
Some(current) if current != &expected_image => {
|
||||
@@ -1235,7 +1233,6 @@ fn workflow_stage_rank(kind: &str) -> Option<u8> {
|
||||
|
||||
fn workflow_design_image(
|
||||
root: &Path,
|
||||
page: &UiWorkflowPageInput,
|
||||
design_asset: &GameCreationAppAssetManifestEntry,
|
||||
) -> Result<UIDesignImage, String> {
|
||||
let absolute = resolve_local_project_path(root, &design_asset.local_path)?;
|
||||
@@ -1248,12 +1245,6 @@ fn workflow_design_image(
|
||||
return Err("页面设计图尺寸无效".to_string());
|
||||
}
|
||||
Ok(UIDesignImage {
|
||||
metadata: UIDesignImageMetadata {
|
||||
name: page.title.trim().to_string(),
|
||||
description: page.description.trim().to_string(),
|
||||
role: Some(UIDesignImageRole::Page),
|
||||
slave_to: None,
|
||||
},
|
||||
path: design_asset.local_path.clone(),
|
||||
pixel_size: Vector2::new(width as f32, height as f32),
|
||||
pixels_per_unit: StrictlyPositiveFinite::new(1.0)
|
||||
|
||||
Reference in New Issue
Block a user