From 5eea689537ab5ab562160b027b96f4123430addf 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:19:29 +0800 Subject: [PATCH] =?UTF-8?q?=E7=94=9F=E6=88=90=20UI=20=E8=AE=BE=E8=AE=A1?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E5=B9=B6=E6=8E=A5=E5=85=A5=E7=BC=96=E8=BE=91?= =?UTF-8?q?=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, }, }; }