From 7915d5803853ba2fdf78796ada389873ae28b665 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 31 Aug 2026 19:04:36 +0800 Subject: [PATCH 01/30] =?UTF-8?q?=E6=8B=86=E5=88=86=20UI=20State=20?= =?UTF-8?q?=E5=88=B0=20HTML=20=E6=B8=B2=E6=9F=93=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 拆分 html_renderer 为资源、节点、容器、文本和图片模块 接入 ui.design.to_html Runtime 工具 保留临时 ui-design-to-html 测试 bin 生产渲染器输出带元数据注释的 HTML 片段 --- .../src-tauri/Cargo.lock | 35 ++ .../src-tauri/Cargo.toml | 1 + .../agent/runtime_actions/action_execution.rs | 1 + .../runtime_actions/autonomous_policy.rs | 1 + .../agent/runtime_actions/parallel_ledger.rs | 2 + .../runtime_actions/tool_policy_snapshot.rs | 2 + .../src-tauri/src/agent/runtime_tools.rs | 2 + .../src/agent/runtime_tools/policy.rs | 1 + .../src/agent/runtime_tools/ui_html.rs | 57 +++ .../src-tauri/src/agent_native_tools.rs | 11 + .../src-tauri/src/bin/ui-design-to-html.rs | 399 ++++++++++++++++++ .../src/ui_editor/html_renderer/assets.rs | 61 +++ .../src/ui_editor/html_renderer/component.rs | 57 +++ .../src/ui_editor/html_renderer/container.rs | 82 ++++ .../src/ui_editor/html_renderer/image.rs | 156 +++++++ .../src/ui_editor/html_renderer/mod.rs | 142 +++++++ .../src/ui_editor/html_renderer/node.rs | 80 ++++ .../src/ui_editor/html_renderer/text.rs | 77 ++++ .../src-tauri/src/ui_editor/mod.rs | 1 + 19 files changed, 1168 insertions(+) create mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/ui_html.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/bin/ui-design-to-html.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/assets.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/component.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/container.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/image.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/mod.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/node.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/text.rs diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.lock b/apps/ai-game-creator-shell/src-tauri/Cargo.lock index a6936c44b..5c500bae0 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.lock +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.lock @@ -1715,6 +1715,7 @@ dependencies = [ "image", "jsonschema", "libc", + "maud", "nalgebra", "oxc_allocator", "oxc_ast", @@ -2732,6 +2733,28 @@ dependencies = [ "rawpointer", ] +[[package]] +name = "maud" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8156733e27020ea5c684db5beac5d1d611e1272ab17901a49466294b84fc217e" +dependencies = [ + "itoa", + "maud_macros", +] + +[[package]] +name = "maud_macros" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7261b00f3952f617899bc012e3dbd56e4f0110a038175929fa5d18e5a19913ca" +dependencies = [ + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn 2.0.118", +] + [[package]] name = "memchr" version = "2.8.2" @@ -3965,6 +3988,18 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proc-macro2-diagnostics" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", + "version_check", +] + [[package]] name = "psl-types" version = "2.0.11" diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.toml b/apps/ai-game-creator-shell/src-tauri/Cargo.toml index 318951ed9..5569adccb 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.toml +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.toml @@ -56,6 +56,7 @@ unicode-normalization = "0.1" uuid = { version = "1", features = ["v4"] } zip = { version = "2", default-features = false, features = ["deflate"] } tauri-plugin-clipboard-manager = "2.3.2" +maud = "0.27.0" [target.'cfg(unix)'.dependencies] libc = "0.2" diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs index a2063143b..58e696980 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs @@ -421,6 +421,7 @@ pub(crate) async fn execute_game_creator_agent_runtime_tool_action_with_pending_ "ui.workflow.run" => { observe_agent_runtime_ui_workflow(root, agent_id, run_id, task, &action.input).await } + "ui.design.to_html" => observe_agent_runtime_ui_design_to_html(root, &action.input), "blackboard.write" => { observe_agent_runtime_blackboard_write(root, agent_id, run_id, &action.input) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs index eeb2f00ff..a6cb76010 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs @@ -1097,6 +1097,7 @@ pub(in crate::agent) fn validate_agent_runtime_autonomous_read_only_delivery_pla | "command.output_read" | "command.poll" | "image.inspect" => false, + "ui.design.to_html" => false, "preview.validate" => agent_id != "preview-playtest" && agent_id != "code-prototype", "command.run_limited" => { agent_id != "preview-readiness" && agent_id != "code-prototype" diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/parallel_ledger.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/parallel_ledger.rs index d047701d8..1de96d573 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/parallel_ledger.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/parallel_ledger.rs @@ -12,6 +12,7 @@ pub(crate) fn agent_runtime_tool_is_parallel_safe_read(tool: &str) -> bool { | "file.list" | "file.read" | "task.list" + | "ui.design.to_html" ) } @@ -100,6 +101,7 @@ pub(in crate::agent) fn game_creator_agent_runtime_tool_command_id( "image.inspect" => Some("image.inspect"), "canvas.asset_generate" => Some("canvas.asset_generate"), "ui.workflow.run" => Some("asset.register"), + "ui.design.to_html" => Some("ui.design.to_html"), "blackboard.write" => Some("memory.write"), "agent.message" => Some("conversation.write"), "agent.delegate" => Some("agent.delegate"), diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs index 0a58e049f..eb24abcdc 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs @@ -100,6 +100,7 @@ pub(crate) fn agent_runtime_executable_tools() -> Vec<&'static str> { "image.inspect", "canvas.asset_generate", "ui.workflow.run", + "ui.design.to_html", "blackboard.write", "agent.message", "agent.delegate", @@ -339,6 +340,7 @@ pub(crate) fn agent_runtime_autonomous_design_foundation_command_is_allowed( | "image.inspect" | "canvas.asset_generate" | "canvas.asset_import" + | "ui.design.to_html" | "agent.audit" | "agent.run_status" ) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs index 1478ae0aa..0148f6d0f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs @@ -17,6 +17,7 @@ mod project_ops; mod run_status; mod task_ops; mod ui_workflow; +mod ui_html; pub(in crate::agent) use action_history::*; pub(in crate::agent) use command_ops::*; @@ -35,6 +36,7 @@ pub(in crate::agent) use project_ops::*; pub(in crate::agent) use run_status::*; pub(in crate::agent) use task_ops::*; pub(in crate::agent) use ui_workflow::*; +pub(in crate::agent) use ui_html::*; #[cfg(test)] pub(crate) use delegation::observe_agent_runtime_agent_delegate_at_locked; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/policy.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/policy.rs index 8fc4f477f..bceb052b7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/policy.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/policy.rs @@ -49,6 +49,7 @@ fn autonomous_design_foundation_command_is_allowed(command_id: &str) -> bool { | "canvas.asset_import" | "asset.register" | "ui.workflow.run" + | "ui.design.to_html" | "agent.audit" | "agent.action_history" | "agent.run_status" diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/ui_html.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/ui_html.rs new file mode 100644 index 000000000..e0ceceb2a --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/ui_html.rs @@ -0,0 +1,57 @@ +use super::*; +use crate::ui_editor::html_renderer::render_ui_design_state_html; +use crate::ui_editor::persistence::{load_ui_design_state_at, LoadUiDesignStateInput}; +use serde_json::Value; + +pub(in crate::agent) fn observe_agent_runtime_ui_design_to_html( + root: &Path, + input: &Value, +) -> AgentRuntimeToolObservation { + let asset_id = input + .get("assetId") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()); + let Some(asset_id) = asset_id else { + return AgentRuntimeToolObservation { + tool: "ui.design.to_html".to_string(), + status: "rejected".to_string(), + summary: "ui.design.to_html 缺少 assetId".to_string(), + detail: None, + }; + }; + let project_id = match game_creator_agent_runtime_context_project_id(root) { + Ok(project_id) => project_id, + Err(error) => return ui_html_error(root, error), + }; + let snapshot = match load_ui_design_state_at(LoadUiDesignStateInput { + project_path: root.to_string_lossy().into_owned(), + expected_project_id: project_id, + asset_id: asset_id.to_string(), + }) { + Ok(snapshot) => snapshot, + Err(error) => return ui_html_error(root, error), + }; + match render_ui_design_state_html(&snapshot.state) { + Ok(html) => AgentRuntimeToolObservation { + tool: "ui.design.to_html".to_string(), + status: "ok".to_string(), + summary: format!( + "已生成 UI HTML(revision {},{} 字节)", + snapshot.revision, + html.len() + ), + detail: Some(html), + }, + Err(error) => ui_html_error(root, error), + } +} + +fn ui_html_error(root: &Path, error: String) -> AgentRuntimeToolObservation { + AgentRuntimeToolObservation { + tool: "ui.design.to_html".to_string(), + status: "error".to_string(), + summary: redact_agent_runtime_project_paths(root, &error, 500), + detail: None, + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs index 8ef1b13d1..4c1185a6f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs @@ -1391,6 +1391,9 @@ fn runtime_tool_description(tool: &str) -> &'static str { "ui.workflow.run" => { "先用 discover 从受控 game/ui-pages.json 或页面声明标记自动发现全部功能页面,再把已登记 ui-prototype 与每个页面的设计图桥接成独立 UI JSON State;可同时载入已登记图片、图标和项目字体,执行 Provider 结构识别、多树合并与分批组件绑定、回读阶段,并且只有所有页面已绑定且已应用到 game/ 后才允许 finalize。项目根目录由 Runtime 注入,模型不得传入宿主路径。" } + "ui.design.to_html" => { + "只读读取指定 UI 设计, 生成HTML片段;" + } "blackboard.write" => "向项目级共享黑板追加稳定结论。", "agent.message" => "向一个目标 Agent 写入定向上下文消息。", "agent.delegate" => { @@ -1683,6 +1686,14 @@ fn runtime_tool_input_schema(tool: &str) -> Value { } } }), + "ui.design.to_html" => json!({ + "type": "object", + "required": ["assetId"], + "additionalProperties": false, + "properties": { + "assetId": { "type": "string", "minLength": 1, "maxLength": 160 } + } + }), "blackboard.write" => two_string_input_schema("title", "content"), "agent.message" => two_string_input_schema("agentId", "content"), "agent.delegate" => json!({ diff --git a/apps/ai-game-creator-shell/src-tauri/src/bin/ui-design-to-html.rs b/apps/ai-game-creator-shell/src-tauri/src/bin/ui-design-to-html.rs new file mode 100644 index 000000000..4bd2b2766 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/bin/ui-design-to-html.rs @@ -0,0 +1,399 @@ +use maud::{html, PreEscaped}; +use serde_json::Value; +use std::env; +use std::fmt::Write as _; +use std::fs; + +fn main() { + let args = env::args().skip(1).collect::>(); + if args.len() != 2 { + eprintln!("用法:ui-design-to-html "); + std::process::exit(2); + } + let input = fs::read_to_string(&args[0]) + .unwrap_or_else(|error| fail(&format!("读取输入失败:{error}"))); + let document: Value = serde_json::from_str(&input) + .unwrap_or_else(|error| fail(&format!("解析 UI JSON 失败:{error}"))); + let state = document.get("state").unwrap_or(&document); + let html = render_state(state).unwrap_or_else(|error| fail(&error)); + fs::write(&args[1], html).unwrap_or_else(|error| fail(&format!("写入 HTML 失败:{error}"))); +} + +fn render_state(state: &Value) -> Result { + let trees = state + .get("ui_trees") + .and_then(Value::as_array) + .ok_or("State 缺少 ui_trees")?; + let mut body = String::new(); + for (index, tree) in trees.iter().enumerate() { + if index > 0 { + body.push_str("\n\n"); + } + body.push_str(&render_tree(state, tree)?); + } + Ok(html! { + (PreEscaped("")) + html { + head { + meta charset="utf-8"; + meta name="viewport" content="width=device-width, initial-scale=1"; + style { (PreEscaped("html,body{margin:0;width:100%;min-height:100%;}body{overflow:auto;}[data-ui-tree]{position:relative;width:100%;min-height:0;overflow:hidden;}:root{--ui-scale:1;}")) } + } + body { (PreEscaped(body)) } + } + }.into_string()) +} + +fn render_tree(state: &Value, tree: &Value) -> Result { + let source = tree + .get("src_ui_design") + .and_then(Value::as_str) + .ok_or("UITree 缺少 src_ui_design")?; + let image = state + .get("ui_design_images") + .and_then(|images| images.get(source)) + .ok_or_else(|| format!("缺少设计图资源:{source}"))?; + let width = logical_dimension(image, 0)?; + let height = logical_dimension(image, 1)?; + let root = tree.get("root").ok_or("UITree 缺少 root")?; + let mut content = String::new(); + let tree_metadata = + serde_json::json!({"srcUiDesign": source, "width": width, "height": height}); + write!( + &mut content, + "", + comment_safe(&tree_metadata.to_string()) + ) + .unwrap(); + write!(&mut content, "
", html_escape(source), width, height, width, height).unwrap(); + content.push_str(&render_node(state, root, false)?); + content.push_str("
"); + Ok(content) +} + +fn render_node(state: &Value, node: &Value, in_container: bool) -> Result { + let id = node + .get("id") + .and_then(Value::as_str) + .ok_or("Node 缺少 id")?; + let metadata = node.get("metadata").ok_or("Node 缺少 metadata")?; + let name = metadata + .get("name") + .and_then(Value::as_str) + .unwrap_or_default(); + let description = metadata + .get("description") + .and_then(Value::as_str) + .unwrap_or_default(); + let layout = node.get("layout").ok_or("Node 缺少 layout")?; + let node_metadata = serde_json::json!({"nodeId": id, "name": name, "description": description}); + let mut output = format!( + "", + comment_safe(&node_metadata.to_string()) + ); + if node.get("children_display_mode").and_then(Value::as_str) == Some("Exclusive") { + output.push_str(""); + } + let style = node_style(layout, in_container)?; + let style = format!("{style}border:0;outline:0;background:transparent;overflow:visible;"); + write!( + &mut output, + "
", + html_escape(id), + style + ) + .unwrap(); + if let Some(components) = node.get("components").and_then(Value::as_array) { + for component in components { + output.push_str(&render_component(state, component)?); + } + } + let child_container = layout.get("container").is_some_and(|value| value != "None"); + if let Some(children) = node.get("children").and_then(Value::as_array) { + for child in children { + output.push_str(&render_node(state, child, child_container)?); + } + } + output.push_str("
"); + Ok(output) +} + +fn render_component(state: &Value, component: &Value) -> Result { + if let Some(text) = component.get("Text") { + let content = text + .get("content") + .and_then(Value::as_str) + .unwrap_or_default(); + let size = text + .get("font_sizing") + .and_then(|sizing| { + sizing + .get("Fixed") + .or_else(|| sizing.get("BestFit").and_then(|range| range.get("min"))) + }) + .and_then(Value::as_u64) + .unwrap_or(14); + let alignment = text + .get("alignment") + .and_then(Value::as_str) + .unwrap_or("UpperLeft"); + let horizontal = if alignment.ends_with("Center") { + "center" + } else if alignment.ends_with("Right") { + "right" + } else { + "left" + }; + let vertical = if alignment.starts_with("Middle") { + "center" + } else if alignment.starts_with("Lower") { + "flex-end" + } else { + "flex-start" + }; + let color = text + .get("color") + .and_then(Value::as_array) + .map(|v| { + [ + v.first().and_then(Value::as_u64).unwrap_or(255), + v.get(1).and_then(Value::as_u64).unwrap_or(255), + v.get(2).and_then(Value::as_u64).unwrap_or(255), + v.get(3).and_then(Value::as_u64).unwrap_or(255), + ] + }) + .unwrap_or([255, 255, 255, 255]); + let white_space = + if text.get("horizontal_overflow").and_then(Value::as_str) == Some("Overflow") { + "nowrap" + } else { + "normal" + }; + let overflow = if text.get("vertical_overflow").and_then(Value::as_str) == Some("Overflow") + { + "visible" + } else { + "hidden" + }; + let overflow_wrap = if white_space == "normal" { + "anywhere" + } else { + "normal" + }; + let line_spacing = text + .get("line_spacing") + .and_then(Value::as_f64) + .unwrap_or(1.0); + let (font_weight, font_style) = match text.get("font_style").and_then(Value::as_str) { + Some("Bold") => (700, "normal"), + Some("Italic") => (400, "italic"), + Some("BoldItalic") => (700, "italic"), + _ => (400, "normal"), + }; + let style = format!("position:absolute;inset:0;display:flex;width:100%;height:100%;box-sizing:border-box;color:rgba({},{},{},{});font-size:calc({size}px * var(--ui-scale, 1));font-weight:{font_weight};font-style:{font_style};align-items:{vertical};justify-content:{};text-align:{horizontal};line-height:{line_spacing};white-space:{white_space};overflow:{overflow};overflow-wrap:{overflow_wrap};", color[0], color[1], color[2], color[3] as f32 / 255.0, if horizontal == "left" { "flex-start" } else if horizontal == "center" { "center" } else { "flex-end" }); + return Ok( + html! { div data-component-kind="Text" style=(style) { (content) } }.into_string(), + ); + } + if let Some(image) = component.get("Image") { + let target = image + .get("target_graphic") + .and_then(Value::as_str) + .ok_or("Image 缺少 target_graphic")?; + let sprite = state + .get("sprite_assets") + .and_then(|sprites| sprites.get(target)) + .ok_or_else(|| format!("缺少 Sprite 资源:{target}"))?; + let path = sprite + .get("path") + .and_then(Value::as_str) + .ok_or("Sprite 缺少 path")?; + if path.trim().is_empty() + || path.starts_with('/') + || path.split('/').any(|part| part == "..") + || path.chars().any(|character| { + character.is_control() || matches!(character, '\'' | '"' | '`' | '(' | ')') + }) + { + return Err(format!("资源路径无效:{path}")); + } + let src = format!("/{path}"); + let image_type = image.get("image_type").unwrap_or(&Value::Null); + if let Some(tiled) = image_type.get("Tiled") { + let multiplier = tiled + .get("pixels_per_unit_multiplier") + .and_then(Value::as_f64) + .unwrap_or(1.0); + let w = logical_sprite_dimension(sprite, 0, multiplier)?; + let h = logical_sprite_dimension(sprite, 1, multiplier)?; + let style = format!("position:absolute;inset:0;overflow:hidden;background-image:url(\"{src}\");background-repeat:repeat;background-position:top left;background-size:{w}px {h}px;"); + return Ok(html! { div data-component-kind="Image" style=(style) {} }.into_string()); + } + let mut image_style = "width:100%;height:100%;display:block;object-fit:fill;".to_string(); + if let Some(simple) = image_type.get("Simple") { + if simple + .get("preserve_aspect") + .and_then(Value::as_bool) + .unwrap_or(false) + { + image_style = image_style.replace("fill", "contain"); + } + } + if let Some(filled) = image_type.get("Filled") { + let amount = filled + .get("amount") + .and_then(Value::as_f64) + .unwrap_or(1.0) + .clamp(0.0, 1.0); + if amount < 1.0 { + image_style.push_str(&format!( + "clip-path:inset(0 {}% 0 0);", + (1.0 - amount) * 100.0 + )); + } + } + return Ok(html! { div data-component-kind="Image" style="position:absolute;inset:0;overflow:hidden;" { img src=(src) alt="" aria-hidden="true" draggable="false" style=(image_style); } }.into_string()); + } + Err("未知 UI 组件类型".to_string()) +} + +fn node_style(layout: &Value, in_container: bool) -> Result { + let transform = layout + .get("transform") + .ok_or("ControlLayout 缺少 transform")?; + let amin = pair(transform, "anchor_min")?; + let amax = pair(transform, "anchor_max")?; + let omin = pair(transform, "offset_min")?; + let omax = pair(transform, "offset_max")?; + if amin[0] > amax[0] || amin[1] > amax[1] { + return Err("Transform anchors must be ordered".to_string()); + } + let mut style = if in_container { + "position:relative;".to_string() + } else { + format!( + "position:absolute;left:{};top:{};right:{};bottom:{};", + css_length(amin[0] * 100.0, omin[0])?, + css_length(amin[1] * 100.0, omin[1])?, + css_length((1.0 - amax[0]) * 100.0, -omax[0])?, + css_length((1.0 - amax[1]) * 100.0, -omax[1])? + ) + }; + if let Some(container) = layout.get("container") { + style.push_str(&container_css(container)); + } + Ok(style) +} + +fn container_css(container: &Value) -> String { + if container == "None" { + return String::new(); + } + if let Some(props) = container.get("HBox") { + return format!( + "display:flex;flex-direction:row;gap:calc({}px * var(--ui-scale, 1));", + number(props.get("separation")) + ); + } + if let Some(props) = container.get("VBox") { + return format!( + "display:flex;flex-direction:column;gap:calc({}px * var(--ui-scale, 1));", + number(props.get("separation")) + ); + } + if let Some(props) = container.get("Grid") { + return format!("display:grid;grid-template-columns:repeat({},minmax(0,1fr));column-gap:calc({}px * var(--ui-scale, 1));row-gap:calc({}px * var(--ui-scale, 1));", props.get("columns").and_then(Value::as_u64).unwrap_or(1), number(props.get("h_separation")), number(props.get("v_separation"))); + } + if let Some(props) = container.get("Margin") { + return format!( + "display:grid;padding:calc({}px * var(--ui-scale, 1)) calc({}px * var(--ui-scale, 1)) calc({}px * var(--ui-scale, 1)) calc({}px * var(--ui-scale, 1));", + number(props.get("margin_top")), + number(props.get("margin_right")), + number(props.get("margin_bottom")), + number(props.get("margin_left")) + ); + } + "display:grid;place-items:center;".to_string() +} + +fn css_length(percent: f64, offset: f64) -> Result { + if !percent.is_finite() || !offset.is_finite() { + return Err("Transform 包含非有限数值".to_string()); + } + if percent == 0.0 { + return Ok(format!("calc({offset}px * var(--ui-scale, 1))")); + } + if offset == 0.0 { + return Ok(format!("{percent}%")); + } + Ok(format!( + "calc({percent}% {} {}px * var(--ui-scale, 1))", + if offset < 0.0 { '-' } else { '+' }, + offset.abs() + )) +} +fn pair(value: &Value, field: &str) -> Result<[f64; 2], String> { + let values = value + .get(field) + .and_then(Value::as_array) + .ok_or_else(|| format!("Transform 缺少 {field}"))?; + if values.len() != 2 { + return Err(format!("Transform.{field} 不是二元数组")); + } + Ok([ + values[0].as_f64().ok_or("Transform 数值无效")?, + values[1].as_f64().ok_or("Transform 数值无效")?, + ]) +} +fn logical_dimension(image: &Value, index: usize) -> Result { + let pixels = image + .get("pixel_size") + .and_then(Value::as_array) + .and_then(|v| v.get(index)) + .and_then(Value::as_f64) + .ok_or("设计图 pixel_size 无效")?; + let ppu = image + .get("pixels_per_unit") + .and_then(Value::as_f64) + .ok_or("设计图 pixels_per_unit 无效")?; + if pixels <= 0.0 || ppu <= 0.0 { + return Err("设计图尺寸无效".to_string()); + } + Ok(pixels / ppu) +} +fn logical_sprite_dimension(sprite: &Value, index: usize, multiplier: f64) -> Result { + let pixels = sprite + .get("pixel_size") + .and_then(Value::as_array) + .and_then(|v| v.get(index)) + .and_then(Value::as_f64) + .ok_or("Sprite pixel_size 无效")?; + let ppu = sprite + .get("pixels_per_unit") + .and_then(Value::as_f64) + .ok_or("Sprite pixels_per_unit 无效")?; + if pixels <= 0.0 || ppu <= 0.0 || multiplier <= 0.0 { + return Err("Sprite 尺寸无效".to_string()); + } + Ok(pixels / (ppu * multiplier)) +} +fn number(value: Option<&Value>) -> String { + value + .and_then(Value::as_f64) + .map(|v| v.to_string()) + .unwrap_or_else(|| "0".to_string()) +} +fn comment_safe(value: &str) -> String { + value.replace("--", "- -") +} +fn html_escape(value: &str) -> String { + value + .replace('&', "&") + .replace('"', """) + .replace('<', "<") + .replace('>', ">") +} +fn fail(message: &str) -> ! { + eprintln!("{message}"); + std::process::exit(1) +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/assets.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/assets.rs new file mode 100644 index 000000000..2b2e3d90c --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/assets.rs @@ -0,0 +1,61 @@ +use maud::{Markup, PreEscaped}; + +pub(super) fn font_face_rule( + font: &crate::ui_editor::resource::font::FontAsset, + family: &str, +) -> Result { + let path = asset_url(&font.path)?; + Ok(format!("@font-face{{font-family:'{family}';src:url('{path}') format('{}');font-style:{};font-weight:{};}}", font.metadata.format.extension(), if font.metadata.italic { "italic" } else { "normal" }, font.metadata.weight)) +} + +pub(super) fn html_comment(label: &str, value: serde_json::Value) -> Markup { + let text = serde_json::to_string(&value) + .unwrap_or_else(|_| "{}".to_string()) + .replace("--", "- -"); + PreEscaped(format!("")) +} + +pub(super) fn asset_url(path: &str) -> Result { + let path = path.trim(); + if path.is_empty() + || path.starts_with('/') + || path.split('/').any(|part| part == "..") + || path.chars().any(|character| { + character.is_control() || matches!(character, '\'' | '"' | '`' | '(' | ')') + }) + { + return Err(format!("资源路径无效:{path}")); + } + Ok(format!("/{path}")) +} + +pub(super) fn finite(value: f32, field: &str) -> Result { + if value.is_finite() { + Ok(value) + } else { + Err(format!("{field} 必须是有限数值")) + } +} + +pub(super) fn finite_positive(value: f32, field: &str) -> Result { + finite(value, field).and_then(|v| { + if v > 0.0 { + Ok(v) + } else { + Err(format!("{field} 必须大于 0")) + } + }) +} + +pub(super) fn trim_float(value: f32) -> String { + let text = format!("{value:.4}"); + text.trim_end_matches('0').trim_end_matches('.').to_string() +} + +pub(super) fn hex_id(value: &str) -> String { + value + .as_bytes() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/component.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/component.rs new file mode 100644 index 000000000..68ea12110 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/component.rs @@ -0,0 +1,57 @@ +use super::assets::{asset_url, hex_id}; +use super::image::image_styles; +use super::text::text_style; +use crate::ui_editor::component::text::FontSource; +use crate::ui_editor::component::Component; +use crate::ui_editor::state::State; +use maud::{html, Markup}; + +pub(super) fn render_component(state: &State, component: &Component) -> Result { + match component { + Component::Text(text) => { + let mut style = text_style( + text.font_style, + text.alignment, + text.font_sizing, + text.color, + text.line_spacing.get(), + text.horizontal_overflow, + text.vertical_overflow, + ); + if let FontSource::Bound(font_id) = &text.font { + state + .font_assets + .get(font_id) + .ok_or_else(|| format!("Text 缺少字体资源:{}", font_id.as_str()))?; + let family = format!("ui-editor-font-{}", hex_id(font_id.as_str())); + style.push_str(&format!("font-family:'{family}';")); + return Ok(html! { + div data-component-kind="Text" style=(style) { + (text.content) + } + }); + } + Ok(html! { div data-component-kind="Text" style=(style) { (text.content) } }) + } + Component::Image(image) => { + let sprite_id = image + .target_graphic + .as_ref() + .ok_or_else(|| "Image 缺少 target_graphic".to_string())?; + let sprite = state + .sprite_assets + .get(sprite_id) + .ok_or_else(|| format!("Image 缺少 Sprite 资源:{}", sprite_id.as_str()))?; + let src = asset_url(&sprite.path)?; + let (style, image_style) = image_styles(&image.image_type, sprite, &src)?; + Ok(match image_style { + Some(image_style) => html! { + div data-component-kind="Image" style=(style) { + img src=(src) alt="" aria-hidden="true" draggable="false" style=(image_style); + } + }, + None => html! { div data-component-kind="Image" style=(style) {} }, + }) + } + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/container.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/container.rs new file mode 100644 index 000000000..1d6948fd5 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/container.rs @@ -0,0 +1,82 @@ +use super::assets::trim_float; +use crate::ui_editor::layout::control_layout::{Container, ControlLayout}; + +const UI_SCALE: &str = "var(--ui-scale, 1)"; + +pub(super) fn container_style(container: &Container) -> String { + match container { + Container::None => String::new(), + Container::HBox { alignment, separation } => format!("display:flex;flex-direction:row;justify-content:{};gap:{};min-width:0;min-height:0;", alignment_css(*alignment), scaled_px(*separation).unwrap_or_else(|_| "0px".to_string())), + Container::VBox { alignment, separation } => format!("display:flex;flex-direction:column;justify-content:{};gap:{};min-width:0;min-height:0;", alignment_css(*alignment), scaled_px(*separation).unwrap_or_else(|_| "0px".to_string())), + Container::Grid { columns, h_separation, v_separation } => format!("display:grid;grid-template-columns:repeat({},minmax(0,1fr));column-gap:{};row-gap:{};min-width:0;min-height:0;", columns, scaled_px(*h_separation).unwrap_or_default(), scaled_px(*v_separation).unwrap_or_default()), + Container::Margin { margin_left, margin_top, margin_right, margin_bottom } => format!("display:grid;padding-left:{};padding-top:{};padding-right:{};padding-bottom:{};min-width:0;min-height:0;", scaled_px(*margin_left).unwrap_or_default(), scaled_px(*margin_top).unwrap_or_default(), scaled_px(*margin_right).unwrap_or_default(), scaled_px(*margin_bottom).unwrap_or_default()), + Container::Center { .. } => "display:grid;place-items:center;min-width:0;min-height:0;".to_string(), + } +} + +pub(super) fn child_container_style(layout: &ControlLayout, parent: &Container) -> String { + let min_w = scaled_px(layout.custom_minimum_size.x).unwrap_or_default(); + let min_h = scaled_px(layout.custom_minimum_size.y).unwrap_or_default(); + match parent { + Container::HBox { .. } => format!( + "min-width:{min_w};min-height:{min_h};{}align-self:{};", + flex_grow( + layout.size_flags_horizontal, + layout.size_flags_stretch_ratio + ), + cross_axis(layout.size_flags_vertical) + ), + Container::VBox { .. } => format!( + "min-width:{min_w};min-height:{min_h};{}align-self:{};", + flex_grow(layout.size_flags_vertical, layout.size_flags_stretch_ratio), + cross_axis(layout.size_flags_horizontal) + ), + Container::Margin { .. } => { + "grid-area:1 / 1;align-self:stretch;justify-self:stretch;".to_string() + } + Container::Center { use_top_left } => { + if *use_top_left { + "justify-self:center;align-self:center;transform:translate(50%,50%);".to_string() + } else { + "justify-self:center;align-self:center;".to_string() + } + } + Container::Grid { .. } | Container::None => { + format!("min-width:{min_w};min-height:{min_h};") + } + } +} + +fn scaled_px(value: f32) -> Result { + if !value.is_finite() { + return Err("layout px 必须是有限数值".to_string()); + } + Ok(format!("calc({}px * {})", trim_float(value), UI_SCALE)) +} +fn alignment_css( + value: crate::ui_editor::layout::control_layout::ContainerAlignment, +) -> &'static str { + match value { + crate::ui_editor::layout::control_layout::ContainerAlignment::Begin => "flex-start", + crate::ui_editor::layout::control_layout::ContainerAlignment::Center => "center", + crate::ui_editor::layout::control_layout::ContainerAlignment::End => "flex-end", + } +} +fn cross_axis(value: u8) -> &'static str { + if value & 1 != 0 { + "stretch" + } else if value == 4 { + "center" + } else if value == 8 { + "flex-end" + } else { + "flex-start" + } +} +fn flex_grow(flags: u8, ratio: f32) -> String { + if flags & 2 != 0 && ratio.is_finite() && ratio >= 0.0 { + format!("flex-grow:{};", trim_float(ratio)) + } else { + String::new() + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/image.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/image.rs new file mode 100644 index 000000000..c63b39237 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/image.rs @@ -0,0 +1,156 @@ +use super::assets::{finite_positive, trim_float}; +use crate::ui_editor::component::image::{ + FillMethod, HorizontalFillOrigin, ImageType, Radial180Origin, Radial360Origin, Radial90Origin, + VerticalFillOrigin, +}; + +const UI_SCALE: &str = "var(--ui-scale, 1)"; + +pub(super) fn image_styles( + image_type: &ImageType, + sprite: &crate::ui_editor::resource::sprite::SpriteAsset, + src: &str, +) -> Result<(String, Option), String> { + match image_type { + ImageType::Simple { preserve_aspect } => Ok(( + "position:absolute;inset:0;overflow:hidden;".to_string(), + Some(format!( + "width:100%;height:100%;display:block;object-fit:{};", + if *preserve_aspect { "contain" } else { "fill" } + )), + )), + ImageType::Filled { + preserve_aspect, + method, + amount, + } => { + let mut image = format!( + "width:100%;height:100%;display:block;object-fit:{};", + if *preserve_aspect { "contain" } else { "fill" } + ); + if let Some(clip) = fill_clip_path(method, amount.clamp(0.0, 1.0)) { + if clip.starts_with("conic-gradient") { + image.push_str(&format!("mask-image:{clip};-webkit-mask-image:{clip};")); + } else { + image.push_str(&format!("clip-path:{clip};")); + } + } + Ok(( + "position:absolute;inset:0;overflow:hidden;".to_string(), + Some(image), + )) + } + ImageType::Tiled { + pixels_per_unit_multiplier, + .. + } => { + let w = finite_positive( + sprite.pixel_size.x + / (sprite.pixels_per_unit().get() * pixels_per_unit_multiplier.get()), + "tile width", + )?; + let h = finite_positive( + sprite.pixel_size.y + / (sprite.pixels_per_unit().get() * pixels_per_unit_multiplier.get()), + "tile height", + )?; + Ok((format!("position:absolute;inset:0;background-image:url(\"{src}\");background-repeat:repeat;background-position:top left;background-size:calc({}px * {}) calc({}px * {});overflow:hidden;", trim_float(w), UI_SCALE, trim_float(h), UI_SCALE), None)) + } + ImageType::Sliced { + fill_center, + pixels_per_unit_multiplier, + } => { + let border = sprite.border(); + let scale = finite_positive( + sprite.pixels_per_unit().get() * pixels_per_unit_multiplier.get(), + "slice scale", + )?; + let widths = [ + border.top() as f32 / scale, + border.right() as f32 / scale, + border.bottom() as f32 / scale, + border.left() as f32 / scale, + ]; + Ok((format!("position:absolute;inset:0;overflow:hidden;border-style:solid;border-width:calc({}px * {}) calc({}px * {}) calc({}px * {}) calc({}px * {});border-image-source:url(\"{src}\");border-image-slice:{} {} {} {}{};border-image-width:calc({}px * {}) calc({}px * {}) calc({}px * {}) calc({}px * {});border-image-repeat:stretch;", trim_float(widths[0]), UI_SCALE, trim_float(widths[1]), UI_SCALE, trim_float(widths[2]), UI_SCALE, trim_float(widths[3]), UI_SCALE, border.top(), border.right(), border.bottom(), border.left(), if *fill_center { " fill" } else { "" }, trim_float(widths[0]), UI_SCALE, trim_float(widths[1]), UI_SCALE, trim_float(widths[2]), UI_SCALE, trim_float(widths[3]), UI_SCALE), None)) + } + } +} + +fn fill_clip_path(method: &FillMethod, amount: f32) -> Option { + if amount >= 1.0 { + return None; + } + if amount <= 0.0 { + return Some("inset(0 0 0 100%)".to_string()); + } + match method { + FillMethod::Horizontal(HorizontalFillOrigin::Left) => Some(format!( + "inset(0 {}% 0 0)", + trim_float((1.0 - amount) * 100.0) + )), + FillMethod::Horizontal(HorizontalFillOrigin::Right) => Some(format!( + "inset(0 0 0 {}%)", + trim_float((1.0 - amount) * 100.0) + )), + FillMethod::Vertical(VerticalFillOrigin::Top) => Some(format!( + "inset(0 0 {}% 0)", + trim_float((1.0 - amount) * 100.0) + )), + FillMethod::Vertical(VerticalFillOrigin::Bottom) => Some(format!( + "inset({}% 0 0 0)", + trim_float((1.0 - amount) * 100.0) + )), + FillMethod::Radial90 { origin, clockwise } => Some(conic_mask( + radial_origin_angle_90(*origin), + *clockwise, + amount, + 90.0, + )), + FillMethod::Radial180 { origin, clockwise } => Some(conic_mask( + radial_origin_angle_180(*origin), + *clockwise, + amount, + 180.0, + )), + FillMethod::Radial360 { origin, clockwise } => Some(conic_mask( + radial_origin_angle_360(*origin), + *clockwise, + amount, + 360.0, + )), + } +} +fn conic_mask(origin: f32, clockwise: bool, amount: f32, max_sweep: f32) -> String { + let sweep = amount * max_sweep; + let start = if clockwise { origin } else { origin - sweep }; + format!( + "conic-gradient(from {}deg, #000 0deg {}deg, transparent {}deg 360deg)", + trim_float(start), + trim_float(sweep), + trim_float(sweep) + ) +} +fn radial_origin_angle_90(origin: Radial90Origin) -> f32 { + match origin { + Radial90Origin::TopLeft => 0.0, + Radial90Origin::TopRight => 90.0, + Radial90Origin::BottomRight => 180.0, + Radial90Origin::BottomLeft => 270.0, + } +} +fn radial_origin_angle_180(origin: Radial180Origin) -> f32 { + match origin { + Radial180Origin::Top => 0.0, + Radial180Origin::Right => 90.0, + Radial180Origin::Bottom => 180.0, + Radial180Origin::Left => 270.0, + } +} +fn radial_origin_angle_360(origin: Radial360Origin) -> f32 { + match origin { + Radial360Origin::Top => 0.0, + Radial360Origin::Right => 90.0, + Radial360Origin::Bottom => 180.0, + Radial360Origin::Left => 270.0, + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/mod.rs new file mode 100644 index 000000000..275d1371d --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/mod.rs @@ -0,0 +1,142 @@ +mod assets; +mod component; +mod container; +mod image; +mod node; +mod text; + +use self::assets::{finite_positive, font_face_rule, hex_id, html_comment}; +use self::component::render_component; +use self::container::{child_container_style, container_style}; +use self::node::{is_container, transform_style}; +use crate::ui_editor::layout::children_display_mode::ChildrenDisplayMode; +use crate::ui_editor::layout::control_layout::Container; +use crate::ui_editor::layout::node::Node; +use crate::ui_editor::state::{State, UITree}; +use maud::{html, Markup, PreEscaped}; +use serde_json::json; + +pub(crate) fn render_ui_design_state_html(state: &State) -> Result { + let mut trees = Vec::with_capacity(state.ui_trees.len()); + for (index, tree) in state.ui_trees.iter().enumerate() { + if index > 0 { + trees.push("\n\n".to_string()); + } + trees.push(render_tree(state, tree)?.into_string()); + } + let font_faces = state + .font_assets + .values() + .map(|font| { + let family = format!("ui-editor-font-{}", hex_id(font.asset_id.as_str())); + font_face_rule(font, &family) + }) + .collect::, _>>()? + .join(""); + let fragment_comment = html_comment( + "genarrative-ui-fragment", + json!({ + "format": "html-fragment", + "treeCount": state.ui_trees.len(), + }), + ); + let fonts = (!font_faces.is_empty()).then(|| { + html! { + style data-ui-fonts { (PreEscaped(font_faces)) } + } + .into_string() + }); + let mut fragment = String::new(); + fragment.push_str(&fragment_comment.into_string()); + if let Some(fonts) = fonts { + fragment.push_str(&fonts); + } + fragment.push_str(&trees.concat()); + Ok(fragment) +} + +fn render_tree(state: &State, tree: &UITree) -> Result { + let image = state + .ui_design_images + .get(&tree.src_ui_design) + .ok_or_else(|| format!("UITree 缺少 src_ui_design:{}", tree.src_ui_design.as_str()))?; + let width = finite_positive(image.pixel_size.x, "UI design width")? + / finite_positive(image.pixels_per_unit.get(), "UI design pixelsPerUnit")?; + let height = finite_positive(image.pixel_size.y, "UI design height")? + / finite_positive(image.pixels_per_unit.get(), "UI design pixelsPerUnit")?; + let tree_comment = html_comment( + "genarrative-ui-tree", + json!({ + "srcUiDesign": tree.src_ui_design.as_str(), + "width": width, + "height": height, + }), + ); + let style = format!( + "position:relative;width:100%;height:auto;min-height:0;aspect-ratio:{width} / {height};--ui-design-width:{width}px;--ui-design-height:{height}px;" + ); + Ok(html! { + (tree_comment) + div data-ui-tree=(tree.src_ui_design.as_str()) style=(style) { + (render_node(state, &tree.root, None)?) + } + }) +} + +fn render_node( + state: &State, + node: &Node, + parent_container: Option<&Container>, +) -> Result { + let mut style = transform_style(&node.layout, parent_container.is_some())?; + style.push_str("border:0;outline:0;background:transparent;overflow:visible;"); + style.push_str(&container_style(&node.layout.container)); + if let Some(parent) = parent_container { + style.push_str(&child_container_style(&node.layout, parent)); + } + let comment = html_comment( + "genarrative-ui-node", + json!({ + "nodeId": node.id.as_str(), + "name": node.metadata.name, + "description": node.metadata.description, + }), + ); + let exclusive_comment = matches!(node.children_display_mode, ChildrenDisplayMode::Exclusive) + .then(|| { + html_comment( + "genarrative-ui-node-group", + json!({"childrenDisplayMode": "Exclusive", "childrenRendered": "all"}), + ) + }); + let components = node + .components + .iter() + .map(|component| render_component(state, component)) + .collect::, _>>()? + .into_iter() + .map(|fragment| fragment.into_string()) + .collect::>(); + let children = node + .children + .iter() + .map(|child| { + render_node( + state, + child, + is_container(&node.layout.container).then_some(&node.layout.container), + ) + }) + .collect::, _>>()? + .into_iter() + .map(|fragment| fragment.into_string()) + .collect::>(); + Ok(html! { + (comment) + @if let Some(group_comment) = exclusive_comment { (group_comment) } + div data-node-id=(node.id.as_str()) style=(style) { + (PreEscaped(components.concat())) + (PreEscaped(children.concat())) + } + }) +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/node.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/node.rs new file mode 100644 index 000000000..4e609b5e9 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/node.rs @@ -0,0 +1,80 @@ +use super::assets::{finite, trim_float}; +use crate::ui_editor::layout::control_layout::ControlLayout; + +const UI_SCALE: &str = "var(--ui-scale, 1)"; + +pub(super) fn transform_style( + layout: &ControlLayout, + in_container: bool, +) -> Result { + let transform = &layout.transform; + for value in transform + .anchor_min + .iter() + .chain(transform.anchor_max.iter()) + .chain(transform.offset_min.iter()) + .chain(transform.offset_max.iter()) + { + if !value.is_finite() { + return Err("Transform 包含非有限数值".to_string()); + } + } + if transform.anchor_min.x > transform.anchor_max.x + || transform.anchor_min.y > transform.anchor_max.y + { + return Err("Transform anchors must be ordered".to_string()); + } + if in_container { + return Ok(format!( + "position:relative;min-width:{};min-height:{};", + scaled_px(layout.custom_minimum_size.x)?, + scaled_px(layout.custom_minimum_size.y)? + )); + } + Ok(format!( + "position:absolute;left:{};top:{};right:{};bottom:{};", + css_length(transform.anchor_min.x * 100.0, transform.offset_min.x)?, + css_length(transform.anchor_min.y * 100.0, transform.offset_min.y)?, + css_length( + (1.0 - transform.anchor_max.x) * 100.0, + -transform.offset_max.x + )?, + css_length( + (1.0 - transform.anchor_max.y) * 100.0, + -transform.offset_max.y + )? + )) +} + +fn css_length(percent: f32, offset: f32) -> Result { + if !percent.is_finite() || !offset.is_finite() { + return Err("Transform 无法转换为 CSS".to_string()); + } + if percent == 0.0 { + return Ok(format!("calc({}px * {})", trim_float(offset), UI_SCALE)); + } + if offset == 0.0 { + return Ok(format!("{}%", trim_float(percent))); + } + Ok(format!( + "calc({}% {} {}px * {})", + trim_float(percent), + if offset < 0.0 { '-' } else { '+' }, + trim_float(offset.abs()), + UI_SCALE + )) +} + +fn scaled_px(value: f32) -> Result { + finite(value, "layout px")?; + Ok(format!("calc({}px * {})", trim_float(value), UI_SCALE)) +} + +pub(super) fn is_container( + container: &crate::ui_editor::layout::control_layout::Container, +) -> bool { + !matches!( + container, + crate::ui_editor::layout::control_layout::Container::None + ) +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/text.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/text.rs new file mode 100644 index 000000000..5857900e4 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/text.rs @@ -0,0 +1,77 @@ +use crate::ui_editor::component::text::{ + FontSizing, FontStyle, HorizontalTextOverflow, TextAlignment, VerticalTextOverflow, +}; + +const UI_SCALE: &str = "var(--ui-scale, 1)"; + +pub(super) fn text_style( + font_style: FontStyle, + alignment: TextAlignment, + sizing: FontSizing, + color: [u8; 4], + line_spacing: f32, + horizontal_overflow: HorizontalTextOverflow, + vertical_overflow: VerticalTextOverflow, +) -> String { + let [r, g, b, a] = color; + let (justify, align, text_align) = match alignment { + TextAlignment::UpperLeft | TextAlignment::MiddleLeft | TextAlignment::LowerLeft => ( + "flex-start", + if matches!(alignment, TextAlignment::MiddleLeft) { + "center" + } else if matches!(alignment, TextAlignment::LowerLeft) { + "flex-end" + } else { + "flex-start" + }, + "left", + ), + TextAlignment::UpperCenter | TextAlignment::MiddleCenter | TextAlignment::LowerCenter => ( + "center", + if matches!(alignment, TextAlignment::MiddleCenter) { + "center" + } else if matches!(alignment, TextAlignment::LowerCenter) { + "flex-end" + } else { + "flex-start" + }, + "center", + ), + _ => ( + "flex-end", + if matches!(alignment, TextAlignment::MiddleRight) { + "center" + } else if matches!(alignment, TextAlignment::LowerRight) { + "flex-end" + } else { + "flex-start" + }, + "right", + ), + }; + let (weight, italic) = match font_style { + FontStyle::Normal => (400, "normal"), + FontStyle::Bold => (700, "normal"), + FontStyle::Italic => (400, "italic"), + FontStyle::BoldItalic => (700, "italic"), + }; + let size = match sizing { + FontSizing::Fixed(value) => value.get(), + FontSizing::BestFit(range) => range.min().get(), + }; + let (white_space, overflow, wrap) = match (horizontal_overflow, vertical_overflow) { + (HorizontalTextOverflow::Wrap, VerticalTextOverflow::Truncate) => { + ("normal", "hidden", "anywhere") + } + (HorizontalTextOverflow::Wrap, VerticalTextOverflow::Overflow) => { + ("normal", "visible", "anywhere") + } + (HorizontalTextOverflow::Overflow, VerticalTextOverflow::Truncate) => { + ("nowrap", "hidden", "normal") + } + (HorizontalTextOverflow::Overflow, VerticalTextOverflow::Overflow) => { + ("nowrap", "visible", "normal") + } + }; + format!("position:absolute;inset:0;display:flex;width:100%;height:100%;box-sizing:border-box;padding:0;color:rgba({r},{g},{b},{});font-family:system-ui,-apple-system,BlinkMacSystemFont,\"Segoe UI\",sans-serif;font-size:calc({size}px * {UI_SCALE});font-weight:{weight};font-style:{italic};align-items:{align};justify-content:{justify};text-align:{text_align};line-height:{};white-space:{white_space};overflow:{overflow};overflow-wrap:{wrap};", f32::from(a) / 255.0, line_spacing) +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/mod.rs index d002c7611..51fb28ec3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/mod.rs @@ -1,5 +1,6 @@ pub mod commands; pub mod component; +pub(crate) mod html_renderer; pub mod layout; pub mod persistence; pub mod resource; -- 2.52.0 From 56be007ec998d7e47b47b1d0b29a6316e71e5e58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 31 Aug 2026 19:07:56 +0800 Subject: [PATCH 02/30] =?UTF-8?q?=E5=88=A0=E9=99=A4=E4=B8=B4=E6=97=B6=20UI?= =?UTF-8?q?=20HTML=20=E6=B5=8B=E8=AF=95=E5=B7=A5=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除 ui-design-to-html standalone bin 保留 Runtime ui.design.to_html 作为唯一 HTML 生成入口 --- .../src-tauri/src/bin/ui-design-to-html.rs | 399 ------------------ 1 file changed, 399 deletions(-) delete mode 100644 apps/ai-game-creator-shell/src-tauri/src/bin/ui-design-to-html.rs diff --git a/apps/ai-game-creator-shell/src-tauri/src/bin/ui-design-to-html.rs b/apps/ai-game-creator-shell/src-tauri/src/bin/ui-design-to-html.rs deleted file mode 100644 index 4bd2b2766..000000000 --- a/apps/ai-game-creator-shell/src-tauri/src/bin/ui-design-to-html.rs +++ /dev/null @@ -1,399 +0,0 @@ -use maud::{html, PreEscaped}; -use serde_json::Value; -use std::env; -use std::fmt::Write as _; -use std::fs; - -fn main() { - let args = env::args().skip(1).collect::>(); - if args.len() != 2 { - eprintln!("用法:ui-design-to-html "); - std::process::exit(2); - } - let input = fs::read_to_string(&args[0]) - .unwrap_or_else(|error| fail(&format!("读取输入失败:{error}"))); - let document: Value = serde_json::from_str(&input) - .unwrap_or_else(|error| fail(&format!("解析 UI JSON 失败:{error}"))); - let state = document.get("state").unwrap_or(&document); - let html = render_state(state).unwrap_or_else(|error| fail(&error)); - fs::write(&args[1], html).unwrap_or_else(|error| fail(&format!("写入 HTML 失败:{error}"))); -} - -fn render_state(state: &Value) -> Result { - let trees = state - .get("ui_trees") - .and_then(Value::as_array) - .ok_or("State 缺少 ui_trees")?; - let mut body = String::new(); - for (index, tree) in trees.iter().enumerate() { - if index > 0 { - body.push_str("\n\n"); - } - body.push_str(&render_tree(state, tree)?); - } - Ok(html! { - (PreEscaped("")) - html { - head { - meta charset="utf-8"; - meta name="viewport" content="width=device-width, initial-scale=1"; - style { (PreEscaped("html,body{margin:0;width:100%;min-height:100%;}body{overflow:auto;}[data-ui-tree]{position:relative;width:100%;min-height:0;overflow:hidden;}:root{--ui-scale:1;}")) } - } - body { (PreEscaped(body)) } - } - }.into_string()) -} - -fn render_tree(state: &Value, tree: &Value) -> Result { - let source = tree - .get("src_ui_design") - .and_then(Value::as_str) - .ok_or("UITree 缺少 src_ui_design")?; - let image = state - .get("ui_design_images") - .and_then(|images| images.get(source)) - .ok_or_else(|| format!("缺少设计图资源:{source}"))?; - let width = logical_dimension(image, 0)?; - let height = logical_dimension(image, 1)?; - let root = tree.get("root").ok_or("UITree 缺少 root")?; - let mut content = String::new(); - let tree_metadata = - serde_json::json!({"srcUiDesign": source, "width": width, "height": height}); - write!( - &mut content, - "", - comment_safe(&tree_metadata.to_string()) - ) - .unwrap(); - write!(&mut content, "
", html_escape(source), width, height, width, height).unwrap(); - content.push_str(&render_node(state, root, false)?); - content.push_str("
"); - Ok(content) -} - -fn render_node(state: &Value, node: &Value, in_container: bool) -> Result { - let id = node - .get("id") - .and_then(Value::as_str) - .ok_or("Node 缺少 id")?; - let metadata = node.get("metadata").ok_or("Node 缺少 metadata")?; - let name = metadata - .get("name") - .and_then(Value::as_str) - .unwrap_or_default(); - let description = metadata - .get("description") - .and_then(Value::as_str) - .unwrap_or_default(); - let layout = node.get("layout").ok_or("Node 缺少 layout")?; - let node_metadata = serde_json::json!({"nodeId": id, "name": name, "description": description}); - let mut output = format!( - "", - comment_safe(&node_metadata.to_string()) - ); - if node.get("children_display_mode").and_then(Value::as_str) == Some("Exclusive") { - output.push_str(""); - } - let style = node_style(layout, in_container)?; - let style = format!("{style}border:0;outline:0;background:transparent;overflow:visible;"); - write!( - &mut output, - "
", - html_escape(id), - style - ) - .unwrap(); - if let Some(components) = node.get("components").and_then(Value::as_array) { - for component in components { - output.push_str(&render_component(state, component)?); - } - } - let child_container = layout.get("container").is_some_and(|value| value != "None"); - if let Some(children) = node.get("children").and_then(Value::as_array) { - for child in children { - output.push_str(&render_node(state, child, child_container)?); - } - } - output.push_str("
"); - Ok(output) -} - -fn render_component(state: &Value, component: &Value) -> Result { - if let Some(text) = component.get("Text") { - let content = text - .get("content") - .and_then(Value::as_str) - .unwrap_or_default(); - let size = text - .get("font_sizing") - .and_then(|sizing| { - sizing - .get("Fixed") - .or_else(|| sizing.get("BestFit").and_then(|range| range.get("min"))) - }) - .and_then(Value::as_u64) - .unwrap_or(14); - let alignment = text - .get("alignment") - .and_then(Value::as_str) - .unwrap_or("UpperLeft"); - let horizontal = if alignment.ends_with("Center") { - "center" - } else if alignment.ends_with("Right") { - "right" - } else { - "left" - }; - let vertical = if alignment.starts_with("Middle") { - "center" - } else if alignment.starts_with("Lower") { - "flex-end" - } else { - "flex-start" - }; - let color = text - .get("color") - .and_then(Value::as_array) - .map(|v| { - [ - v.first().and_then(Value::as_u64).unwrap_or(255), - v.get(1).and_then(Value::as_u64).unwrap_or(255), - v.get(2).and_then(Value::as_u64).unwrap_or(255), - v.get(3).and_then(Value::as_u64).unwrap_or(255), - ] - }) - .unwrap_or([255, 255, 255, 255]); - let white_space = - if text.get("horizontal_overflow").and_then(Value::as_str) == Some("Overflow") { - "nowrap" - } else { - "normal" - }; - let overflow = if text.get("vertical_overflow").and_then(Value::as_str) == Some("Overflow") - { - "visible" - } else { - "hidden" - }; - let overflow_wrap = if white_space == "normal" { - "anywhere" - } else { - "normal" - }; - let line_spacing = text - .get("line_spacing") - .and_then(Value::as_f64) - .unwrap_or(1.0); - let (font_weight, font_style) = match text.get("font_style").and_then(Value::as_str) { - Some("Bold") => (700, "normal"), - Some("Italic") => (400, "italic"), - Some("BoldItalic") => (700, "italic"), - _ => (400, "normal"), - }; - let style = format!("position:absolute;inset:0;display:flex;width:100%;height:100%;box-sizing:border-box;color:rgba({},{},{},{});font-size:calc({size}px * var(--ui-scale, 1));font-weight:{font_weight};font-style:{font_style};align-items:{vertical};justify-content:{};text-align:{horizontal};line-height:{line_spacing};white-space:{white_space};overflow:{overflow};overflow-wrap:{overflow_wrap};", color[0], color[1], color[2], color[3] as f32 / 255.0, if horizontal == "left" { "flex-start" } else if horizontal == "center" { "center" } else { "flex-end" }); - return Ok( - html! { div data-component-kind="Text" style=(style) { (content) } }.into_string(), - ); - } - if let Some(image) = component.get("Image") { - let target = image - .get("target_graphic") - .and_then(Value::as_str) - .ok_or("Image 缺少 target_graphic")?; - let sprite = state - .get("sprite_assets") - .and_then(|sprites| sprites.get(target)) - .ok_or_else(|| format!("缺少 Sprite 资源:{target}"))?; - let path = sprite - .get("path") - .and_then(Value::as_str) - .ok_or("Sprite 缺少 path")?; - if path.trim().is_empty() - || path.starts_with('/') - || path.split('/').any(|part| part == "..") - || path.chars().any(|character| { - character.is_control() || matches!(character, '\'' | '"' | '`' | '(' | ')') - }) - { - return Err(format!("资源路径无效:{path}")); - } - let src = format!("/{path}"); - let image_type = image.get("image_type").unwrap_or(&Value::Null); - if let Some(tiled) = image_type.get("Tiled") { - let multiplier = tiled - .get("pixels_per_unit_multiplier") - .and_then(Value::as_f64) - .unwrap_or(1.0); - let w = logical_sprite_dimension(sprite, 0, multiplier)?; - let h = logical_sprite_dimension(sprite, 1, multiplier)?; - let style = format!("position:absolute;inset:0;overflow:hidden;background-image:url(\"{src}\");background-repeat:repeat;background-position:top left;background-size:{w}px {h}px;"); - return Ok(html! { div data-component-kind="Image" style=(style) {} }.into_string()); - } - let mut image_style = "width:100%;height:100%;display:block;object-fit:fill;".to_string(); - if let Some(simple) = image_type.get("Simple") { - if simple - .get("preserve_aspect") - .and_then(Value::as_bool) - .unwrap_or(false) - { - image_style = image_style.replace("fill", "contain"); - } - } - if let Some(filled) = image_type.get("Filled") { - let amount = filled - .get("amount") - .and_then(Value::as_f64) - .unwrap_or(1.0) - .clamp(0.0, 1.0); - if amount < 1.0 { - image_style.push_str(&format!( - "clip-path:inset(0 {}% 0 0);", - (1.0 - amount) * 100.0 - )); - } - } - return Ok(html! { div data-component-kind="Image" style="position:absolute;inset:0;overflow:hidden;" { img src=(src) alt="" aria-hidden="true" draggable="false" style=(image_style); } }.into_string()); - } - Err("未知 UI 组件类型".to_string()) -} - -fn node_style(layout: &Value, in_container: bool) -> Result { - let transform = layout - .get("transform") - .ok_or("ControlLayout 缺少 transform")?; - let amin = pair(transform, "anchor_min")?; - let amax = pair(transform, "anchor_max")?; - let omin = pair(transform, "offset_min")?; - let omax = pair(transform, "offset_max")?; - if amin[0] > amax[0] || amin[1] > amax[1] { - return Err("Transform anchors must be ordered".to_string()); - } - let mut style = if in_container { - "position:relative;".to_string() - } else { - format!( - "position:absolute;left:{};top:{};right:{};bottom:{};", - css_length(amin[0] * 100.0, omin[0])?, - css_length(amin[1] * 100.0, omin[1])?, - css_length((1.0 - amax[0]) * 100.0, -omax[0])?, - css_length((1.0 - amax[1]) * 100.0, -omax[1])? - ) - }; - if let Some(container) = layout.get("container") { - style.push_str(&container_css(container)); - } - Ok(style) -} - -fn container_css(container: &Value) -> String { - if container == "None" { - return String::new(); - } - if let Some(props) = container.get("HBox") { - return format!( - "display:flex;flex-direction:row;gap:calc({}px * var(--ui-scale, 1));", - number(props.get("separation")) - ); - } - if let Some(props) = container.get("VBox") { - return format!( - "display:flex;flex-direction:column;gap:calc({}px * var(--ui-scale, 1));", - number(props.get("separation")) - ); - } - if let Some(props) = container.get("Grid") { - return format!("display:grid;grid-template-columns:repeat({},minmax(0,1fr));column-gap:calc({}px * var(--ui-scale, 1));row-gap:calc({}px * var(--ui-scale, 1));", props.get("columns").and_then(Value::as_u64).unwrap_or(1), number(props.get("h_separation")), number(props.get("v_separation"))); - } - if let Some(props) = container.get("Margin") { - return format!( - "display:grid;padding:calc({}px * var(--ui-scale, 1)) calc({}px * var(--ui-scale, 1)) calc({}px * var(--ui-scale, 1)) calc({}px * var(--ui-scale, 1));", - number(props.get("margin_top")), - number(props.get("margin_right")), - number(props.get("margin_bottom")), - number(props.get("margin_left")) - ); - } - "display:grid;place-items:center;".to_string() -} - -fn css_length(percent: f64, offset: f64) -> Result { - if !percent.is_finite() || !offset.is_finite() { - return Err("Transform 包含非有限数值".to_string()); - } - if percent == 0.0 { - return Ok(format!("calc({offset}px * var(--ui-scale, 1))")); - } - if offset == 0.0 { - return Ok(format!("{percent}%")); - } - Ok(format!( - "calc({percent}% {} {}px * var(--ui-scale, 1))", - if offset < 0.0 { '-' } else { '+' }, - offset.abs() - )) -} -fn pair(value: &Value, field: &str) -> Result<[f64; 2], String> { - let values = value - .get(field) - .and_then(Value::as_array) - .ok_or_else(|| format!("Transform 缺少 {field}"))?; - if values.len() != 2 { - return Err(format!("Transform.{field} 不是二元数组")); - } - Ok([ - values[0].as_f64().ok_or("Transform 数值无效")?, - values[1].as_f64().ok_or("Transform 数值无效")?, - ]) -} -fn logical_dimension(image: &Value, index: usize) -> Result { - let pixels = image - .get("pixel_size") - .and_then(Value::as_array) - .and_then(|v| v.get(index)) - .and_then(Value::as_f64) - .ok_or("设计图 pixel_size 无效")?; - let ppu = image - .get("pixels_per_unit") - .and_then(Value::as_f64) - .ok_or("设计图 pixels_per_unit 无效")?; - if pixels <= 0.0 || ppu <= 0.0 { - return Err("设计图尺寸无效".to_string()); - } - Ok(pixels / ppu) -} -fn logical_sprite_dimension(sprite: &Value, index: usize, multiplier: f64) -> Result { - let pixels = sprite - .get("pixel_size") - .and_then(Value::as_array) - .and_then(|v| v.get(index)) - .and_then(Value::as_f64) - .ok_or("Sprite pixel_size 无效")?; - let ppu = sprite - .get("pixels_per_unit") - .and_then(Value::as_f64) - .ok_or("Sprite pixels_per_unit 无效")?; - if pixels <= 0.0 || ppu <= 0.0 || multiplier <= 0.0 { - return Err("Sprite 尺寸无效".to_string()); - } - Ok(pixels / (ppu * multiplier)) -} -fn number(value: Option<&Value>) -> String { - value - .and_then(Value::as_f64) - .map(|v| v.to_string()) - .unwrap_or_else(|| "0".to_string()) -} -fn comment_safe(value: &str) -> String { - value.replace("--", "- -") -} -fn html_escape(value: &str) -> String { - value - .replace('&', "&") - .replace('"', """) - .replace('<', "<") - .replace('>', ">") -} -fn fail(message: &str) -> ! { - eprintln!("{message}"); - std::process::exit(1) -} -- 2.52.0 From 0e0c40a69d0ff2c6496c6467fab1fa03e94bc91b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 31 Aug 2026 19:16:38 +0800 Subject: [PATCH 03/30] =?UTF-8?q?=E7=A7=BB=E9=99=A4=20UI=20=E8=AE=BE?= =?UTF-8?q?=E8=AE=A1=E5=B0=BA=E5=AF=B8=E6=9A=B4=E9=9C=B2=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 删除 --ui-design-width 与 --ui-design-height 自定义属性 不再在树元数据注释中暴露设计图宽高 --- .../src-tauri/src/ui_editor/html_renderer/mod.rs | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/mod.rs index 275d1371d..ce53ef471 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/mod.rs @@ -66,14 +66,10 @@ fn render_tree(state: &State, tree: &UITree) -> Result { / finite_positive(image.pixels_per_unit.get(), "UI design pixelsPerUnit")?; let tree_comment = html_comment( "genarrative-ui-tree", - json!({ - "srcUiDesign": tree.src_ui_design.as_str(), - "width": width, - "height": height, - }), + json!({"srcUiDesign": tree.src_ui_design.as_str()}), ); let style = format!( - "position:relative;width:100%;height:auto;min-height:0;aspect-ratio:{width} / {height};--ui-design-width:{width}px;--ui-design-height:{height}px;" + "position:relative;width:100%;height:auto;min-height:0;aspect-ratio:{width} / {height};" ); Ok(html! { (tree_comment) -- 2.52.0 From 8a32d4b48f4117605ba09a29efd4c0d52e7c0be3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 31 Aug 2026 19:23:19 +0800 Subject: [PATCH 04/30] =?UTF-8?q?=E6=8C=89=E9=A2=84=E8=A7=88=E5=AE=B9?= =?UTF-8?q?=E5=99=A8=E5=B0=BA=E5=AF=B8=E6=B8=B2=E6=9F=93=20UI=20=E6=A0=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除设计图宽高计算与 aspect-ratio 约束 让树容器填充调用方父容器尺寸 整理组件渲染子模块路径 --- .../html_renderer/{ => component}/image.rs | 4 ++-- .../{component.rs => component/mod.rs} | 7 +++++-- .../html_renderer/{ => component}/text.rs | 2 +- .../src-tauri/src/ui_editor/html_renderer/mod.rs | 14 +++----------- 4 files changed, 11 insertions(+), 16 deletions(-) rename apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/{ => component}/image.rs (97%) rename apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/{component.rs => component/mod.rs} (97%) rename apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/{ => component}/text.rs (98%) diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/image.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/component/image.rs similarity index 97% rename from apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/image.rs rename to apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/component/image.rs index c63b39237..1c3ffb5a4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/image.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/component/image.rs @@ -1,12 +1,12 @@ -use super::assets::{finite_positive, trim_float}; use crate::ui_editor::component::image::{ FillMethod, HorizontalFillOrigin, ImageType, Radial180Origin, Radial360Origin, Radial90Origin, VerticalFillOrigin, }; +use crate::ui_editor::html_renderer::assets::{finite_positive, trim_float}; const UI_SCALE: &str = "var(--ui-scale, 1)"; -pub(super) fn image_styles( +pub(in crate::ui_editor::html_renderer) fn image_styles( image_type: &ImageType, sprite: &crate::ui_editor::resource::sprite::SpriteAsset, src: &str, diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/component.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/component/mod.rs similarity index 97% rename from apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/component.rs rename to apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/component/mod.rs index 68ea12110..237512aef 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/component.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/component/mod.rs @@ -1,10 +1,13 @@ use super::assets::{asset_url, hex_id}; -use super::image::image_styles; -use super::text::text_style; use crate::ui_editor::component::text::FontSource; use crate::ui_editor::component::Component; use crate::ui_editor::state::State; +use image::image_styles; use maud::{html, Markup}; +use text::text_style; + +mod image; +mod text; pub(super) fn render_component(state: &State, component: &Component) -> Result { match component { diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/text.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/component/text.rs similarity index 98% rename from apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/text.rs rename to apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/component/text.rs index 5857900e4..9fcaa1606 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/text.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/component/text.rs @@ -4,7 +4,7 @@ use crate::ui_editor::component::text::{ const UI_SCALE: &str = "var(--ui-scale, 1)"; -pub(super) fn text_style( +pub(in crate::ui_editor::html_renderer) fn text_style( font_style: FontStyle, alignment: TextAlignment, sizing: FontSizing, diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/mod.rs index ce53ef471..389c82c73 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/mod.rs @@ -1,11 +1,9 @@ mod assets; mod component; mod container; -mod image; mod node; -mod text; -use self::assets::{finite_positive, font_face_rule, hex_id, html_comment}; +use self::assets::{font_face_rule, hex_id, html_comment}; use self::component::render_component; use self::container::{child_container_style, container_style}; use self::node::{is_container, transform_style}; @@ -56,21 +54,15 @@ pub(crate) fn render_ui_design_state_html(state: &State) -> Result Result { - let image = state + state .ui_design_images .get(&tree.src_ui_design) .ok_or_else(|| format!("UITree 缺少 src_ui_design:{}", tree.src_ui_design.as_str()))?; - let width = finite_positive(image.pixel_size.x, "UI design width")? - / finite_positive(image.pixels_per_unit.get(), "UI design pixelsPerUnit")?; - let height = finite_positive(image.pixel_size.y, "UI design height")? - / finite_positive(image.pixels_per_unit.get(), "UI design pixelsPerUnit")?; let tree_comment = html_comment( "genarrative-ui-tree", json!({"srcUiDesign": tree.src_ui_design.as_str()}), ); - let style = format!( - "position:relative;width:100%;height:auto;min-height:0;aspect-ratio:{width} / {height};" - ); + let style = "position:relative;width:100%;height:100%;min-height:0;"; Ok(html! { (tree_comment) div data-ui-tree=(tree.src_ui_design.as_str()) style=(style) { -- 2.52.0 From 43f64b3e708e7ed629301a4a5d86af3f64bee716 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 31 Aug 2026 19:25:27 +0800 Subject: [PATCH 05/30] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E7=AE=80=E8=A6=81?= =?UTF-8?q?=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 生成 HTML 样例时添加说明 --- .../src-tauri/src/ui_editor/html_renderer/mod.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/mod.rs index 389c82c73..1b6bab493 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/mod.rs @@ -36,6 +36,12 @@ pub(crate) fn render_ui_design_state_html(state: &State) -> Result Date: Tue, 1 Sep 2026 13:19:29 +0800 Subject: [PATCH 06/30] =?UTF-8?q?=E7=94=9F=E6=88=90=20UI=20=E8=AE=BE?= =?UTF-8?q?=E8=AE=A1=E4=BB=A3=E7=A0=81=E5=B9=B6=E6=8E=A5=E5=85=A5=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E5=99=A8=E6=8C=89=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 State 到 JS 模块的生成与原子写入 接入保存并生成代码按钮及状态反馈 统一树节点元数据与 ui-node-id 标记 暂置旧 UI HTML 工具为空实现 --- .../src-tauri/src/agent/direct_runtime.rs | 10 +- .../src-tauri/src/agent/direct_tools_mcp.rs | 35 +++- .../src/agent/runtime_tools/ui_html.rs | 45 +---- .../src-tauri/src/agent_native_tools.rs | 2 +- .../src-tauri/src/main.rs | 10 ++ .../src/ui_editor/html_renderer/assets.rs | 4 +- .../ui_editor/html_renderer/component/mod.rs | 8 +- .../src/ui_editor/html_renderer/mod.rs | 154 +++++++++++++++++- .../src-tauri/src/ui_editor/persistence.rs | 56 +++++++ .../features/ui-editor/uiDesignStateStore.ts | 21 +++ .../src/view/ui-editor/index.tsx | 74 ++++++++- .../src/view/ui-editor/useUiEditorPage.ts | 27 +++ 12 files changed, 376 insertions(+), 70 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs index e515babc1..6829b7598 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs @@ -2177,9 +2177,7 @@ fn direct_registered_taonier_slice_paths(root: &Path) -> Vec { fn direct_game_sources_referenced_taonier_assets(root: &Path) -> Vec { let sources = direct_codex_game_outputs(root) .into_iter() - .filter_map(|(relative_path, _, _)| { - std::fs::read_to_string(root.join(relative_path)).ok() - }) + .filter_map(|(relative_path, _, _)| std::fs::read_to_string(root.join(relative_path)).ok()) .collect::>(); let mut available_paths = Vec::new(); if direct_taonier_art_base_is_valid(root) { @@ -2267,9 +2265,7 @@ fn direct_browser_evidence_needs_art_repair( fn direct_game_output_completion_error(root: &Path) -> Option { let entry = agent_runtime_game_entry_relative_path(root); if !root.join(entry).is_file() { - return Some(format!( - "Codex 返回后未找到 {entry},项目未进入可运行状态" - )); + return Some(format!("Codex 返回后未找到 {entry},项目未进入可运行状态")); } if !direct_game_sources_reference_taonier_art_package(root) { return Some( @@ -3660,6 +3656,7 @@ fn build_direct_codex_system_prompt_with_search( "工作区边界:只在当前项目目录内工作;不要读取或输出凭据、Token、Cookie、auth.json、.env 或宿主私密路径。遇到阻断必须说明具体原因、文件和下一步,不要声称未验证的成功。".to_string(), "AGC 工具授权边界:DirectProject 的 agc_tools 由当前客户端桥接到 AGC 后端,使用客户端已有登录会话和受控凭据完成授权。用户不需要、也不得向你提供、配置、粘贴或创建 API Key、Token、Cookie、URL 或 .env。工具返回 401/403 时,只说明 AGC 客户端登录或权限状态异常并停止,不要索要凭据、猜测外部 API,也不要暴露内部 URL。".to_string(), DIRECT_AGC_ENGINEERING_GUIDANCE.to_string(), + "UI 设计 HTML 转换工具 `agc_tools.agc_ui_design_to_html` 当前暂未实现。".to_string(), "工程执行要求:优先复用现有结构;按需读取真实文件,不依赖客户端预注入源码快照;修改后运行与改动相关的本地验证。不要创建 Supervisor、专业 Agent 或平行项目。".to_string(), format!("提示词与技能:{skill_index}"), ]; @@ -4576,6 +4573,7 @@ mod tests { assert!(prompt.contains("agc_tools.taonier_prepare_game_art")); assert!(prompt.contains("agc_tools.agc_generate_image")); assert!(prompt.contains("agc_tools.agc_browser_playtest")); + assert!(prompt.contains("agc_tools.agc_ui_design_to_html")); assert!(prompt.contains("DirectProject 提供 Codex 原生文件、搜索、命令、图片查看、Skill")); assert!(!prompt.contains("客户端会在系统上下文提供有界的当前游戏文件快照")); assert!(prompt.contains("Codex 不直接保存或伪造项目版本")); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs index 7c896bd3a..1ebb9dac5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs @@ -356,6 +356,23 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool) -> Value { "additionalProperties": false } }), + json!({ + "name": "agc_ui_design_to_html", + "description": "UI 设计 HTML 转换工具当前暂未实现。", + "inputSchema": { + "type": "object", + "properties": { + "assetId": { + "type": "string", + "minLength": 1, + "maxLength": 160, + "description": "当前项目 manifest 中 kind=UI 的 JSON 设计资源 assetId" + } + }, + "required": ["assetId"], + "additionalProperties": false + } + }), ]; let mut tools = tools; if controlled_web_search { @@ -997,6 +1014,15 @@ async fn call_agc_browser_playtest(arguments: &Value) -> Value { call_client_tool_bridge("agc_browser_playtest", arguments).await } +fn call_agc_ui_design_to_html(root: &Path, arguments: &Value) -> Value { + let _ = (root, arguments); + mcp_tool_result( + "agc_ui_design_to_html 暂未实现".to_string(), + Vec::new(), + true, + ) +} + async fn call_agc_web_search(arguments: &Value) -> Value { if !controlled_web_search_enabled() { return mcp_tool_result("AGC 受控联网搜索未启用".to_string(), Vec::new(), true); @@ -1017,7 +1043,7 @@ async fn call_agc_web_search(arguments: &Value) -> Value { .await } -async fn handle_direct_tools_mcp_request(_root: &Path, request: Value) -> Option { +async fn handle_direct_tools_mcp_request(root: &Path, request: Value) -> Option { let id = request.get("id").cloned(); let method = request.get("method").and_then(Value::as_str)?; if id.is_none() { @@ -1068,6 +1094,7 @@ async fn handle_direct_tools_mcp_request(_root: &Path, request: Value) -> Option } "agc_remove_background" => call_agc_remove_background(&arguments).await, "agc_browser_playtest" => call_agc_browser_playtest(&arguments).await, + "agc_ui_design_to_html" => call_agc_ui_design_to_html(root, &arguments), "agc_web_search" => call_agc_web_search(&arguments).await, _ => mcp_tool_result("未知或未审核的 AGC 工具".to_string(), Vec::new(), true), }; @@ -1178,8 +1205,7 @@ mod tests { #[test] fn tool_catalog_preserves_reviewed_resource_contracts() { assert!( - DIRECT_TOOLS_MCP_MAX_REQUEST_BYTES - > DIRECT_TOOLS_MCP_MAX_WRITE_CONTENT_BYTES + 1024, + DIRECT_TOOLS_MCP_MAX_REQUEST_BYTES > DIRECT_TOOLS_MCP_MAX_WRITE_CONTENT_BYTES + 1024, "MCP request envelope must fit the advertised file-write payload" ); let specs = direct_tools_mcp_specs(); @@ -1203,7 +1229,8 @@ mod tests { "agc_import_account_assets", "agc_create_or_derive_resource", "agc_remove_background", - "agc_browser_playtest" + "agc_browser_playtest", + "agc_ui_design_to_html" ] ); let serialized = specs.to_string(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/ui_html.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/ui_html.rs index e0ceceb2a..648fccef5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/ui_html.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/ui_html.rs @@ -1,57 +1,14 @@ use super::*; -use crate::ui_editor::html_renderer::render_ui_design_state_html; -use crate::ui_editor::persistence::{load_ui_design_state_at, LoadUiDesignStateInput}; use serde_json::Value; pub(in crate::agent) fn observe_agent_runtime_ui_design_to_html( root: &Path, input: &Value, ) -> AgentRuntimeToolObservation { - let asset_id = input - .get("assetId") - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()); - let Some(asset_id) = asset_id else { - return AgentRuntimeToolObservation { - tool: "ui.design.to_html".to_string(), - status: "rejected".to_string(), - summary: "ui.design.to_html 缺少 assetId".to_string(), - detail: None, - }; - }; - let project_id = match game_creator_agent_runtime_context_project_id(root) { - Ok(project_id) => project_id, - Err(error) => return ui_html_error(root, error), - }; - let snapshot = match load_ui_design_state_at(LoadUiDesignStateInput { - project_path: root.to_string_lossy().into_owned(), - expected_project_id: project_id, - asset_id: asset_id.to_string(), - }) { - Ok(snapshot) => snapshot, - Err(error) => return ui_html_error(root, error), - }; - match render_ui_design_state_html(&snapshot.state) { - Ok(html) => AgentRuntimeToolObservation { - tool: "ui.design.to_html".to_string(), - status: "ok".to_string(), - summary: format!( - "已生成 UI HTML(revision {},{} 字节)", - snapshot.revision, - html.len() - ), - detail: Some(html), - }, - Err(error) => ui_html_error(root, error), - } -} - -fn ui_html_error(root: &Path, error: String) -> AgentRuntimeToolObservation { AgentRuntimeToolObservation { tool: "ui.design.to_html".to_string(), status: "error".to_string(), - summary: redact_agent_runtime_project_paths(root, &error, 500), + summary: "ui.design.to_html 暂未实现".to_string(), detail: None, } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs index 4c1185a6f..2c4daca1f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs @@ -1392,7 +1392,7 @@ fn runtime_tool_description(tool: &str) -> &'static str { "先用 discover 从受控 game/ui-pages.json 或页面声明标记自动发现全部功能页面,再把已登记 ui-prototype 与每个页面的设计图桥接成独立 UI JSON State;可同时载入已登记图片、图标和项目字体,执行 Provider 结构识别、多树合并与分批组件绑定、回读阶段,并且只有所有页面已绑定且已应用到 game/ 后才允许 finalize。项目根目录由 Runtime 注入,模型不得传入宿主路径。" } "ui.design.to_html" => { - "只读读取指定 UI 设计, 生成HTML片段;" + "UI 设计 HTML 转换工具当前暂未实现。" } "blackboard.write" => "向项目级共享黑板追加稳定结论。", "agent.message" => "向一个目标 Agent 写入定向上下文消息。", 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 ad1499c0b..46f072f3b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -160,6 +160,15 @@ fn save_ui_design_state( ui_editor::persistence::save_ui_design_state_at(input) } +#[tauri::command] +fn generate_ui_design_code( + input: ui_editor::persistence::GenerateUiDesignCodeInput, +) -> Result { + let root = Path::new(input.project_path.trim()); + enforce_project_permission_policy(root, "file.write")?; + ui_editor::persistence::generate_ui_design_code_at(input) +} + #[tauri::command] fn ensure_ui_design_resource_for_prototype( input: ui_editor::resource_bridge::EnsureUiDesignResourceForPrototypeInput, @@ -2226,6 +2235,7 @@ fn main() { bind_components, load_ui_design_state, save_ui_design_state, + generate_ui_design_code, ensure_ui_design_resource_for_prototype, generate_platform_art_asset, open_canvas_project, diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/assets.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/assets.rs index 2b2e3d90c..b83ce6315 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/assets.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/assets.rs @@ -9,10 +9,10 @@ pub(super) fn font_face_rule( } pub(super) fn html_comment(label: &str, value: serde_json::Value) -> Markup { - let text = serde_json::to_string(&value) + let text = serde_json::to_string_pretty(&value) .unwrap_or_else(|_| "{}".to_string()) .replace("--", "- -"); - PreEscaped(format!("")) + PreEscaped(format!("")) } pub(super) fn asset_url(path: &str) -> Result { diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/component/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/component/mod.rs index 237512aef..4c6b460d9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/component/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/component/mod.rs @@ -29,12 +29,12 @@ pub(super) fn render_component(state: &State, component: &Component) -> Result { let sprite_id = image @@ -49,11 +49,11 @@ pub(super) fn render_component(state: &State, component: &Component) -> Result html! { - div data-component-kind="Image" style=(style) { + div style=(style) { img src=(src) alt="" aria-hidden="true" draggable="false" style=(image_style); } }, - None => html! { div data-component-kind="Image" style=(style) {} }, + None => html! { div style=(style) {} }, }) } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/mod.rs index 1b6bab493..d33c76490 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/mod.rs @@ -3,7 +3,7 @@ mod component; mod container; mod node; -use self::assets::{font_face_rule, hex_id, html_comment}; +use self::assets::{font_face_rule, hex_id, html_comment, trim_float}; use self::component::render_component; use self::container::{child_container_style, container_style}; use self::node::{is_container, transform_style}; @@ -59,21 +59,86 @@ pub(crate) fn render_ui_design_state_html(state: &State) -> Result Result<(String, Vec, usize), String> { + let mut exports = Vec::with_capacity(state.ui_trees.len()); + let mut modules = Vec::with_capacity(state.ui_trees.len()); + let mut node_count = 0usize; + for tree in &state.ui_trees { + let export_name = tree_export_name(tree.src_ui_design.as_str()); + exports.push(export_name.clone()); + let image = state + .ui_design_images + .get(&tree.src_ui_design) + .ok_or_else(|| format!("UITree 缺少 src_ui_design:{}", tree.src_ui_design.as_str()))?; + let tree_comment = html_comment( + "genarrative-ui-tree", + json!({ + "srcUiDesign": tree.src_ui_design.as_str(), + "name": image.metadata.name, + "description": image.metadata.description, + }), + ) + .into_string(); + let fonts = state + .font_assets + .values() + .map(|font| { + let family = format!("ui-editor-font-{}", hex_id(font.asset_id.as_str())); + font_face_rule(font, &family) + }) + .collect::, _>>()? + .join(""); + let root = render_node_with_scale( + state, + &tree.root, + None, + Some((image.pixel_size.x, image.pixel_size.y)), + )? + .into_string(); + node_count += count_nodes(&tree.root); + let mut fragment = String::new(); + fragment.push_str(&tree_comment); + if !fonts.is_empty() { + fragment.push_str(&html! { style data-ui-fonts { (PreEscaped(fonts)) } }.into_string()); + } + fragment.push_str(&root); + let escaped = escape_template_literal(&pretty_html_fragment(&fragment)); + modules.push(format!("export const {export_name} = `\n{escaped}\n`;")); + } + let mut output = String::from( + "// UI 设计生成模块:仅导出 HTML 片段,不执行任何注入逻辑。\n\ + // 重新生成会覆盖本文件中的手动修改。\n\ + // 可通过宿主页面设置 --ui-scale 调整整体像素缩放,例如:\n\ + // document.documentElement.style.setProperty('--ui-scale', '0.85');\n\ + // 注入示例(仅供 Agent 按需修改):\n\ + // import { tree_example } from './generated-xxx.js';\n\ + // document.body.insertAdjacentHTML('beforeend', tree_example);\n\ + // const node = document.querySelector('[ui-node-id=\"...\"]');\n\n", + ); + output.push_str(&modules.join("\n\n")); + if !output.ends_with('\n') { + output.push('\n'); + } + Ok((output, exports, node_count)) +} + fn render_tree(state: &State, tree: &UITree) -> Result { - state + let image = state .ui_design_images .get(&tree.src_ui_design) .ok_or_else(|| format!("UITree 缺少 src_ui_design:{}", tree.src_ui_design.as_str()))?; let tree_comment = html_comment( "genarrative-ui-tree", - json!({"srcUiDesign": tree.src_ui_design.as_str()}), + json!({ + "srcUiDesign": tree.src_ui_design.as_str(), + }), ); let style = "position:relative;width:100%;height:100%;min-height:0;"; Ok(html! { (tree_comment) - div data-ui-tree=(tree.src_ui_design.as_str()) style=(style) { - (render_node(state, &tree.root, None)?) - } + div style=(style) { (render_node(state, &tree.root, None)?) } }) } @@ -81,8 +146,27 @@ fn render_node( state: &State, node: &Node, parent_container: Option<&Container>, +) -> Result { + render_node_with_scale(state, node, parent_container, None) +} + +fn render_node_with_scale( + state: &State, + node: &Node, + parent_container: Option<&Container>, + root_scale: Option<(f32, f32)>, ) -> Result { let mut style = transform_style(&node.layout, parent_container.is_some())?; + if let Some((width, height)) = root_scale { + if !width.is_finite() || width <= 0.0 || !height.is_finite() || height <= 0.0 { + return Err("UI 设计尺寸必须为正有限数值".to_string()); + } + style.push_str(&format!( + "--ui-scale:min(calc(100vw / {}px),calc(100vh / {}px));", + trim_float(width), + trim_float(height) + )); + } style.push_str("border:0;outline:0;background:transparent;overflow:visible;"); style.push_str(&container_style(&node.layout.container)); if let Some(parent) = parent_container { @@ -115,10 +199,11 @@ fn render_node( .children .iter() .map(|child| { - render_node( + render_node_with_scale( state, child, is_container(&node.layout.container).then_some(&node.layout.container), + None, ) }) .collect::, _>>()? @@ -128,9 +213,62 @@ fn render_node( Ok(html! { (comment) @if let Some(group_comment) = exclusive_comment { (group_comment) } - div data-node-id=(node.id.as_str()) style=(style) { + div ui-node-id=(node.id.as_str()) style=(style) { (PreEscaped(components.concat())) (PreEscaped(children.concat())) } }) } + +fn count_nodes(node: &Node) -> usize { + 1 + node.children.iter().map(count_nodes).sum::() +} + +fn tree_export_name(id: &str) -> String { + let mut name = String::from("tree_"); + for character in id.chars() { + if character.is_ascii_alphanumeric() || character == '_' { + name.push(character); + } else { + name.push('_'); + } + } + name +} + +fn escape_template_literal(value: &str) -> String { + value + .replace('\\', "\\\\") + .replace('`', "\\`") + .replace("${", "\\${") +} + +fn pretty_html_fragment(value: &str) -> String { + let mut depth = 0usize; + let mut lines = Vec::new(); + for raw in value.replace("><", ">\n<").lines() { + let line = raw.trim(); + if line.is_empty() { + continue; + } + let closing = line.starts_with("") + && !line.starts_with(", + pub(crate) tree_count: usize, + pub(crate) node_count: usize, +} + #[derive(Clone, Debug, Deserialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] pub(crate) struct SaveUiDesignStateInput { @@ -149,6 +167,44 @@ pub(crate) fn load_ui_design_state_at( }) } +pub(crate) fn generate_ui_design_code_at( + input: GenerateUiDesignCodeInput, +) -> Result { + let root = Path::new(input.project_path.trim()); + let expected_project_id = required_identifier(&input.expected_project_id, "expectedProjectId")?; + let asset_id = required_identifier(&input.asset_id, "assetId")?; + let _lock = acquire_project_write_lock(root, "ui_design.code_generate")?; + let asset = ui_design_asset(root, &expected_project_id, &asset_id)?; + let document = + read_ui_design_document_locked(root, &asset.local_path, &expected_project_id, &asset_id)?; + let (content, tree_exports, node_count) = render_ui_design_state_js(&document.state)?; + let relative_path = format!("ui/generated-{}.js", generated_file_stem(&asset_id)); + let path = resolve_local_project_path(root, &relative_path)?; + write_ui_design_raw_file(&path, "UI 设计生成代码", content.as_bytes())?; + Ok(GenerateUiDesignCodeResult { + relative_path, + tree_count: tree_exports.len(), + tree_exports, + node_count, + }) +} + +fn generated_file_stem(asset_id: &str) -> String { + let mut stem = String::new(); + for character in asset_id.chars() { + if character.is_ascii_alphanumeric() || matches!(character, '_' | '-') { + stem.push(character); + } else { + stem.push('_'); + } + } + if stem.is_empty() { + "ui-design".to_string() + } else { + stem + } +} + pub(crate) fn save_ui_design_state_at( input: SaveUiDesignStateInput, ) -> Result { diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/uiDesignStateStore.ts b/apps/ai-game-creator-shell/src/features/ui-editor/uiDesignStateStore.ts index 951d0911c..7109415a2 100644 --- a/apps/ai-game-creator-shell/src/features/ui-editor/uiDesignStateStore.ts +++ b/apps/ai-game-creator-shell/src/features/ui-editor/uiDesignStateStore.ts @@ -14,6 +14,13 @@ export type UiDesignStateSaveResult = } | { status: 'conflict'; current: UiDesignStateSnapshot }; +export type UiDesignCodeGenerationResult = { + relativePath: string; + treeExports: string[]; + treeCount: number; + nodeCount: number; +}; + export type IUiDesignStateStore = { load(assetId: string): Promise; save( @@ -21,6 +28,7 @@ export type IUiDesignStateStore = { expectedRevision: number, state: State, ): Promise; + generateCode(assetId: string): Promise; }; export function createTauriUiDesignStateStore( @@ -44,6 +52,11 @@ export function createTauriUiDesignStateStore( }, }); }, + generateCode(assetId) { + return invoke('generate_ui_design_code', { + input: { projectPath, expectedProjectId, assetId }, + }); + }, }; } @@ -76,6 +89,14 @@ export const uiDesignStateStore: IUiDesignStateStore = { committedProjectRevision: 0, }; }, + async generateCode() { + return { + relativePath: 'ui/generated-ui-design.js', + treeExports: [], + treeCount: 0, + nodeCount: 0, + }; + }, }; export const EMPTY_UI_DESIGN_STATE = EMPTY_UI_EDITOR_STATE; diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx index f9833cd50..d4c470643 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx @@ -60,6 +60,8 @@ export default function UiEditorPage({ ); const [saveWarningOpen, setSaveWarningOpen] = useState(false); const [saveAfterReturn, setSaveAfterReturn] = useState(false); + const [generateAfterWarning, setGenerateAfterWarning] = useState(false); + const [generateSuccess, setGenerateSuccess] = useState(null); const [returnConfirmOpen, setReturnConfirmOpen] = useState(false); async function save(afterReturn = false) { @@ -71,11 +73,23 @@ export default function UiEditorPage({ } } + async function saveAndGenerate() { + setGenerateSuccess(null); + if (await session.save.save()) { + const result = await session.save.generateCode(); + if (result) { + setGenerateAfterWarning(false); + setGenerateSuccess(`代码已生成:${result.relativePath}`); + } + } + } + function requestSave(afterReturn = false) { if (!resourceId || session.save.isSaving || session.workflow.isAiRunning) { return; } setSaveAfterReturn(afterReturn); + setGenerateAfterWarning(false); if (session.save.hasWarnings()) { setSaveWarningOpen(true); return; @@ -83,6 +97,25 @@ export default function UiEditorPage({ void save(afterReturn); } + function requestSaveAndGenerate() { + if ( + !resourceId || + session.save.isSaving || + session.save.isGenerating || + session.workflow.isAiRunning + ) { + return; + } + setSaveAfterReturn(false); + setGenerateSuccess(null); + setGenerateAfterWarning(true); + if (session.save.hasWarnings()) { + setSaveWarningOpen(true); + return; + } + void saveAndGenerate(); + } + function requestBack() { if (session.save.isDirty) { setReturnConfirmOpen(true); @@ -122,6 +155,25 @@ export default function UiEditorPage({ > {session.save.isSaving ? '保存中…' : '保存'} + ) : null} @@ -133,6 +185,22 @@ export default function UiEditorPage({ {session.save.saveError} ) : null} + {session.save.generateError ? ( +
+ 代码生成失败:{session.save.generateError} +
+ ) : null} + {generateSuccess ? ( +
+ {generateSuccess} +
+ ) : null} { setSaveWarningOpen(false); - void save(saveAfterReturn); + if (generateAfterWarning) { + void saveAndGenerate(); + } else { + void save(saveAfterReturn); + } }} > 仍然保存 diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts b/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts index de160ca63..f70feaa56 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts +++ b/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts @@ -186,6 +186,8 @@ export function useUiEditorSession( ); const [saveError, setSaveError] = useState(null); const [isSaving, setIsSaving] = useState(false); + const [generateError, setGenerateError] = useState(null); + const [isGenerating, setIsGenerating] = useState(false); const normalizedInitialStepIndex = initialStep === 'reference-analysis' ? 0 @@ -1071,6 +1073,28 @@ export function useUiEditorSession( } } + async function generateCode() { + if ( + !resourceId || + isGenerating || + isLoading || + loadError || + editor.isLocked + ) { + return null; + } + setGenerateError(null); + setIsGenerating(true); + try { + return await stateStore.generateCode(resourceId); + } catch (cause) { + setGenerateError(cause instanceof Error ? cause.message : String(cause)); + return null; + } finally { + setIsGenerating(false); + } + } + return { input: { projectPath, @@ -1237,9 +1261,12 @@ export function useUiEditorSession( isSaving, isDirty, saveError, + isGenerating, + generateError, hasWarnings: () => postCheckIssuesForSave(editor.state).length > 0, warnings: () => postCheckIssuesForSave(editor.state), save, + generateCode, }, }; } -- 2.52.0 From c0d10b4025a7627ac44b5c5278f98a0252ea63c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 1 Sep 2026 13:26:26 +0800 Subject: [PATCH 07/30] =?UTF-8?q?=E7=A7=BB=E9=99=A4=E6=97=A7=20UI=20HTML?= =?UTF-8?q?=20Agent=20=E5=B7=A5=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 删除 ui.design.to_html 与 agc_ui_design_to_html 的工具目录和分发逻辑 清理 Runtime policy、native catalog 与 DirectProject prompt 引用 保留 UI 编辑器直接生成代码能力 --- .../src-tauri/src/agent/direct_runtime.rs | 2 -- .../src-tauri/src/agent/direct_tools_mcp.rs | 28 ------------------- .../agent/runtime_actions/action_execution.rs | 1 - .../runtime_actions/autonomous_policy.rs | 1 - .../agent/runtime_actions/parallel_ledger.rs | 2 -- .../runtime_actions/tool_policy_snapshot.rs | 2 -- .../src-tauri/src/agent/runtime_tools.rs | 2 -- .../src/agent/runtime_tools/policy.rs | 1 - .../src/agent/runtime_tools/ui_html.rs | 14 ---------- .../src-tauri/src/agent_native_tools.rs | 11 -------- .../src/ui_editor/html_renderer/mod.rs | 19 ++++++------- 11 files changed, 9 insertions(+), 74 deletions(-) delete mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/ui_html.rs diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs index 6829b7598..c5105706d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs @@ -3656,7 +3656,6 @@ fn build_direct_codex_system_prompt_with_search( "工作区边界:只在当前项目目录内工作;不要读取或输出凭据、Token、Cookie、auth.json、.env 或宿主私密路径。遇到阻断必须说明具体原因、文件和下一步,不要声称未验证的成功。".to_string(), "AGC 工具授权边界:DirectProject 的 agc_tools 由当前客户端桥接到 AGC 后端,使用客户端已有登录会话和受控凭据完成授权。用户不需要、也不得向你提供、配置、粘贴或创建 API Key、Token、Cookie、URL 或 .env。工具返回 401/403 时,只说明 AGC 客户端登录或权限状态异常并停止,不要索要凭据、猜测外部 API,也不要暴露内部 URL。".to_string(), DIRECT_AGC_ENGINEERING_GUIDANCE.to_string(), - "UI 设计 HTML 转换工具 `agc_tools.agc_ui_design_to_html` 当前暂未实现。".to_string(), "工程执行要求:优先复用现有结构;按需读取真实文件,不依赖客户端预注入源码快照;修改后运行与改动相关的本地验证。不要创建 Supervisor、专业 Agent 或平行项目。".to_string(), format!("提示词与技能:{skill_index}"), ]; @@ -4573,7 +4572,6 @@ mod tests { assert!(prompt.contains("agc_tools.taonier_prepare_game_art")); assert!(prompt.contains("agc_tools.agc_generate_image")); assert!(prompt.contains("agc_tools.agc_browser_playtest")); - assert!(prompt.contains("agc_tools.agc_ui_design_to_html")); assert!(prompt.contains("DirectProject 提供 Codex 原生文件、搜索、命令、图片查看、Skill")); assert!(!prompt.contains("客户端会在系统上下文提供有界的当前游戏文件快照")); assert!(prompt.contains("Codex 不直接保存或伪造项目版本")); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs index 1ebb9dac5..a2ad7f737 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs @@ -356,23 +356,6 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool) -> Value { "additionalProperties": false } }), - json!({ - "name": "agc_ui_design_to_html", - "description": "UI 设计 HTML 转换工具当前暂未实现。", - "inputSchema": { - "type": "object", - "properties": { - "assetId": { - "type": "string", - "minLength": 1, - "maxLength": 160, - "description": "当前项目 manifest 中 kind=UI 的 JSON 设计资源 assetId" - } - }, - "required": ["assetId"], - "additionalProperties": false - } - }), ]; let mut tools = tools; if controlled_web_search { @@ -1014,15 +997,6 @@ async fn call_agc_browser_playtest(arguments: &Value) -> Value { call_client_tool_bridge("agc_browser_playtest", arguments).await } -fn call_agc_ui_design_to_html(root: &Path, arguments: &Value) -> Value { - let _ = (root, arguments); - mcp_tool_result( - "agc_ui_design_to_html 暂未实现".to_string(), - Vec::new(), - true, - ) -} - async fn call_agc_web_search(arguments: &Value) -> Value { if !controlled_web_search_enabled() { return mcp_tool_result("AGC 受控联网搜索未启用".to_string(), Vec::new(), true); @@ -1094,7 +1068,6 @@ async fn handle_direct_tools_mcp_request(root: &Path, request: Value) -> Option< } "agc_remove_background" => call_agc_remove_background(&arguments).await, "agc_browser_playtest" => call_agc_browser_playtest(&arguments).await, - "agc_ui_design_to_html" => call_agc_ui_design_to_html(root, &arguments), "agc_web_search" => call_agc_web_search(&arguments).await, _ => mcp_tool_result("未知或未审核的 AGC 工具".to_string(), Vec::new(), true), }; @@ -1230,7 +1203,6 @@ mod tests { "agc_create_or_derive_resource", "agc_remove_background", "agc_browser_playtest", - "agc_ui_design_to_html" ] ); let serialized = specs.to_string(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs index 58e696980..a2063143b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs @@ -421,7 +421,6 @@ pub(crate) async fn execute_game_creator_agent_runtime_tool_action_with_pending_ "ui.workflow.run" => { observe_agent_runtime_ui_workflow(root, agent_id, run_id, task, &action.input).await } - "ui.design.to_html" => observe_agent_runtime_ui_design_to_html(root, &action.input), "blackboard.write" => { observe_agent_runtime_blackboard_write(root, agent_id, run_id, &action.input) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs index a6cb76010..eeb2f00ff 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs @@ -1097,7 +1097,6 @@ pub(in crate::agent) fn validate_agent_runtime_autonomous_read_only_delivery_pla | "command.output_read" | "command.poll" | "image.inspect" => false, - "ui.design.to_html" => false, "preview.validate" => agent_id != "preview-playtest" && agent_id != "code-prototype", "command.run_limited" => { agent_id != "preview-readiness" && agent_id != "code-prototype" diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/parallel_ledger.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/parallel_ledger.rs index 1de96d573..d047701d8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/parallel_ledger.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/parallel_ledger.rs @@ -12,7 +12,6 @@ pub(crate) fn agent_runtime_tool_is_parallel_safe_read(tool: &str) -> bool { | "file.list" | "file.read" | "task.list" - | "ui.design.to_html" ) } @@ -101,7 +100,6 @@ pub(in crate::agent) fn game_creator_agent_runtime_tool_command_id( "image.inspect" => Some("image.inspect"), "canvas.asset_generate" => Some("canvas.asset_generate"), "ui.workflow.run" => Some("asset.register"), - "ui.design.to_html" => Some("ui.design.to_html"), "blackboard.write" => Some("memory.write"), "agent.message" => Some("conversation.write"), "agent.delegate" => Some("agent.delegate"), diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs index eb24abcdc..0a58e049f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs @@ -100,7 +100,6 @@ pub(crate) fn agent_runtime_executable_tools() -> Vec<&'static str> { "image.inspect", "canvas.asset_generate", "ui.workflow.run", - "ui.design.to_html", "blackboard.write", "agent.message", "agent.delegate", @@ -340,7 +339,6 @@ pub(crate) fn agent_runtime_autonomous_design_foundation_command_is_allowed( | "image.inspect" | "canvas.asset_generate" | "canvas.asset_import" - | "ui.design.to_html" | "agent.audit" | "agent.run_status" ) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs index 0148f6d0f..1478ae0aa 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs @@ -17,7 +17,6 @@ mod project_ops; mod run_status; mod task_ops; mod ui_workflow; -mod ui_html; pub(in crate::agent) use action_history::*; pub(in crate::agent) use command_ops::*; @@ -36,7 +35,6 @@ pub(in crate::agent) use project_ops::*; pub(in crate::agent) use run_status::*; pub(in crate::agent) use task_ops::*; pub(in crate::agent) use ui_workflow::*; -pub(in crate::agent) use ui_html::*; #[cfg(test)] pub(crate) use delegation::observe_agent_runtime_agent_delegate_at_locked; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/policy.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/policy.rs index bceb052b7..8fc4f477f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/policy.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/policy.rs @@ -49,7 +49,6 @@ fn autonomous_design_foundation_command_is_allowed(command_id: &str) -> bool { | "canvas.asset_import" | "asset.register" | "ui.workflow.run" - | "ui.design.to_html" | "agent.audit" | "agent.action_history" | "agent.run_status" diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/ui_html.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/ui_html.rs deleted file mode 100644 index 648fccef5..000000000 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/ui_html.rs +++ /dev/null @@ -1,14 +0,0 @@ -use super::*; -use serde_json::Value; - -pub(in crate::agent) fn observe_agent_runtime_ui_design_to_html( - root: &Path, - input: &Value, -) -> AgentRuntimeToolObservation { - AgentRuntimeToolObservation { - tool: "ui.design.to_html".to_string(), - status: "error".to_string(), - summary: "ui.design.to_html 暂未实现".to_string(), - detail: None, - } -} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs index 2c4daca1f..8ef1b13d1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs @@ -1391,9 +1391,6 @@ fn runtime_tool_description(tool: &str) -> &'static str { "ui.workflow.run" => { "先用 discover 从受控 game/ui-pages.json 或页面声明标记自动发现全部功能页面,再把已登记 ui-prototype 与每个页面的设计图桥接成独立 UI JSON State;可同时载入已登记图片、图标和项目字体,执行 Provider 结构识别、多树合并与分批组件绑定、回读阶段,并且只有所有页面已绑定且已应用到 game/ 后才允许 finalize。项目根目录由 Runtime 注入,模型不得传入宿主路径。" } - "ui.design.to_html" => { - "UI 设计 HTML 转换工具当前暂未实现。" - } "blackboard.write" => "向项目级共享黑板追加稳定结论。", "agent.message" => "向一个目标 Agent 写入定向上下文消息。", "agent.delegate" => { @@ -1686,14 +1683,6 @@ fn runtime_tool_input_schema(tool: &str) -> Value { } } }), - "ui.design.to_html" => json!({ - "type": "object", - "required": ["assetId"], - "additionalProperties": false, - "properties": { - "assetId": { "type": "string", "minLength": 1, "maxLength": 160 } - } - }), "blackboard.write" => two_string_input_schema("title", "content"), "agent.message" => two_string_input_schema("agentId", "content"), "agent.delegate" => json!({ diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/mod.rs index d33c76490..a835d5713 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/mod.rs @@ -107,16 +107,15 @@ pub(crate) fn render_ui_design_state_js( let escaped = escape_template_literal(&pretty_html_fragment(&fragment)); modules.push(format!("export const {export_name} = `\n{escaped}\n`;")); } - let mut output = String::from( - "// UI 设计生成模块:仅导出 HTML 片段,不执行任何注入逻辑。\n\ - // 重新生成会覆盖本文件中的手动修改。\n\ - // 可通过宿主页面设置 --ui-scale 调整整体像素缩放,例如:\n\ - // document.documentElement.style.setProperty('--ui-scale', '0.85');\n\ - // 注入示例(仅供 Agent 按需修改):\n\ - // import { tree_example } from './generated-xxx.js';\n\ - // document.body.insertAdjacentHTML('beforeend', tree_example);\n\ - // const node = document.querySelector('[ui-node-id=\"...\"]');\n\n", - ); + let mut output = String::from(concat!( + "// UI 设计生成模块:仅导出 HTML 片段,不执行任何注入逻辑。\n", + "// 重新生成会覆盖本文件中的手动修改。\n", + "// 每个 tree 根节点已按对应 UI design 尺寸直接设置 --ui-scale。\n", + "// 注入示例(仅供 Agent 按需修改):\n", + "// import { tree_example } from '/ui/generated-xxx.js';\n", + "// document.body.insertAdjacentHTML('beforeend', tree_example);\n", + "// const node = document.querySelector('[ui-node-id=\"...\"]');\n\n", + )); output.push_str(&modules.join("\n\n")); if !output.ends_with('\n') { output.push('\n'); -- 2.52.0 From 937016084509ada2a3ef45b389ad683d9ffc8033 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 1 Sep 2026 13:45:20 +0800 Subject: [PATCH 08/30] =?UTF-8?q?=E9=87=8D=E6=9E=84=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 生成 HTML in js 样例时添加说明 --- .../src/ui_editor/html_renderer/mod.rs | 36 +++++++++++-------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/mod.rs index a835d5713..e417fd6c1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/mod.rs @@ -36,12 +36,6 @@ pub(crate) fn render_ui_design_state_html(state: &State) -> Result Date: Tue, 1 Sep 2026 15:27:30 +0800 Subject: [PATCH 09/30] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=9C=AA=E4=BD=BF?= =?UTF-8?q?=E7=94=A8=E7=9A=84=20MCP=20=E6=A0=B9=E8=B7=AF=E5=BE=84=E5=8F=82?= =?UTF-8?q?=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将未使用参数恢复为下划线前缀,消除 Rust 编译警告 其余工作区改动保持未暂存 --- .../src-tauri/src/agent/direct_tools_mcp.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs index a2ad7f737..9b319f43d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs @@ -1017,7 +1017,7 @@ async fn call_agc_web_search(arguments: &Value) -> Value { .await } -async fn handle_direct_tools_mcp_request(root: &Path, request: Value) -> Option { +async fn handle_direct_tools_mcp_request(_root: &Path, request: Value) -> Option { let id = request.get("id").cloned(); let method = request.get("method").and_then(Value::as_str)?; if id.is_none() { -- 2.52.0 From 7fb66927930322f9a81dce6730156d0e9f967cf6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 1 Sep 2026 15:27:43 +0800 Subject: [PATCH 10/30] =?UTF-8?q?=E7=BA=A6=E6=9D=9F=20UI=20=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E5=99=A8=E4=BF=9D=E5=AD=98=E4=B8=8E=E7=94=9F=E6=88=90?= =?UTF-8?q?=E5=B9=B6=E5=8F=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 提取按钮文案条件并在保存、生成及保存并返回入口统一互斥状态 要求生成基于已加载的持久化 revision,并同步更新会话边界文档 --- .../src/view/ui-editor/index.tsx | 17 ++++++++++++----- .../src/view/ui-editor/useUiEditorPage.ts | 3 +++ ...【前端架构】UI编辑会话模块边界-2026-08-19.md | 2 ++ 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx index d4c470643..515ba744c 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx @@ -22,6 +22,12 @@ import { useUiEditorSession, } from './useUiEditorPage'; +function saveAndGenerateLabel(isSaving: boolean, isGenerating: boolean) { + if (isSaving) return '保存中…'; + if (isGenerating) return '生成中…'; + return '保存并生成代码'; +} + export default function UiEditorPage({ projectPath, resourceId, @@ -146,6 +152,7 @@ export default function UiEditorPage({ className="rounded-lg bg-orange-600 px-3 py-1.5 text-sm font-semibold text-white disabled:opacity-60" disabled={ session.save.isSaving || + session.save.isGenerating || session.save.isLoading || Boolean(session.save.loadError) || session.save.persistedRevision === null || @@ -168,11 +175,10 @@ export default function UiEditorPage({ } onClick={requestSaveAndGenerate} > - {session.save.isSaving - ? '保存中…' - : session.save.isGenerating - ? '生成中…' - : '保存并生成代码'} + {saveAndGenerateLabel( + session.save.isSaving, + session.save.isGenerating, + )} @@ -275,6 +281,7 @@ export default function UiEditorPage({ className="rounded-lg bg-orange-600 px-3 py-2 text-xs font-semibold text-white disabled:opacity-60" disabled={ session.save.isSaving || + session.save.isGenerating || Boolean(session.save.loadError) || session.save.persistedRevision === null || session.save.isLocked diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts b/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts index f70feaa56..06adc9a8f 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts +++ b/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts @@ -1039,6 +1039,7 @@ export function useUiEditorSession( if ( !resourceId || isSaving || + isGenerating || isLoading || loadError || persistedRevision === null || @@ -1077,8 +1078,10 @@ export function useUiEditorSession( if ( !resourceId || isGenerating || + isSaving || isLoading || loadError || + persistedRevision === null || editor.isLocked ) { return null; diff --git a/docs/technical/【前端架构】UI编辑会话模块边界-2026-08-19.md b/docs/technical/【前端架构】UI编辑会话模块边界-2026-08-19.md index 84d8d2694..33d14aea0 100644 --- a/docs/technical/【前端架构】UI编辑会话模块边界-2026-08-19.md +++ b/docs/technical/【前端架构】UI编辑会话模块边界-2026-08-19.md @@ -14,3 +14,5 @@ - `save`:加载 / revision 状态、脏状态、保存警告和保存意图。 预览树在会话边界归一化为 `UITree | null`:尚未选择界面图、正在加载或尚未识别树时均以 `null` 表示,不能把 `Array.find` 的 `undefined` 传播到视图接口。 + +保存与代码生成共享同一份持久化 State/revision。会话层在保存或生成进行期间互斥拦截,且代码生成必须基于已加载的持久化 revision;视图层的保存按钮和“保存并返回”按钮同步遵守该互斥状态。 -- 2.52.0 From 7d88e58e35ecffb4268ec188e708ea6c9ee6c175 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 1 Sep 2026 15:27:55 +0800 Subject: [PATCH 11/30] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=20Web=20Mock=20?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E7=94=9F=E6=88=90=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tauri 不可用的内存存储不再伪报代码生成成功 统一返回明确的当前环境不支持生成代码错误 --- .../src/features/ui-editor/uiDesignStateStore.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/uiDesignStateStore.ts b/apps/ai-game-creator-shell/src/features/ui-editor/uiDesignStateStore.ts index 7109415a2..1335ce8ad 100644 --- a/apps/ai-game-creator-shell/src/features/ui-editor/uiDesignStateStore.ts +++ b/apps/ai-game-creator-shell/src/features/ui-editor/uiDesignStateStore.ts @@ -90,12 +90,7 @@ export const uiDesignStateStore: IUiDesignStateStore = { }; }, async generateCode() { - return { - relativePath: 'ui/generated-ui-design.js', - treeExports: [], - treeCount: 0, - nodeCount: 0, - }; + throw new Error('当前环境不支持生成代码'); }, }; -- 2.52.0 From 5e30ad8eac80008cabbedf21d7f8f1a68958d692 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 1 Sep 2026 15:28:35 +0800 Subject: [PATCH 12/30] =?UTF-8?q?=E5=8A=A0=E5=9B=BA=20UI=20HTML=20?= =?UTF-8?q?=E6=B3=A8=E9=87=8A=E8=BD=AC=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 循环替换连续连字符,防止注释边界被提前闭合 --- .../src-tauri/src/ui_editor/html_renderer/assets.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/assets.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/assets.rs index b83ce6315..2ba1c5913 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/assets.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/assets.rs @@ -9,9 +9,10 @@ pub(super) fn font_face_rule( } pub(super) fn html_comment(label: &str, value: serde_json::Value) -> Markup { - let text = serde_json::to_string_pretty(&value) - .unwrap_or_else(|_| "{}".to_string()) - .replace("--", "- -"); + let mut text = serde_json::to_string_pretty(&value).unwrap_or_else(|_| "{}".to_string()); + while text.contains("--") { + text = text.replace("--", "- -"); + } PreEscaped(format!("")) } -- 2.52.0 From 0564121dfcf5fc27a125522ada0c2fc15a442f75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 1 Sep 2026 15:28:44 +0800 Subject: [PATCH 13/30] =?UTF-8?q?=E6=8B=92=E7=BB=9D=20UI=20=E8=B5=84?= =?UTF-8?q?=E6=BA=90=E8=B7=AF=E5=BE=84=E5=8F=8D=E6=96=9C=E6=9D=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 阻止浏览器路径归一化导致的资源目录逃逸 当前资源尚未上线,不增加迁移流程 --- .../src-tauri/src/ui_editor/html_renderer/assets.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/assets.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/assets.rs index 2ba1c5913..12e9a5178 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/assets.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/assets.rs @@ -20,6 +20,7 @@ pub(super) fn asset_url(path: &str) -> Result { let path = path.trim(); if path.is_empty() || path.starts_with('/') + || path.contains('\\') || path.split('/').any(|part| part == "..") || path.chars().any(|character| { character.is_control() || matches!(character, '\'' | '"' | '`' | '(' | ')') -- 2.52.0 From cdca55e82b62ce8aeca866ee072a2fb67a9ae14f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 1 Sep 2026 15:30:26 +0800 Subject: [PATCH 14/30] =?UTF-8?q?=E4=B8=BA=20UI=20=E6=A0=91=E5=AF=BC?= =?UTF-8?q?=E5=87=BA=E5=90=8D=E8=BF=BD=E5=8A=A0=E6=A0=B9=E8=8A=82=E7=82=B9?= =?UTF-8?q?=E6=A0=87=E8=AF=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 使用根节点 ID 的十六进制后缀避免清洗后导出名重复 保留现有导出名主体,降低调用方迁移成本 --- .../src-tauri/src/ui_editor/html_renderer/mod.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/mod.rs index e417fd6c1..93f71a414 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/mod.rs @@ -56,11 +56,16 @@ pub(crate) fn render_ui_design_state_html(state: &State) -> Result Result<(String, Vec, usize), String> { + // 生成阶段只渲染已由保存流程 validate_state 校验过的 State;不重复执行领域校验。 let mut exports = Vec::with_capacity(state.ui_trees.len()); let mut modules = Vec::with_capacity(state.ui_trees.len()); let mut node_count = 0usize; for tree in &state.ui_trees { - let export_name = tree_export_name(tree.src_ui_design.as_str()); + let export_name = format!( + "{}_{}", + tree_export_name(tree.src_ui_design.as_str()), + hex_id(tree.root.id.as_str()) + ); exports.push(export_name.clone()); let image = state .ui_design_images -- 2.52.0 From d0abec8ba9fef0f3ae93e693cae459ff168ab148 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 1 Sep 2026 15:34:21 +0800 Subject: [PATCH 15/30] =?UTF-8?q?=E5=9C=A8=E4=BF=9D=E5=AD=98=E9=98=B6?= =?UTF-8?q?=E6=AE=B5=E6=A0=A1=E9=AA=8C=20UI=20Anchor=20=E8=8C=83=E5=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 拒绝超出 0..=1 的 Transform Anchor,避免生成溢出 CSS 生成阶段复用保存后的校验结果,不重复执行领域检查 --- .../src-tauri/src/ui_editor/persistence.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/persistence.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/persistence.rs index 5edec22cc..5a0fdbadf 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/persistence.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/persistence.rs @@ -659,6 +659,16 @@ fn validate_node( { return Err("UI 节点 Transform 必须是有限数值".to_string()); } + if node + .layout + .transform + .anchor_min + .iter() + .chain(node.layout.transform.anchor_max.iter()) + .any(|value| *value < 0.0 || *value > 1.0) + { + return Err("UI 节点 Transform 的 anchor 必须处于 0..=1 范围".to_string()); + } if node .layout .transform -- 2.52.0 From 6558119c2ef5d6470e5a7468eb19c0c69473b119 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 1 Sep 2026 15:34:29 +0800 Subject: [PATCH 16/30] =?UTF-8?q?=E6=94=BE=E5=AE=BD=20UI=20=E7=94=9F?= =?UTF-8?q?=E6=88=90=E4=BB=A3=E7=A0=81=E6=96=87=E4=BB=B6=E4=B8=8A=E9=99=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为生成 JavaScript 引入独立的 8 倍 State 大小上限 保留原子写入流程并让错误提示使用实际代码上限 --- .../src-tauri/src/ui_editor/persistence.rs | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/persistence.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/persistence.rs index 5a0fdbadf..52f39ca88 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/persistence.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/persistence.rs @@ -19,6 +19,7 @@ use typed_floats::tf32::StrictlyPositiveFinite; const UI_DESIGN_STATE_SCHEMA_VERSION: &str = "game-creator-ui-design-state.v1"; const UI_DESIGN_STATE_MAX_BYTES: usize = 2 * 1024 * 1024; +const UI_DESIGN_CODE_MAX_BYTES: usize = UI_DESIGN_STATE_MAX_BYTES * 8; const UI_DESIGN_STATE_MAX_IMAGES: usize = 4; const UI_DESIGN_STATE_MAX_SPRITES: usize = 1_024; pub(crate) const UI_DESIGN_STATE_MAX_NODES: usize = 10_000; @@ -180,7 +181,12 @@ pub(crate) fn generate_ui_design_code_at( let (content, tree_exports, node_count) = render_ui_design_state_js(&document.state)?; let relative_path = format!("ui/generated-{}.js", generated_file_stem(&asset_id)); let path = resolve_local_project_path(root, &relative_path)?; - write_ui_design_raw_file(&path, "UI 设计生成代码", content.as_bytes())?; + write_ui_design_raw_file_with_limit( + &path, + "UI 设计生成代码", + content.as_bytes(), + UI_DESIGN_CODE_MAX_BYTES, + )?; Ok(GenerateUiDesignCodeResult { relative_path, tree_count: tree_exports.len(), @@ -438,8 +444,17 @@ fn serialize_ui_design_document(document: &PersistedUiDesignState) -> Result.` file. fn write_ui_design_raw_file(path: &Path, label: &str, bytes: &[u8]) -> Result<(), String> { - if bytes.len() > UI_DESIGN_STATE_MAX_BYTES { - return Err(format!("{label} 超过 {UI_DESIGN_STATE_MAX_BYTES} 字节上限")); + write_ui_design_raw_file_with_limit(path, label, bytes, UI_DESIGN_STATE_MAX_BYTES) +} + +fn write_ui_design_raw_file_with_limit( + path: &Path, + label: &str, + bytes: &[u8], + max_bytes: usize, +) -> Result<(), String> { + if bytes.len() > max_bytes { + return Err(format!("{label} 超过 {max_bytes} 字节上限")); } let parent = path.parent().ok_or_else(|| format!("{label} 缺少父目录"))?; fs::create_dir_all(parent).map_err(|error| format!("创建 {label} 目录失败:{error}"))?; -- 2.52.0 From cb10902459fde2c81708b157f17b2786fa8a598b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 1 Sep 2026 16:05:20 +0800 Subject: [PATCH 17/30] =?UTF-8?q?=E8=A1=A5=E5=85=85=20UI=20Editor=20?= =?UTF-8?q?=E8=B5=84=E6=BA=90=E4=BD=BF=E7=94=A8=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 说明 UI Editor JSON 由 Tauri 管理并通过生成的 ui 模块接入游戏 同步 AGC skill pack manifest 版本与内容指纹 --- .../resources/agc-skills/agc-web-game-development/SKILL.md | 4 ++++ .../src-tauri/resources/agc-skills/manifest.json | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-web-game-development/SKILL.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-web-game-development/SKILL.md index 8448381b2..eda84dd25 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-web-game-development/SKILL.md +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-web-game-development/SKILL.md @@ -19,6 +19,10 @@ Implement the user's actual game request in the current project. Choose DOM, Can Call `agc_read_skill_resource` with `skillName="agc-web-game-development"` and `relativePath="references/game-quality-checklist.md"` when implementing a new game loop or a broad gameplay revision. +# Notes +* UI Editor's JSON formated is a special kind asset; Never edit it or read it directly. User can export to `ui/generated-.js` and want u to use it; + Read it instead, the doc embedded will instruct you how to use it. + ## Reporting Report the files changed, observable gameplay behavior, real validation performed, and any remaining limitation. Do not report a test or browser pass that did not run. diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json index a5d8a510d..72b100cb8 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json @@ -1,6 +1,6 @@ { "schemaVersion": "agc-skill-pack.v1", - "version": "2026-08-26.4", + "version": "2026-08-26.8", "skills": [ { "name": "agc-project-structure", @@ -61,7 +61,7 @@ "agents/openai.yaml", "references/game-quality-checklist.md" ], - "sha256": "b54646fc83eb48ffa270a726c63cdea5f05099d7990ec99024b00e255fb38d6c" + "sha256": "dc65a8dc5ebebf26d957978813b736f71417f57de6b6a1c49e2d4353535611a9" }, { "name": "agc-browser-playtest", -- 2.52.0 From 43ab4464edf1944891219ba535e0a72de7f40841 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 1 Sep 2026 17:03:20 +0800 Subject: [PATCH 18/30] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20UI=20=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E5=99=A8=E4=BF=9D=E5=AD=98=E4=B8=8E=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E7=94=9F=E6=88=90=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 普通保存和资源切换时清理生成提示与进行中状态。 为生成前置条件拦截补充可见错误,并修正保存确认按钮文案。 补齐 IUiDesignStateStore 测试替身的必需非 nullable generateCode 实现。 修正 Rust HTML 树分隔注释并同步更新 UI 编辑会话约定。 --- .../src-tauri/src/ui_editor/html_renderer/mod.rs | 2 +- apps/ai-game-creator-shell/src/view/ui-editor/index.tsx | 3 ++- .../src/view/ui-editor/useUiEditorPage.ts | 7 +++++++ apps/ai-game-creator-shell/tests/uiEditorPage.test.ts | 7 +++++++ .../technical/【前端架构】UI编辑会话模块边界-2026-08-19.md | 4 ++++ 5 files changed, 21 insertions(+), 2 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/mod.rs index 93f71a414..e86bfff7f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/mod.rs @@ -18,7 +18,7 @@ pub(crate) fn render_ui_design_state_html(state: &State) -> Result 0 { - trees.push("\n\n".to_string()); + trees.push("\n\n".to_string()); } trees.push(render_tree(state, tree)?.into_string()); } diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx index 515ba744c..b6f298eed 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx @@ -94,6 +94,7 @@ export default function UiEditorPage({ if (!resourceId || session.save.isSaving || session.workflow.isAiRunning) { return; } + setGenerateSuccess(null); setSaveAfterReturn(afterReturn); setGenerateAfterWarning(false); if (session.save.hasWarnings()) { @@ -355,7 +356,7 @@ export default function UiEditorPage({ } }} > - 仍然保存 + {generateAfterWarning ? '仍然保存并生成' : '仍然保存'} diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts b/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts index 06adc9a8f..d2382f24e 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts +++ b/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts @@ -257,6 +257,8 @@ export function useUiEditorSession( setPersistedRevision(0); setSavedStateSignature(JSON.stringify(EMPTY_UI_EDITOR_STATE)); setSaveError(null); + setGenerateError(null); + setIsGenerating(false); setHiddenNodeIds(new Set()); return; } @@ -265,6 +267,9 @@ export function useUiEditorSession( setIsLoading(true); setLoadError(null); setPersistedRevision(null); + setSaveError(null); + setGenerateError(null); + setIsGenerating(false); void stateStore .load(resourceId) .then(({ state, revision }) => { @@ -1048,6 +1053,7 @@ export function useUiEditorSession( return false; } setSaveError(null); + setGenerateError(null); setIsSaving(true); const snapshot = structuredClone(editor.state); const snapshotSignature = JSON.stringify(snapshot); @@ -1084,6 +1090,7 @@ export function useUiEditorSession( persistedRevision === null || editor.isLocked ) { + setGenerateError('当前状态不允许生成代码,请稍后重试。'); return null; } setGenerateError(null); diff --git a/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts b/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts index 72fd8b5ad..6c21bc416 100644 --- a/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts +++ b/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts @@ -137,6 +137,7 @@ async function renderLoadedSession(state: State) { const stateStore: IUiDesignStateStore = { load: vi.fn().mockResolvedValue({ revision: 0, state }), save: vi.fn(), + generateCode: vi.fn().mockRejectedValue(new Error('测试未配置代码生成')), }; const hook = renderHook(() => useUiEditorSession('/tmp/ui-editor', 'ui-resource', stateStore), @@ -150,6 +151,7 @@ describe('UiEditorPage', () => { const stateStore: IUiDesignStateStore = { load: vi.fn().mockResolvedValue(structuredClone(EMPTY_SNAPSHOT)), save: vi.fn(), + generateCode: vi.fn().mockRejectedValue(new Error('测试未配置代码生成')), }; render( @@ -193,6 +195,7 @@ describe('UiEditorPage', () => { state: stateWithPages(['page']), }), save: vi.fn(), + generateCode: vi.fn().mockRejectedValue(new Error('测试未配置代码生成')), }; const hook = renderHook( ({ resourceId }) => @@ -248,6 +251,7 @@ describe('UiEditorPage', () => { const stateStore: IUiDesignStateStore = { load: vi.fn(() => new Promise(() => undefined)), save: vi.fn(), + generateCode: vi.fn().mockRejectedValue(new Error('测试未配置代码生成')), }; const hook = renderHook(() => useUiEditorSession('/tmp/ui-editor', 'ui-resource', stateStore), @@ -355,6 +359,7 @@ describe('UiEditorPage', () => { state: stateWithPages(['gameplay-page']), }), save: vi.fn(), + generateCode: vi.fn().mockRejectedValue(new Error('测试未配置代码生成')), }; render( @@ -594,6 +599,7 @@ describe('UiEditorPage', () => { const stateStore: IUiDesignStateStore = { load: vi.fn().mockResolvedValue(structuredClone(EMPTY_SNAPSHOT)), save: vi.fn().mockRejectedValue(new Error('临时存储不可用')), + generateCode: vi.fn().mockRejectedValue(new Error('测试未配置代码生成')), }; render( createElement(UiEditorPage, { @@ -627,6 +633,7 @@ describe('UiEditorPage', () => { status: 'conflict', currentRevision: 1, }), + generateCode: vi.fn().mockRejectedValue(new Error('测试未配置代码生成')), }; render( createElement(UiEditorPage, { diff --git a/docs/technical/【前端架构】UI编辑会话模块边界-2026-08-19.md b/docs/technical/【前端架构】UI编辑会话模块边界-2026-08-19.md index 33d14aea0..6e33010bd 100644 --- a/docs/technical/【前端架构】UI编辑会话模块边界-2026-08-19.md +++ b/docs/technical/【前端架构】UI编辑会话模块边界-2026-08-19.md @@ -16,3 +16,7 @@ 预览树在会话边界归一化为 `UITree | null`:尚未选择界面图、正在加载或尚未识别树时均以 `null` 表示,不能把 `Array.find` 的 `undefined` 传播到视图接口。 保存与代码生成共享同一份持久化 State/revision。会话层在保存或生成进行期间互斥拦截,且代码生成必须基于已加载的持久化 revision;视图层的保存按钮和“保存并返回”按钮同步遵守该互斥状态。 + +`UiDesignStateStore` 的 `generateCode(assetId)` 是必需能力,返回成功结果时不得为 nullable;所有注入的 adapter 与测试替身都必须实现该方法。 + +资源切换时,会话必须清理上一资源的保存/生成错误和生成中状态;普通保存开始时也清理代码生成错误。生成请求若因加载、锁定或 revision 等前置条件被拦截,必须向视图提供可见错误,而不是静默返回。 -- 2.52.0 From 913c51264a73d3109692107fd512efda4edd35b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 1 Sep 2026 17:07:29 +0800 Subject: [PATCH 19/30] =?UTF-8?q?=E6=98=8E=E7=A1=AE=20Tiled=20fill=5Fcente?= =?UTF-8?q?r=20=E6=9A=82=E4=B8=8D=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在前端预览与 Rust HTML 渲染两侧保留 repeat fallback 并添加 TODO。 在 UI 编辑器布局契约中明确 Tiled.fill_center=false 当前不支持及后续迁移要求。 --- .../src-tauri/src/ui_editor/html_renderer/component/image.rs | 1 + .../src/features/ui-editor/utils/componentToCss.ts | 1 + .../【技术方案】UI编辑器Godot容器布局模型-2026-08-18.md | 1 + 3 files changed, 3 insertions(+) diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/component/image.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/component/image.rs index 1c3ffb5a4..6dcbb8d28 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/component/image.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/component/image.rs @@ -44,6 +44,7 @@ pub(in crate::ui_editor::html_renderer) fn image_styles( pixels_per_unit_multiplier, .. } => { + // TODO: Tiled.fill_center=false 当前暂不支持,保持整块 repeat fallback。 let w = finite_positive( sprite.pixel_size.x / (sprite.pixels_per_unit().get() * pixels_per_unit_multiplier.get()), diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/utils/componentToCss.ts b/apps/ai-game-creator-shell/src/features/ui-editor/utils/componentToCss.ts index 92ac8d202..2b80c682d 100644 --- a/apps/ai-game-creator-shell/src/features/ui-editor/utils/componentToCss.ts +++ b/apps/ai-game-creator-shell/src/features/ui-editor/utils/componentToCss.ts @@ -141,6 +141,7 @@ function imageTypeModel( const size = logicalSize(sprite, multiplier); if ('Tiled' in imageType) { + // TODO: Tiled.fill_center=false 当前暂不支持,保持浏览器 repeat fallback。 return { kind: 'background', src, diff --git a/docs/technical/【技术方案】UI编辑器Godot容器布局模型-2026-08-18.md b/docs/technical/【技术方案】UI编辑器Godot容器布局模型-2026-08-18.md index b7981c5bc..f9852c817 100644 --- a/docs/technical/【技术方案】UI编辑器Godot容器布局模型-2026-08-18.md +++ b/docs/technical/【技术方案】UI编辑器Godot容器布局模型-2026-08-18.md @@ -35,6 +35,7 @@ Container 专属数据是互斥 tagged union:HBox/VBox 存 `alignment + separa - 不支持 Flex/Flow wrap;Grid 按行优先排列。 - Margin 的 children 填满内侧矩形;Center 的 children 居中并可重叠。 - v1 minimum size 只使用 `custom_minimum_size`。TODO:为文本、图片等组件提供 minimum-size,再与 custom minimum 逐轴取最大值。 +- `ImageType::Tiled.fill_center=false` 当前暂不支持,前端预览与 Rust HTML 生成均保持整块 `repeat` fallback;如需实现仅铺边框,必须先补充 CSS 输出与既有项目迁移契约。 ## 编辑器交互 -- 2.52.0 From e708e09c91f4601b16e7bbbb634370b102b40239 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 1 Sep 2026 18:54:49 +0800 Subject: [PATCH 20/30] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20UI=20=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E5=99=A8=E5=AF=BC=E5=87=BA=E4=B8=8E=E9=A2=84=E8=A7=88?= =?UTF-8?q?=E4=B8=80=E8=87=B4=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复保存按钮在 AI 运行期间的禁用状态与生成成功提示失效 加强字体资源路径校验并复用生成代码字体 CSS 让布局错误显式传播并为生成文件名追加 asset ID 摘要 统一前端与 Rust 的 Radial90 顺时针填充方向 补充代码生成不推进项目 revision 的技术方案与决策记录 --- .../src/ui_editor/html_renderer/assets.rs | 2 +- .../html_renderer/component/image.rs | 2 +- .../src/ui_editor/html_renderer/container.rs | 27 +++++++------ .../src/ui_editor/html_renderer/mod.rs | 28 +++++++------ .../src-tauri/src/ui_editor/persistence.rs | 21 ++++++++-- .../ui-editor/utils/componentToCss.ts | 24 +++++++---- .../src/view/ui-editor/index.tsx | 40 ++++++++----------- .../shared-memory/decision-log.md | 6 +++ ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 1 + ...】UI工作流资源桥接与Runtime执行-2026-08-24.md | 2 + 10 files changed, 90 insertions(+), 63 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/assets.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/assets.rs index 12e9a5178..15c99ce95 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/assets.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/assets.rs @@ -23,7 +23,7 @@ pub(super) fn asset_url(path: &str) -> Result { || path.contains('\\') || path.split('/').any(|part| part == "..") || path.chars().any(|character| { - character.is_control() || matches!(character, '\'' | '"' | '`' | '(' | ')') + character.is_control() || matches!(character, '<' | '>' | '\'' | '"' | '`' | '(' | ')') }) { return Err(format!("资源路径无效:{path}")); diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/component/image.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/component/image.rs index 6dcbb8d28..5af248dfa 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/component/image.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/component/image.rs @@ -102,7 +102,7 @@ fn fill_clip_path(method: &FillMethod, amount: f32) -> Option { trim_float((1.0 - amount) * 100.0) )), FillMethod::Radial90 { origin, clockwise } => Some(conic_mask( - radial_origin_angle_90(*origin), + radial_origin_angle_90(*origin) - if *clockwise { 90.0 } else { 0.0 }, *clockwise, amount, 90.0, diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/container.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/container.rs index 1d6948fd5..c4b2dcb9d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/container.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/container.rs @@ -3,21 +3,24 @@ use crate::ui_editor::layout::control_layout::{Container, ControlLayout}; const UI_SCALE: &str = "var(--ui-scale, 1)"; -pub(super) fn container_style(container: &Container) -> String { - match container { +pub(super) fn container_style(container: &Container) -> Result { + Ok(match container { Container::None => String::new(), - Container::HBox { alignment, separation } => format!("display:flex;flex-direction:row;justify-content:{};gap:{};min-width:0;min-height:0;", alignment_css(*alignment), scaled_px(*separation).unwrap_or_else(|_| "0px".to_string())), - Container::VBox { alignment, separation } => format!("display:flex;flex-direction:column;justify-content:{};gap:{};min-width:0;min-height:0;", alignment_css(*alignment), scaled_px(*separation).unwrap_or_else(|_| "0px".to_string())), - Container::Grid { columns, h_separation, v_separation } => format!("display:grid;grid-template-columns:repeat({},minmax(0,1fr));column-gap:{};row-gap:{};min-width:0;min-height:0;", columns, scaled_px(*h_separation).unwrap_or_default(), scaled_px(*v_separation).unwrap_or_default()), - Container::Margin { margin_left, margin_top, margin_right, margin_bottom } => format!("display:grid;padding-left:{};padding-top:{};padding-right:{};padding-bottom:{};min-width:0;min-height:0;", scaled_px(*margin_left).unwrap_or_default(), scaled_px(*margin_top).unwrap_or_default(), scaled_px(*margin_right).unwrap_or_default(), scaled_px(*margin_bottom).unwrap_or_default()), + Container::HBox { alignment, separation } => format!("display:flex;flex-direction:row;justify-content:{};gap:{};min-width:0;min-height:0;", alignment_css(*alignment), scaled_px(*separation)?), + Container::VBox { alignment, separation } => format!("display:flex;flex-direction:column;justify-content:{};gap:{};min-width:0;min-height:0;", alignment_css(*alignment), scaled_px(*separation)?), + Container::Grid { columns, h_separation, v_separation } => format!("display:grid;grid-template-columns:repeat({},minmax(0,1fr));column-gap:{};row-gap:{};min-width:0;min-height:0;", columns, scaled_px(*h_separation)?, scaled_px(*v_separation)?), + Container::Margin { margin_left, margin_top, margin_right, margin_bottom } => format!("display:grid;padding-left:{};padding-top:{};padding-right:{};padding-bottom:{};min-width:0;min-height:0;", scaled_px(*margin_left)?, scaled_px(*margin_top)?, scaled_px(*margin_right)?, scaled_px(*margin_bottom)?), Container::Center { .. } => "display:grid;place-items:center;min-width:0;min-height:0;".to_string(), - } + }) } -pub(super) fn child_container_style(layout: &ControlLayout, parent: &Container) -> String { - let min_w = scaled_px(layout.custom_minimum_size.x).unwrap_or_default(); - let min_h = scaled_px(layout.custom_minimum_size.y).unwrap_or_default(); - match parent { +pub(super) fn child_container_style( + layout: &ControlLayout, + parent: &Container, +) -> Result { + let min_w = scaled_px(layout.custom_minimum_size.x)?; + let min_h = scaled_px(layout.custom_minimum_size.y)?; + Ok(match parent { Container::HBox { .. } => format!( "min-width:{min_w};min-height:{min_h};{}align-self:{};", flex_grow( @@ -44,7 +47,7 @@ pub(super) fn child_container_style(layout: &ControlLayout, parent: &Container) Container::Grid { .. } | Container::None => { format!("min-width:{min_w};min-height:{min_h};") } - } + }) } fn scaled_px(value: f32) -> Result { diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/mod.rs index e86bfff7f..e0c8199e7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/mod.rs @@ -57,6 +57,15 @@ pub(crate) fn render_ui_design_state_js( state: &State, ) -> Result<(String, Vec, usize), String> { // 生成阶段只渲染已由保存流程 validate_state 校验过的 State;不重复执行领域校验。 + let font_faces = state + .font_assets + .values() + .map(|font| { + let family = format!("ui-editor-font-{}", hex_id(font.asset_id.as_str())); + font_face_rule(font, &family) + }) + .collect::, _>>()? + .join(""); let mut exports = Vec::with_capacity(state.ui_trees.len()); let mut modules = Vec::with_capacity(state.ui_trees.len()); let mut node_count = 0usize; @@ -80,15 +89,6 @@ pub(crate) fn render_ui_design_state_js( }), ) .into_string(); - let fonts = state - .font_assets - .values() - .map(|font| { - let family = format!("ui-editor-font-{}", hex_id(font.asset_id.as_str())); - font_face_rule(font, &family) - }) - .collect::, _>>()? - .join(""); let root = render_node_with_scale( state, &tree.root, @@ -99,8 +99,10 @@ pub(crate) fn render_ui_design_state_js( node_count += count_nodes(&tree.root); let mut fragment = String::new(); fragment.push_str(&tree_comment); - if !fonts.is_empty() { - fragment.push_str(&html! { style data-ui-fonts { (PreEscaped(fonts)) } }.into_string()); + if !font_faces.is_empty() { + fragment.push_str( + &html! { style data-ui-fonts { (PreEscaped(font_faces.as_str())) } }.into_string(), + ); } fragment.push_str(&root); let escaped = escape_template_literal(&pretty_html_fragment(&fragment)); @@ -178,9 +180,9 @@ fn render_node_with_scale( )); } style.push_str("border:0;outline:0;background:transparent;overflow:visible;"); - style.push_str(&container_style(&node.layout.container)); + style.push_str(&container_style(&node.layout.container)?); if let Some(parent) = parent_container { - style.push_str(&child_container_style(&node.layout, parent)); + style.push_str(&child_container_style(&node.layout, parent)?); } let comment = html_comment( "genarrative-ui-node", diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/persistence.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/persistence.rs index 52f39ca88..e6c513f11 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/persistence.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/persistence.rs @@ -10,6 +10,7 @@ use crate::ui_editor::utils::UIDesignImageId; use crate::*; use nalgebra::Vector2; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; use std::collections::HashSet; use std::fs::{self, File}; use std::io::{Read, Write}; @@ -204,11 +205,14 @@ fn generated_file_stem(asset_id: &str) -> String { stem.push('_'); } } - if stem.is_empty() { - "ui-design".to_string() + let readable_stem = if stem.is_empty() { + "ui-design" } else { - stem - } + stem.as_str() + }; + // 保留可读前缀,并追加摘要以避免不同 ID 映射到同一路径。 + let digest = format!("{:x}", Sha256::digest(asset_id.as_bytes())); + format!("{readable_stem}-{}", &digest[..16]) } pub(crate) fn save_ui_design_state_at( @@ -1001,6 +1005,15 @@ mod tests { ); } + #[test] + fn generated_file_stem_keeps_distinct_asset_ids_distinct() { + let first = generated_file_stem("a.b"); + let second = generated_file_stem("a/b"); + assert_ne!(first, second); + assert!(first.starts_with("a_b-")); + assert_eq!(first.len(), "a_b-".len() + 16); + } + #[test] fn preserves_sprite_asset_when_saving_a_dragged_node_transform() { let (directory, asset_id) = fixture(); diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/utils/componentToCss.ts b/apps/ai-game-creator-shell/src/features/ui-editor/utils/componentToCss.ts index 2b80c682d..69ea58362 100644 --- a/apps/ai-game-creator-shell/src/features/ui-editor/utils/componentToCss.ts +++ b/apps/ai-game-creator-shell/src/features/ui-editor/utils/componentToCss.ts @@ -80,14 +80,22 @@ function fillClipPath(method: FillMethod, amount: number): string | undefined { const sweep = amount * maxSweep; const origin = config.origin; const originAngle = - origin === 'Top' || origin === 'TopLeft' || origin === 'TopRight' - ? 0 - : origin === 'Right' || origin === 'BottomRight' - ? 90 - : origin === 'Bottom' || origin === 'BottomLeft' - ? 180 - : 270; - const start = config.clockwise ? originAngle : originAngle - sweep; + kind === 'Radial90' + ? origin === 'TopLeft' + ? 0 + : origin === 'TopRight' + ? 90 + : origin === 'BottomRight' + ? 180 + : 270 + : origin === 'Top' + ? 0 + : origin === 'Right' + ? 90 + : origin === 'Bottom' + ? 180 + : 270; + const start = originAngle - (config.clockwise ? 90 : sweep); // CSS conic gradients provide a deterministic browser preview for radial // fills. Exact engine parity is intentionally deferred. return `conic-gradient(from ${start}deg, #000 0deg ${sweep}deg, transparent ${sweep}deg 360deg)`; diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx index b6f298eed..1a4f3fdaa 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx @@ -1,5 +1,5 @@ import { ChevronLeft } from 'lucide-react'; -import { type ReactNode, useMemo, useState } from 'react'; +import { type ReactNode, useEffect, useMemo, useState } from 'react'; import { ThemedModal } from '../../components/modal/ThemedModal'; import { @@ -69,6 +69,18 @@ export default function UiEditorPage({ const [generateAfterWarning, setGenerateAfterWarning] = useState(false); const [generateSuccess, setGenerateSuccess] = useState(null); const [returnConfirmOpen, setReturnConfirmOpen] = useState(false); + const saveDisabled = + session.save.isSaving || + session.save.isGenerating || + session.save.isLoading || + session.workflow.isAiRunning || + Boolean(session.save.loadError) || + session.save.persistedRevision === null || + session.save.isLocked; + + useEffect(() => { + if (session.save.isDirty) setGenerateSuccess(null); + }, [session.save.isDirty]); async function save(afterReturn = false) { if (await session.save.save()) { @@ -151,14 +163,7 @@ export default function UiEditorPage({