From 5de955442645c49a3aee9710ba13dc2dc39244c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 2 Sep 2026 16:49:19 +0800 Subject: [PATCH 1/3] =?UTF-8?q?Feat/ui=E7=BC=96=E8=BE=91=E5=99=A8=E4=B8=8E?= =?UTF-8?q?code=20agent=E6=95=B4=E5=90=88=20(#237)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实现: 在ui编辑器保存旁增加一个保存并生成代码(js) 生成的是js字符串形式的html, 在整个js前有文档说明 code agent使用js直接注入已经生成的 export的html片段, 每次保存会直接覆盖这些html片段, 依靠动态注入实现编辑器的微调反映到游戏中. 每个节点提供了稳定的id用于code agent在生成html的基础上进行功能实现, 节点增删 目前是作为一个自说明的文件直接让code agent阅读使用, 都是用户调整保存顺便生成, 不提供工具调用. 希望以skill的形式使用 --------- Co-authored-by: 段舒康 Co-authored-by: 孔令弘 Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/237 Co-authored-by: 王德宇 Co-committed-by: 王德宇 --- .../src-tauri/Cargo.lock | 35 +++ .../src-tauri/Cargo.toml | 1 + .../agc-web-game-development/SKILL.md | 4 + .../resources/agc-skills/manifest.json | 4 +- .../src-tauri/src/agent/direct_tools_mcp.rs | 2 +- .../src-tauri/src/main.rs | 10 + .../src-tauri/src/preview.rs | 32 +- .../src/ui_editor/html_renderer/assets.rs | 94 ++++++ .../html_renderer/component/image.rs | 228 ++++++++++++++ .../ui_editor/html_renderer/component/mod.rs | 60 ++++ .../ui_editor/html_renderer/component/text.rs | 77 +++++ .../src/ui_editor/html_renderer/container.rs | 85 ++++++ .../src/ui_editor/html_renderer/mod.rs | 286 ++++++++++++++++++ .../src/ui_editor/html_renderer/node.rs | 80 +++++ .../src-tauri/src/ui_editor/mod.rs | 1 + .../src-tauri/src/ui_editor/persistence.rs | 116 ++++++- .../src-tauri/src/ui_editor/resource/font.rs | 17 ++ .../project-workspace/GddApprovalCard.tsx | 6 +- .../features/ui-editor/uiDesignStateStore.ts | 16 + .../ui-editor/utils/componentToCss.ts | 25 +- .../Inspector/Transform/TransformEditor.tsx | 2 - .../components/WorkflowActionCard.tsx | 4 +- .../src/view/ui-editor/index.tsx | 104 +++++-- .../src/view/ui-editor/useUiEditorPage.ts | 132 ++++++-- .../tests/uiEditorPage.test.ts | 7 + .../shared-memory/decision-log.md | 14 + ...前端架构】UI编辑会话模块边界-2026-08-19.md | 6 + ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 1 + ...方案】UI编辑器Godot容器布局模型-2026-08-18.md | 1 + ...方案】立项策划Agent(Fast GDD)-2026-08-10.md | 1 + ...】UI工作流资源桥接与Runtime执行-2026-08-24.md | 2 + 31 files changed, 1394 insertions(+), 59 deletions(-) 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/image.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/component/mod.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/component/text.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/mod.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/node.rs diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.lock b/apps/ai-game-creator-shell/src-tauri/Cargo.lock index 15776243b..665fe6e9d 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", @@ -2734,6 +2735,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" @@ -3967,6 +3990,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 771b41cb2..3b7ab2880 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.toml +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.toml @@ -58,6 +58,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/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..c7aa068b8 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 6d38b0b09..281abf9c1 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.9", "skills": [ { "name": "agc-project-structure", @@ -61,7 +61,7 @@ "agents/openai.yaml", "references/game-quality-checklist.md" ], - "sha256": "b54646fc83eb48ffa270a726c63cdea5f05099d7990ec99024b00e255fb38d6c" + "sha256": "b197a242b75dc5e39739693e793acd25ac62dd2c72bc264305797936da47f356" }, { "name": "agc-browser-playtest", 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 ba1dffecc..d739961aa 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 @@ -1247,7 +1247,7 @@ mod tests { "agc_import_account_assets", "agc_create_or_derive_resource", "agc_remove_background", - "agc_browser_playtest" + "agc_browser_playtest", ] ); let serialized = specs.to_string(); 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 a415b02a9..c9b611aca 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -347,6 +347,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, @@ -2469,6 +2478,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/preview.rs b/apps/ai-game-creator-shell/src-tauri/src/preview.rs index 6f13cd088..ab6c73245 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/preview.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/preview.rs @@ -1429,7 +1429,7 @@ pub(crate) fn resolve_preview_path(root: &Path, url_path: &str) -> Result Result metadata, @@ -1517,7 +1517,7 @@ fn canonical_preview_path(root: &Path, file_path: &Path) -> Result Option { @@ -1659,4 +1659,30 @@ mod tests { assert!(resolve_preview_path(root.path(), "/.agent/secret.json").is_err()); assert!(resolve_preview_path(root.path(), "/memory/private.md").is_err()); } + + #[test] + fn legacy_layout_serves_root_ui_modules() { + let root = tempfile::tempdir().expect("create preview root"); + fs::create_dir_all(root.path().join("game")).expect("create game directory"); + fs::create_dir_all(root.path().join("ui")).expect("create ui directory"); + fs::write(root.path().join("game/index.html"), "") + .expect("write game entry"); + fs::write(root.path().join("ui/generated-x.js"), "export {};") + .expect("write generated module"); + + let expected = root + .path() + .join("ui/generated-x.js") + .canonicalize() + .expect("canonical generated module"); + assert_eq!( + resolve_preview_path(root.path(), "/ui/generated-x.js").unwrap(), + expected + ); + + let response = build_preview_response(root.path(), "GET", "/ui/generated-x.js"); + let response_text = String::from_utf8_lossy(&response); + assert!(response_text.starts_with("HTTP/1.1 200 OK\r\n")); + assert!(response_text.contains("export {};")); + } } 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..e2c74cb7e --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/assets.rs @@ -0,0 +1,94 @@ +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.css_format(), if font.metadata.italic { "italic" } else { "normal" }, font.metadata.weight)) +} + +pub(super) fn html_comment(label: &str, value: serde_json::Value) -> Markup { + let mut text = serde_json::to_string_pretty(&value).unwrap_or_else(|_| "{}".to_string()); + while text.contains("--") { + text = text.replace("--", "- -"); + } + PreEscaped(format!("")) +} + +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, '<' | '>' | '\'' | '"' | '`' | '(' | ')') + }) + { + 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() +} + +#[cfg(test)] +mod tests { + use super::font_face_rule; + use crate::ui_editor::resource::font::{FontAsset, FontAssetMetadata, FontFormat}; + use crate::ui_editor::utils::FontAssetId; + + fn font(format: FontFormat) -> FontAsset { + FontAsset { + asset_id: FontAssetId::new("font-main").expect("valid font id"), + metadata: FontAssetMetadata { + family_name: "测试字体".to_string(), + face_name: "Regular".to_string(), + weight: 400, + italic: false, + format, + source_file_name: format!("font.{}", format.extension()), + }, + path: format!("assets/font.{}", format.extension()), + content_sha256: "0".repeat(64), + } + } + + #[test] + fn font_face_rule_uses_standard_css_format_names() { + let truetype = font_face_rule(&font(FontFormat::TrueType), "ui-font").unwrap(); + let opentype = font_face_rule(&font(FontFormat::OpenType), "ui-font").unwrap(); + assert!(truetype.contains("format('truetype')")); + assert!(opentype.contains("format('opentype')")); + } +} + +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/image.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/component/image.rs new file mode 100644 index 000000000..7149afe45 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/component/image.rs @@ -0,0 +1,228 @@ +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(in crate::ui_editor::html_renderer) 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, + .. + } => { + // 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()), + "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) - if *clockwise { 90.0 } else { 0.0 }, + *clockwise, + amount, + 90.0, + )), + FillMethod::Radial180 { origin, clockwise } => Some(conic_mask( + radial_origin_angle_180(*origin) - if *clockwise { 90.0 } else { 0.0 }, + *clockwise, + amount, + 180.0, + )), + FillMethod::Radial360 { origin, clockwise } => Some(conic_mask( + radial_origin_angle_360(*origin) - if *clockwise { 90.0 } else { 0.0 }, + *clockwise, + amount, + 360.0, + )), + } +} + +#[cfg(test)] +mod tests { + use super::{fill_clip_path, FillMethod, Radial180Origin, Radial360Origin, Radial90Origin}; + + #[test] + fn clockwise_radial_180_and_360_start_ninety_degrees_before_origin() { + let radial_180 = fill_clip_path( + &FillMethod::Radial180 { + origin: Radial180Origin::Top, + clockwise: true, + }, + 0.5, + ) + .unwrap(); + let radial_360 = fill_clip_path( + &FillMethod::Radial360 { + origin: Radial360Origin::Right, + clockwise: true, + }, + 0.25, + ) + .unwrap(); + assert!(radial_180.contains("from -90deg")); + assert!(radial_360.contains("from 0deg")); + } + + #[test] + fn counter_clockwise_radial_start_angles_remain_unchanged() { + let radial_180 = fill_clip_path( + &FillMethod::Radial180 { + origin: Radial180Origin::Top, + clockwise: false, + }, + 0.5, + ) + .unwrap(); + assert!(radial_180.contains("from -90deg")); + } + + #[test] + fn radial_90_matches_preview_for_all_origins_and_directions() { + let cases = [ + (Radial90Origin::TopLeft, 0.0), + (Radial90Origin::TopRight, 90.0), + (Radial90Origin::BottomRight, 180.0), + (Radial90Origin::BottomLeft, 270.0), + ]; + for (origin, angle) in cases { + let clockwise = fill_clip_path( + &FillMethod::Radial90 { + origin, + clockwise: true, + }, + 0.5, + ) + .unwrap(); + let counter_clockwise = fill_clip_path( + &FillMethod::Radial90 { + origin, + clockwise: false, + }, + 0.5, + ) + .unwrap(); + assert!(clockwise.contains(&format!("from {}deg", angle - 90.0))); + assert!(counter_clockwise.contains(&format!("from {}deg", angle - 45.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/component/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/component/mod.rs new file mode 100644 index 000000000..4c6b460d9 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/component/mod.rs @@ -0,0 +1,60 @@ +use super::assets::{asset_url, hex_id}; +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 { + 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 style=(style) { + (text.content) + } + }); + } + Ok(html! { div 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 style=(style) { + img src=(src) alt="" aria-hidden="true" draggable="false" style=(image_style); + } + }, + None => html! { div style=(style) {} }, + }) + } + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/component/text.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/component/text.rs new file mode 100644 index 000000000..9fcaa1606 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/component/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(in crate::ui_editor::html_renderer) 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/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..c4b2dcb9d --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/container.rs @@ -0,0 +1,85 @@ +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) -> 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)?), + 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, +) -> 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( + 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/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/mod.rs new file mode 100644 index 000000000..e0c8199e7 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/mod.rs @@ -0,0 +1,286 @@ +mod assets; +mod component; +mod container; +mod node; + +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}; +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) +} + +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; + for tree in &state.ui_trees { + 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 + .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 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 !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)); + modules.push(format!("export const {export_name} = `\n{escaped}\n`;")); + } + let mut output = String::from( + r#" +//这是从用户手动调整过的UI设计文档生成的html片段, 需要在合适的位置注入游戏中. +// 注入示例: +// import { tree_example } from '/ui/generated-xxx.js'; // 统一使用绝对路径这里 +// document.body.insertAdjacentHTML('beforeend', tree_example); +// 生成规则默认根节点(们)占据整个窗口. +// +// 禁止直接修改本js模块因为它随时会被用户重新导出覆盖 +// 应用逻辑/修改方式, 使用稳定的ui-node-id来查找某个元素然后使用js修改. +// const node = document.querySelector('[ui-node-id="..."]'); +// +// html片段内注释包含了丰富的元数据需要你理解并且尽可能实现它们 +// 对于交互逻辑, 页面关系, 有不清楚的地方可以向用户询问 +// 对于动态内容, 此文档给出的可能只是一个(或几个在列表中)例子, 需要你根据游戏逻辑, 在js中动态实现. +// 对于容器式布局, 文档可能错误地使用position来描述, 需要你合理地使用flex, grid, scrollable, overflow等重写. +// +// 必要时鼓励清理冗余的示例性的元素, 原则: 尽量保留复用原来的父级面板, patch其中的元素, 而不是替换整个面板 +"#, + ); + + 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 { + 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(), + }), + ); + let style = "position:relative;width:100%;height:100%;min-height:0;"; + Ok(html! { + (tree_comment) + div style=(style) { (render_node(state, &tree.root, None)?) } + }) +} + +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 { + 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_with_scale( + state, + child, + is_container(&node.layout.container).then_some(&node.layout.container), + None, + ) + }) + .collect::, _>>()? + .into_iter() + .map(|fragment| fragment.into_string()) + .collect::>(); + Ok(html! { + (comment) + @if let Some(group_comment) = exclusive_comment { (group_comment) } + 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(" 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/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; 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 9557d24b2..04b72c0b3 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 @@ -1,5 +1,6 @@ use crate::ui_editor::component::text::FontSource; use crate::ui_editor::component::Component; +use crate::ui_editor::html_renderer::render_ui_design_state_js; use crate::ui_editor::layout::node::Node; use crate::ui_editor::resource::ui_design_image::{ UIDesignImage, UIDesignImageMetadata, UIDesignImageRole, @@ -9,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}; @@ -18,6 +20,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; @@ -43,6 +46,23 @@ pub(crate) struct LoadUiDesignStateInput { pub(crate) asset_id: String, } +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct GenerateUiDesignCodeInput { + pub(crate) project_path: String, + pub(crate) expected_project_id: String, + pub(crate) asset_id: String, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct GenerateUiDesignCodeResult { + pub(crate) relative_path: String, + pub(crate) tree_exports: Vec, + 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 +169,52 @@ 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_with_limit( + &path, + "UI 设计生成代码", + content.as_bytes(), + UI_DESIGN_CODE_MAX_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('_'); + } + } + let readable_stem = if stem.is_empty() { + "ui-design" + } else { + 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( input: SaveUiDesignStateInput, ) -> Result { @@ -382,8 +448,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}"))?; @@ -920,6 +995,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(); @@ -959,6 +1043,34 @@ mod tests { assert_eq!(loaded.state, state); } + #[test] + fn preserves_ordered_out_of_range_transform_anchors() { + let (directory, asset_id) = fixture(); + let mut state = state_with_sprite_and_dragged_transform(); + let transform = &mut state.ui_trees[0].root.children[0].layout.transform; + transform.anchor_min = Vector2::new(-0.25, -0.5); + transform.anchor_max = Vector2::new(1.5, 1.25); + + let saved = save_ui_design_state_at(input(directory.path(), &asset_id, 0, state.clone())) + .expect("save ordered out-of-range anchors"); + assert!(matches!( + saved, + SaveUiDesignStateResult::Saved { + revision: 1, + state: ref installed, + .. + } if installed == &state + )); + + let loaded = load_ui_design_state_at(LoadUiDesignStateInput { + project_path: directory.path().to_string_lossy().into_owned(), + expected_project_id: PROJECT_ID.to_string(), + asset_id, + }) + .expect("load ordered out-of-range anchors"); + assert_eq!(loaded.state, state); + } + #[test] fn rejects_inverted_transform_anchors() { let state: State = serde_json::from_value(serde_json::json!({ diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/resource/font.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/resource/font.rs index 776f4b2e5..70efc4131 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/resource/font.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/resource/font.rs @@ -30,6 +30,15 @@ impl FontFormat { Self::Woff2 => "woff2", } } + + pub fn css_format(self) -> &'static str { + match self { + Self::TrueType => "truetype", + Self::OpenType => "opentype", + Self::Woff => "woff", + Self::Woff2 => "woff2", + } + } } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize, TS)] @@ -210,4 +219,12 @@ mod tests { .expect_err("reject unparseable web font") .contains("无法安全解析")); } + + #[test] + fn font_format_uses_css_format_strings() { + assert_eq!(FontFormat::TrueType.css_format(), "truetype"); + assert_eq!(FontFormat::OpenType.css_format(), "opentype"); + assert_eq!(FontFormat::Woff.css_format(), "woff"); + assert_eq!(FontFormat::Woff2.css_format(), "woff2"); + } } diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/GddApprovalCard.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/GddApprovalCard.tsx index 5a470dd39..94cfb07e0 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/GddApprovalCard.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/GddApprovalCard.tsx @@ -410,7 +410,11 @@ export function GddApprovalCard({ const canDecide = Boolean( pending && state.state === 'ready_for_approval' && !state.recoveryPending, ); - const decisionDisabled = !canDecide || decisionBusy || hydrateBusy; + // `hydrateBusy` only describes an in-flight background read. Once a card is rendered, the + // loaded projection remains the actionable snapshot until it explicitly reports + // `recoveryPending`; otherwise a refresh racing the first render can transiently disable an + // already-open dialog before the user has had a chance to submit it. + const decisionDisabled = !canDecide || decisionBusy; const submitComment = () => { // 方案 §18.2:`recoveryPending` 期间只允许重试同一 ID,不允许提交决定。触发按钮 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..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 @@ -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,9 @@ export const uiDesignStateStore: IUiDesignStateStore = { committedProjectRevision: 0, }; }, + async generateCode() { + throw new Error('当前环境不支持生成代码'); + }, }; export const EMPTY_UI_DESIGN_STATE = EMPTY_UI_EDITOR_STATE; 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..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)`; @@ -141,6 +149,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/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Transform/TransformEditor.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Transform/TransformEditor.tsx index 1e50b09d2..85039f389 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Transform/TransformEditor.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Transform/TransformEditor.tsx @@ -367,8 +367,6 @@ export function TransformEditor({ const warning = transform.anchor_min[0] > transform.anchor_max[0] || transform.anchor_min[1] > transform.anchor_max[1] || - transform.anchor_min.some((value) => value < 0 || value > 1) || - transform.anchor_max.some((value) => value < 0 || value > 1) || geometry?.invalid; return ( diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/WorkflowActionCard.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/WorkflowActionCard.tsx index 3e450d5aa..c5bf945e4 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/WorkflowActionCard.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/WorkflowActionCard.tsx @@ -39,7 +39,7 @@ export function WorkflowActionCard({ + ) : null} @@ -133,10 +188,26 @@ export default function UiEditorPage({ {session.save.saveError} ) : null} + {session.save.generateError ? ( +
+ 代码生成失败:{session.save.generateError} +
+ ) : null} + {generateSuccess ? ( +
+ {generateSuccess} +
+ ) : null}
@@ -205,12 +276,7 @@ export default function UiEditorPage({
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..ae17854be 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 @@ -255,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; } @@ -263,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 }) => { @@ -365,6 +372,8 @@ export function useUiEditorSession( !isSlaveToDescendant(images, id as UIDesignImageId, activeImageId), ); const isAiRunning = isSuggesting || isRecognizing || isBinding || isMerging; + const isWorkflowBusy = + isAiRunning || isSaving || isGenerating || isLoading || editor.isLocked; const stateSignature = JSON.stringify(editor.state); const isDirty = resourceId !== undefined && @@ -658,7 +667,7 @@ export function useUiEditorSession( } function requestStepChange(step: UiEditorStepId) { - if (step === activeStep || isAiRunning) return; + if (step === activeStep || isWorkflowBusy) return; const leavingIssues = postCheckIssuesForStep(editor.state, activeStep); const enteringIssues = prerequisiteIssuesForStep(editor.state, step); if (leavingIssues.length === 0 && enteringIssues.length === 0) { @@ -929,7 +938,7 @@ export function useUiEditorSession( } async function suggestUiDesignSemantics() { - if (isSuggesting) return; + if (isSuggesting || isWorkflowBusy) return; setSuggestionStatus(null); setIsSuggesting(true); try { @@ -952,7 +961,7 @@ export function useUiEditorSession( } async function recognizeUi() { - if (isRecognizing) return; + if (isRecognizing || isWorkflowBusy) return; setRecognitionStatus(null); setIsRecognizing(true); try { @@ -977,7 +986,7 @@ export function useUiEditorSession( async function mergeUi() { // TODO: This experimental operation is intentionally outside the formal workflow. - if (isMerging) return; + if (isMerging || isWorkflowBusy) return; setMergeStatus(null); setIsMerging(true); try { @@ -995,7 +1004,7 @@ export function useUiEditorSession( } async function bindComponents() { - if (isBinding) return; + if (isBinding || isWorkflowBusy) return; setBindingStatus(null); setIsBinding(true); try { @@ -1037,7 +1046,9 @@ export function useUiEditorSession( if ( !resourceId || isSaving || + isGenerating || isLoading || + isAiRunning || loadError || persistedRevision === null || editor.isLocked @@ -1045,24 +1056,26 @@ export function useUiEditorSession( return false; } setSaveError(null); + setGenerateError(null); setIsSaving(true); - const snapshot = structuredClone(editor.state); - const snapshotSignature = JSON.stringify(snapshot); try { - const result = await stateStore.save( - resourceId, - persistedRevision, - snapshot, - ); - if (result.status === 'conflict') { - setSaveError('资源已在别处更新;请重新加载后再保存。'); - return false; - } - setPersistedRevision(result.revision); - if (JSON.stringify(editor.state) === snapshotSignature) { - setSavedStateSignature(snapshotSignature); - } - return true; + return await editor.runWithStateLocked(async (snapshot) => { + const snapshotSignature = JSON.stringify(snapshot); + const result = await stateStore.save( + resourceId, + persistedRevision, + snapshot, + ); + if (result.status === 'conflict') { + setSaveError('资源已在别处更新;请重新加载后再保存。'); + return false; + } + setPersistedRevision(result.revision); + if (JSON.stringify(editor.state) === snapshotSignature) { + setSavedStateSignature(snapshotSignature); + } + return true; + }); } catch { setSaveError('保存失败,请稍后重试。'); return false; @@ -1071,6 +1084,78 @@ export function useUiEditorSession( } } + async function generateCode() { + if ( + !resourceId || + isGenerating || + isSaving || + isLoading || + isAiRunning || + loadError || + persistedRevision === null || + editor.isLocked + ) { + setGenerateError('当前状态不允许生成代码,请稍后重试。'); + return null; + } + setGenerateError(null); + setIsGenerating(true); + try { + return await editor.runWithStateLocked(() => + stateStore.generateCode(resourceId), + ); + } catch (cause) { + setGenerateError(cause instanceof Error ? cause.message : String(cause)); + return null; + } finally { + setIsGenerating(false); + } + } + + async function saveAndGenerateCode() { + if ( + !resourceId || + isSaving || + isGenerating || + isLoading || + isAiRunning || + loadError || + persistedRevision === null || + editor.isLocked + ) { + return null; + } + setSaveError(null); + setGenerateError(null); + setIsSaving(true); + setIsGenerating(true); + try { + return await editor.runWithStateLocked(async (snapshot) => { + const snapshotSignature = JSON.stringify(snapshot); + const saved = await stateStore.save( + resourceId, + persistedRevision, + snapshot, + ); + if (saved.status === 'conflict') { + setSaveError('资源已在别处更新;请重新加载后再保存。'); + return null; + } + setPersistedRevision(saved.revision); + if (JSON.stringify(editor.state) === snapshotSignature) { + setSavedStateSignature(snapshotSignature); + } + return await stateStore.generateCode(resourceId); + }); + } catch (cause) { + setGenerateError(cause instanceof Error ? cause.message : String(cause)); + return null; + } finally { + setIsGenerating(false); + setIsSaving(false); + } + } + return { input: { projectPath, @@ -1197,6 +1282,7 @@ export function useUiEditorSession( furthestStepIndex, nextStep, isAiRunning, + isBusy: isWorkflowBusy, pendingStepChange: pendingWorkflowStepChange, isSuggesting, hasSuggested, @@ -1237,9 +1323,13 @@ export function useUiEditorSession( isSaving, isDirty, saveError, + isGenerating, + generateError, hasWarnings: () => postCheckIssuesForSave(editor.state).length > 0, warnings: () => postCheckIssuesForSave(editor.state), save, + generateCode, + saveAndGenerateCode, }, }; } 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/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 7c590d5ae..6ccaca4eb 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -15,6 +15,14 @@ - 关联文档:相关 PRD、技术文档、提交或 Issue ``` +## 2026-09-02 GDD 审批卡的后台 hydrate 不抢占已加载决定 + +- 背景:项目页首次加载和运行态刷新可能并发 hydrate。卡片已经显示后,短暂的 `hydrateBusy` 会让已打开的评论弹层提交按钮瞬时变灰,用户无法提交已输入的修改意见。 +- 决策:`hydrateBusy` 只控制恢复区的重试按钮;已加载审批卡的决定按钮和评论弹层继续依据 `canDecide` 与 `decisionBusy` 门控。只有权威状态显式返回 `recoveryPending=true` 时才禁止决定,并保留弹层中的输入内容。 +- 影响范围:AGC GDD 审批卡前端、Fast GDD 审批交互文档;不改变 hydrate command、审批 DTO 或后端状态机。 +- 验证方式:运行评论弹层恢复竞态回归、完整 `appSurface.test.ts`,并执行类型、编码和 diff 检查。 +- 关联文档:`docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md`、`apps/ai-game-creator-shell/src/features/project-workspace/GddApprovalCard.tsx`。 + ## 2026-08-31 DirectProject 客户端扩展按独立 Skill/MCP 导入 - 背景:DirectProject 需要使用用户在 AGC 客户端导入的市面原生 Skill、MCP 和 Plugin 内容,但第三方内容不应直接安装到运行时 Codex,也不应要求用户转换为 AGC 自定义格式。 @@ -7892,3 +7900,9 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - `autonomous-game-build` 中,manifest `dependencies` 只作为上下文,不阻塞 ready;代码、设计、美术、音频和发布任务允许并行启动,child 不依赖固定回执顺序或固定 run 身份才能推进。 - 任务最终状态不再提前绑定平台画布、preview、static smoke 或发布产物检查;这些内容不参与该档位的完成判定,也不会因缺失而重置已完成任务。父 run 在任务图进入终态后直接收束并回复。 - 本档位仍沿用现有项目根和工具权限边界;本次调整只解除流程编排与平台产物验收前置,不新增第二套任务系统。 + +## 2026-09-01 UI 编辑器代码导出与填充预览边界 + +- UI 编辑器导出的 `ui/generated-*.js` 是派生本地产物。代码生成只写文件,绝不推进项目 revision、UI State revision、manifest 阶段或 Runtime 验证门;写入失败只返回生成错误,不能把生成文件写入冒充项目 mutation。 +- 生成文件名保留可读清洗前缀,并追加 asset ID 的 SHA-256 摘要前缀以避免不同 ID 碰撞;不迁移既有旧路径,调用方需在采用新命名后使用新返回路径。 +- Radial90 的前端预览与 Rust 导出统一使用角点映射和顺时针起始角规则,顺时针填充从角点前一条边开始,避免两端渲染偏移。 diff --git a/docs/technical/【前端架构】UI编辑会话模块边界-2026-08-19.md b/docs/technical/【前端架构】UI编辑会话模块边界-2026-08-19.md index 84d8d2694..6e33010bd 100644 --- a/docs/technical/【前端架构】UI编辑会话模块边界-2026-08-19.md +++ b/docs/technical/【前端架构】UI编辑会话模块边界-2026-08-19.md @@ -14,3 +14,9 @@ - `save`:加载 / revision 状态、脏状态、保存警告和保存意图。 预览树在会话边界归一化为 `UITree | null`:尚未选择界面图、正在加载或尚未识别树时均以 `null` 表示,不能把 `Array.find` 的 `undefined` 传播到视图接口。 + +保存与代码生成共享同一份持久化 State/revision。会话层在保存或生成进行期间互斥拦截,且代码生成必须基于已加载的持久化 revision;视图层的保存按钮和“保存并返回”按钮同步遵守该互斥状态。 + +`UiDesignStateStore` 的 `generateCode(assetId)` 是必需能力,返回成功结果时不得为 nullable;所有注入的 adapter 与测试替身都必须实现该方法。 + +资源切换时,会话必须清理上一资源的保存/生成错误和生成中状态;普通保存开始时也清理代码生成错误。生成请求若因加载、锁定或 revision 等前置条件被拦截,必须向视图提供可见错误,而不是静默返回。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index a44724132..fda4e384c 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -1199,6 +1199,7 @@ game-project/ - UI 编辑器复用现有 manifest `kind: "UI"`、`mediaType: "application/json"` 资源,不增加平行 asset kind。资源文件固定为严格 `game-creator-ui-design-state.v1` JSON envelope:`projectId`、`assetId`、每资源 `revision` 和 Rust 唯一源 `State`;旧空对象、未知字段、身份错配、超限、无效内部引用和不安全相对路径均失败关闭。内部引用校验同时覆盖 `Image.target_graphic -> sprite_assets` 与 `Text.font -> font_assets`,可选引用非空时必须命中同一 State 内已登记资源。 - Tauri 专用 load/save command 只接受项目路径、期望项目 ID、manifest asset ID 和(保存时)资源 revision;Rust 按 manifest 解析受控本地路径并在项目写锁内做 CAS。相同 `State` 返回 unchanged 且不推进 project revision;不同内容安装并回读一致后才推进 revision,后续推进失败返回 `reconciliation-required`,不伪装为完整保存。 +- UI 编辑器代码导出仅写入用户项目目录下的 `ui/generated-*.js` 派生文件,绝不推进项目 revision、UI State revision、manifest 阶段或 Runtime 验证门;写入失败只返回生成错误,不得把生成文件写入冒充为项目 mutation。 - UI State 原子安装保留最近一个可解析、canonical 的 `.previous` 恢复候选,作为最佳努力恢复来源;写入主文件前不把完整 State 语义校验重复执行一遍。主文件损坏时,恢复候选仍必须通过同一严格 schema、project/asset identity、revision、引用和 State 校验后才能安装;恢复安装与保存共用项目写锁,并在持锁后重新读取主文件,已有并发保存的有效新版本时直接返回而不安装旧副本。任一候选均不可信则停在加载错误,前端禁编辑和保存。新建 UI 资源先登记并安装合法 envelope,任一步失败补偿 manifest/文件,避免把空 JSON 留给资源卡。 - 图片路径只需是安全项目相对路径,不要求外部图片仍存在或已登记为 manifest asset;缺失媒体只导致 preview 占位。`imageOrder`、当前选择、缩放、面板开关和 preview URL 不写入 State,加载后由 State 派生。保存冻结提交快照,保存期间的新编辑继续保持 dirty;AI state lock 和加载期间禁保存。 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 输出与既有项目迁移契约。 ## 编辑器交互 diff --git a/docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md b/docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md index 32d1e5774..7d599f4d2 100644 --- a/docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md +++ b/docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md @@ -1673,6 +1673,7 @@ type PlanningBaselineInput = - 持有 Runtime 提供的 approvalRequestId;一次点击生成 responseId,busy/超时重试复用。 - command 进行中禁用重复点击;另一个窗口先决定后,当前卡刷新为 `already-decided`,不能覆盖。 - `recoveryPending=true` 时显示可恢复状态,只允许重试同一 ID,不允许提交新版本或启动构建。 +- 已渲染审批卡的后台 hydrate 仅更新权威投影,不因短暂的 `hydrateBusy` 竞态禁用当前决定或已打开的评论弹层;只有 hydrate 返回的 `recoveryPending=true`(或决定请求自身 busy)才阻止提交,并保留用户已输入的原因。 - 待审版本的 receipt 已存在时隐藏对应 stale pending 卡;hydrate 只恢复精确 project/session/run/action identity。 - approved 后只有在所有必需投影恢复完成时启用“做成游戏”;恢复态不提供该出口。 diff --git a/docs/【技术方案】UI工作流资源桥接与Runtime执行-2026-08-24.md b/docs/【技术方案】UI工作流资源桥接与Runtime执行-2026-08-24.md index ed0ad5971..fe5c971a3 100644 --- a/docs/【技术方案】UI工作流资源桥接与Runtime执行-2026-08-24.md +++ b/docs/【技术方案】UI工作流资源桥接与Runtime执行-2026-08-24.md @@ -42,6 +42,8 @@ Agent 通过白名单工具 `ui.workflow.run` 发起工作流。项目路径由 每次 State 或 manifest 阶段变化都推进项目 revision。Runtime 回执带有 `revisionAdvanceCount`,用于并发项目 revision 门禁;manifest 资产的 `source.generationKind` 依次记录: +UI 编辑器“生成代码”只把导出的 `ui/generated-*.js` 写入用户项目目录,**绝不推进项目 revision**,也不改变 UI State revision、manifest 阶段或 Runtime 验证门。生成文件属于派生本地产物;若写入失败,仅返回生成错误,不得通过 revision 变化制造 mutation 证据。 + ```text ui-workflow.reference-ready ui-workflow.structure-ready From 97b0231afc9be59b936e6fba3b44802a5caaafdd Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 2 Sep 2026 18:15:38 +0800 Subject: [PATCH 2/3] =?UTF-8?q?=E4=BC=98=E5=8C=96=20AGC=20=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E6=81=A2=E5=A4=8D=E8=B6=85=E6=97=B6=E4=B8=8E=20Runner?= =?UTF-8?q?=20=E5=90=AF=E5=8A=A8=E6=A0=A1=E9=AA=8C=20(#253)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 变更内容 - 增加启动会话恢复阶段、等待时间和可操作错误提示。 - 为客户端 HTTP 请求增加默认超时、AbortController 取消和可选长请求豁免。 - 会话恢复失败后提供重试,并隔离旧请求的迟到结果。 - 让 Runner 启动探测严格遵守剩余启动时间预算。 - 补充前端、HTTP、Runner 定向测试并同步技术方案。 ## 验证 - npm run typecheck --workspace @genarrative/ai-game-creator-shell - npm run check:encoding - clientHttp 定向测试 - AuthenticatedClient 定向测试 - Runner 启动探测测试 - cargo fmt --check - git diff --check Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/253 Co-authored-by: kdletters Co-committed-by: kdletters --- .../src-tauri/src/agent/runtime_state.rs | 8 +- .../src-tauri/src/runner/client.rs | 27 ++- .../src-tauri/src/runner/tests.rs | 12 ++ .../src/app/AuthenticatedClient.tsx | 197 +++++++++++++++--- .../src/services/clientHttp.ts | 116 ++++++++++- .../tests/appSurface/auth.suite.ts | 36 ++++ .../tests/clientHttp.test.ts | 60 ++++++ ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 7 + 8 files changed, 427 insertions(+), 36 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs index 938f4f82e..30fb3866c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs @@ -2403,13 +2403,17 @@ pub(super) fn try_acquire_game_creator_agent_runtime_task_lock_with_wait( root: &Path, agent_id: &str, ) -> Result, String> { - for attempt in 0..25 { + // Runtime state transitions can persist several audit projections while holding + // the lane lock. Keep the wait bounded, but allow a slow CI/disk-backed + // transition to finish before reporting a false busy error. + const MAX_ATTEMPTS: usize = 100; + for attempt in 0..MAX_ATTEMPTS { if let Some(runtime_lock) = try_acquire_game_creator_agent_runtime_task_lock(root, agent_id)? { return Ok(Some(runtime_lock)); } - if attempt < 24 { + if attempt + 1 < MAX_ATTEMPTS { std::thread::sleep(Duration::from_millis(10)); } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs index f7e12fd44..6c6f9fdbf 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs @@ -439,6 +439,26 @@ pub(super) fn ping_external_agent_runner( .map(|_| ()) } +pub(super) fn ping_external_agent_runner_with_timeout( + endpoint: &ExternalAgentRunnerEndpoint, + timeout: Duration, +) -> Result<(), String> { + if timeout.is_zero() { + return Err("Agent Runner ping 超时预算已耗尽".to_string()); + } + send_external_agent_runner_request_with_protocol_and_id_and_timeouts( + endpoint, + EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, + random_identifier(b"genarrative-agent-runner-start-ping-id")?, + "runner.ping", + ExternalAgentRunnerRequestParams::default(), + EXTERNAL_AGENT_RUNNER_CONNECT_TIMEOUT.min(timeout), + timeout, + timeout, + ) + .map(|_| ()) +} + pub(super) fn retire_incompatible_external_agent_runner( endpoint_path: &Path, endpoint: &ExternalAgentRunnerEndpoint, @@ -1251,10 +1271,15 @@ pub(super) fn wait_for_external_agent_runner( let endpoint_path = external_agent_runner_endpoint_path(config_dir); let deadline = Instant::now() + EXTERNAL_AGENT_RUNNER_START_TIMEOUT; loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Err("外部 Agent Runner 未在启动期限内就绪".to_string()); + } if let Some(endpoint) = read_current_external_agent_runner_endpoint(&endpoint_path, executable_fingerprint) { - if ping_external_agent_runner(&endpoint).is_ok() { + let ping_timeout = EXTERNAL_AGENT_RUNNER_IO_TIMEOUT.min(remaining); + if ping_external_agent_runner_with_timeout(&endpoint, ping_timeout).is_ok() { return Ok(endpoint); } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs index 2941e825d..ee56cb6e1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs @@ -282,6 +282,18 @@ fn runner_start_timeout_covers_cold_debug_binary_fingerprinting() { assert!(EXTERNAL_AGENT_RUNNER_START_TIMEOUT >= Duration::from_secs(30)); } +#[test] +fn startup_runner_ping_timeout_rejects_an_exhausted_budget() { + let endpoint = test_endpoint( + "startup-ping-timeout-token", + "startup-ping-timeout-boot", + 31_338, + ); + let error = ping_external_agent_runner_with_timeout(&endpoint, Duration::ZERO) + .expect_err("an exhausted startup budget must not attempt a runner ping"); + assert!(error.contains("超时预算已耗尽"), "{error}"); +} + #[test] fn forced_runner_drain_deadline_precedes_gui_hard_kill_deadline() { assert!( diff --git a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx index 290a06cc1..980cae00b 100644 --- a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx +++ b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx @@ -4,6 +4,7 @@ import { type FormEvent, type ReactNode, useEffect, + useRef, useState, } from 'react'; @@ -49,6 +50,36 @@ type ClientRuntimeErrorBoundaryState = { errorMessage: string; }; +type AuthCheckStage = 'token' | 'refresh' | 'me' | 'runner'; + +const AUTH_CHECK_STAGE_LABELS: Record = { + token: '读取本地登录凭据', + refresh: '刷新登录状态', + me: '确认当前用户', + runner: '连接本地运行时', +}; + +const AUTH_CHECK_REQUEST_TIMEOUT_MS = 15_000; +// Existing endpoint probes can consume the 10s IPC budget before Runner's +// 30s startup deadline. Keep the UI fence slightly above that worst case. +const AUTH_CHECK_RUNNER_TIMEOUT_MS = 45_000; + +function withAuthCheckTimeout( + promise: Promise, + timeoutMs: number, + message: string, +) { + let timeoutId: number | undefined; + const timeout = new Promise((_, reject) => { + timeoutId = window.setTimeout(() => reject(new Error(message)), timeoutMs); + }); + return Promise.race([promise, timeout]).finally(() => { + if (timeoutId !== undefined) { + window.clearTimeout(timeoutId); + } + }); +} + export class ClientRuntimeErrorBoundary extends Component< ClientRuntimeErrorBoundaryProps, ClientRuntimeErrorBoundaryState @@ -101,6 +132,11 @@ export function AuthenticatedClient({ 'checking' | 'authenticated' | 'unauthenticated' >('checking'); const [authUser, setAuthUser] = useState(null); + const [authCheckStage, setAuthCheckStage] = useState('token'); + const [authCheckElapsedSeconds, setAuthCheckElapsedSeconds] = useState(0); + const [authCheckError, setAuthCheckError] = useState(''); + const [authCheckRetryKey, setAuthCheckRetryKey] = useState(0); + const authCheckRunRef = useRef(0); const [loginMode, setLoginMode] = useState<'code' | 'password'>('code'); const [phone, setPhone] = useState(''); const [code, setCode] = useState(''); @@ -147,100 +183,162 @@ export function AuthenticatedClient({ useEffect(() => { let disposed = false; async function hydrateAuth() { + const runId = ++authCheckRunRef.current; + const isActiveRun = () => !disposed && authCheckRunRef.current === runId; const hydrationGeneration = currentPlatformSessionGeneration(); const hydrationApiBaseUrl = getClientServerBaseUrl(); + setAuthCheckError(''); + setAuthCheckStage('token'); try { if (!getStoredAuthAccessToken()) { - const refreshed = await refreshPlatformSessionForGeneration( - hydrationGeneration, - hydrationApiBaseUrl, + setAuthCheckStage('refresh'); + const refreshed = await withAuthCheckTimeout( + refreshPlatformSessionForGeneration( + hydrationGeneration, + hydrationApiBaseUrl, + ), + AUTH_CHECK_REQUEST_TIMEOUT_MS, + '刷新登录状态超时,请检查服务器地址和网络后重试', ); if (!refreshed) { + if (isActiveRun()) { + setAuthStatus('unauthenticated'); + } return; } } - const user = await getCurrentClientAuthUser(hydrationApiBaseUrl); - if (disposed) { + if (!isActiveRun()) return; + setAuthCheckStage('me'); + const user = await withAuthCheckTimeout( + getCurrentClientAuthUser(hydrationApiBaseUrl), + AUTH_CHECK_REQUEST_TIMEOUT_MS, + '读取当前用户超时,请检查服务器地址和网络后重试', + ); + if (!isActiveRun()) { return; } if (user) { - const committedGeneration = await commitAuthenticatedPlatformSession( - user, - hydrationGeneration, - hydrationApiBaseUrl, + setAuthCheckStage('runner'); + const committedGeneration = await withAuthCheckTimeout( + commitAuthenticatedPlatformSession( + user, + hydrationGeneration, + hydrationApiBaseUrl, + ), + AUTH_CHECK_RUNNER_TIMEOUT_MS, + '连接本地运行时超时,请重试或重启客户端', ); if (committedGeneration === null) { return; } - if (disposed) { + if (!isActiveRun()) { return; } setAuthUser(user); + setAuthCheckError(''); setAuthStatus('authenticated'); return; } clearStoredAuthAccessToken(); setAuthStatus('unauthenticated'); } catch (error) { - if (disposed) { + if (!isActiveRun()) { return; } if ( getStoredAuthAccessToken() && isClientAuthRecoverableCheckError(error) ) { - setLoginStatus( - getClientAuthErrorMessage(error, '登录服务暂时不可用,请稍后重试'), + const message = getClientAuthErrorMessage( + error, + '登录服务暂时不可用,请稍后重试', ); + setAuthCheckError(message); + setLoginStatus(message); setAuthStatus('unauthenticated'); return; } if (getStoredAuthAccessToken()) { try { - const refreshed = await refreshPlatformSessionForGeneration( - hydrationGeneration, - hydrationApiBaseUrl, + if (!isActiveRun()) return; + setAuthCheckStage('refresh'); + const refreshed = await withAuthCheckTimeout( + refreshPlatformSessionForGeneration( + hydrationGeneration, + hydrationApiBaseUrl, + ), + AUTH_CHECK_REQUEST_TIMEOUT_MS, + '刷新登录状态超时,请检查服务器地址和网络后重试', ); if (!refreshed) { + if (isActiveRun()) { + setAuthStatus('unauthenticated'); + } return; } - const user = await getCurrentClientAuthUser(hydrationApiBaseUrl); - if (disposed) { + if (!isActiveRun()) return; + setAuthCheckStage('me'); + const user = await withAuthCheckTimeout( + getCurrentClientAuthUser(hydrationApiBaseUrl), + AUTH_CHECK_REQUEST_TIMEOUT_MS, + '读取当前用户超时,请检查服务器地址和网络后重试', + ); + if (!isActiveRun()) { return; } if (user) { - const committedGeneration = - await commitAuthenticatedPlatformSession( + if (!isActiveRun()) return; + setAuthCheckStage('runner'); + const committedGeneration = await withAuthCheckTimeout( + commitAuthenticatedPlatformSession( user, hydrationGeneration, hydrationApiBaseUrl, - ); + ), + AUTH_CHECK_RUNNER_TIMEOUT_MS, + '连接本地运行时超时,请重试或重启客户端', + ); if (committedGeneration === null) { return; } - if (disposed) { + if (!isActiveRun()) { return; } + setAuthCheckError(''); setAuthUser(user); setAuthStatus('authenticated'); return; } } catch (retryError) { + if (!isActiveRun()) { + return; + } if ( getStoredAuthAccessToken() && isClientAuthRecoverableCheckError(retryError) ) { - setLoginStatus( - getClientAuthErrorMessage( - retryError, - '登录服务暂时不可用,请稍后重试', - ), + const message = getClientAuthErrorMessage( + retryError, + '登录服务暂时不可用,请稍后重试', ); + setAuthCheckError(message); + setLoginStatus(message); setAuthStatus('unauthenticated'); return; } } } + if (!isActiveRun()) { + return; + } + if (isClientAuthRecoverableCheckError(error)) { + const message = getClientAuthErrorMessage( + error, + '登录服务暂时不可用,请稍后重试', + ); + setAuthCheckError(message); + setLoginStatus(message); + } clearStoredAuthAccessToken(); setAuthStatus('unauthenticated'); } @@ -249,7 +347,18 @@ export function AuthenticatedClient({ return () => { disposed = true; }; - }, []); + }, [authCheckRetryKey]); + + useEffect(() => { + if (authStatus !== 'checking') { + return; + } + setAuthCheckElapsedSeconds(0); + const timer = window.setInterval(() => { + setAuthCheckElapsedSeconds((current) => current + 1); + }, 1000); + return () => window.clearInterval(timer); + }, [authStatus, authCheckRetryKey]); useEffect( () => @@ -358,6 +467,7 @@ export function AuthenticatedClient({ return; } setAuthUser(user); + setAuthCheckError(''); setAuthStatus('authenticated'); setCode(''); setPassword(''); @@ -383,6 +493,7 @@ export function AuthenticatedClient({ nativeClearError = error; } setAuthUser(null); + setAuthCheckError(''); setAuthStatus('unauthenticated'); setLoginStatus( nativeClearError @@ -392,6 +503,7 @@ export function AuthenticatedClient({ } if (authStatus === 'checking') { + const stageLabel = AUTH_CHECK_STAGE_LABELS[authCheckStage]; return (
陶泥儿

正在检查登录状态

+

+ {stageLabel} · 已等待 {authCheckElapsedSeconds} 秒 +

+

+ {authCheckElapsedSeconds >= 8 + ? '检查时间较长,请确认服务器可访问;若持续无响应可稍后重试' + : '正在恢复会话,请稍候'} +

); @@ -417,6 +537,23 @@ export function AuthenticatedClient({

登录陶泥儿 GameAgent

登录后进入首页和本地项目工作区

+ {authCheckError ? ( +
+

{authCheckError}

+ +
+ ) : null}