From 006d278e9fbd3071eaba0eb46079d9bee0bbff8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 23 Sep 2026 17:43:48 +0800 Subject: [PATCH] =?UTF-8?q?=E9=80=80=E5=BD=B9=20Agent=20=E4=BE=A7=20ui.wor?= =?UTF-8?q?kflow.run=20=E5=B7=A5=E4=BD=9C=E6=B5=81=E5=B7=A5=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除 ui_editor/workflow.rs 与 agent/runtime_tools/ui_workflow.rs 两个工作流实现模块 - agent_native_tools 去掉 ui.workflow.run 的工具描述与 schema 分支 - prompts/runtime/texts/native-tools.json 删除 ui.workflow.run.description 文案 - runtime_tools/policy、tool_policy_snapshot、parallel_ledger、project_gates 移除该工具的白名单与门禁引用 - action_execution 去掉 ui.workflow.run 分发分支,action_audit 删除整块工作流审计与枚举项 - runtime_protocol 的 verification 与 acceptance_graph 去掉工作流校验项 - tool_plan_handoff/content_validation 去掉 ui-design pages 校验分支 - ui_editor/mod.rs 去掉 workflow 模块声明 --- .../prompts/runtime/texts/native-tools.json | 1 - .../src/agent/runtime_actions/action_audit.rs | 156 -- .../agent/runtime_actions/action_execution.rs | 3 - .../agent/runtime_actions/parallel_ledger.rs | 1 - .../agent/runtime_actions/project_gates.rs | 53 - .../runtime_actions/tool_policy_snapshot.rs | 2 - .../runtime_protocol/acceptance_graph.rs | 1 - .../agent/runtime_protocol/verification.rs | 2 - .../src-tauri/src/agent/runtime_tools.rs | 2 - .../src/agent/runtime_tools/policy.rs | 1 - .../src/agent/runtime_tools/ui_workflow.rs | 79 - .../src-tauri/src/agent_native_tools.rs | 30 - .../tool_plan_handoff/content_validation.rs | 8 - .../src-tauri/src/ui_editor/mod.rs | 1 - .../src-tauri/src/ui_editor/workflow.rs | 1757 ----------------- 15 files changed, 2097 deletions(-) delete mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/ui_workflow.rs delete mode 100644 apps/ai-game-creator-shell/src-tauri/src/ui_editor/workflow.rs diff --git a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/native-tools.json b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/native-tools.json index 75bf4c9f8..ff83d7bfa 100644 --- a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/native-tools.json +++ b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/native-tools.json @@ -37,7 +37,6 @@ "preview.validate.description": "用真实浏览器验证桌面和移动预览并保存证据。", "image.inspect.description": "让视觉模型检查一至两张项目内图片。", "canvas.asset_generate.description": "通过已配置的 External Editor API 按项目需求生成图片或图集并登记到画布、素材库和项目 assets;可使用已登记资源作为参考。assetKind=icon-spritesheet 的 prompt 去除首尾空白后须为 1 到 200 个 Unicode 字符,保留内部换行并作为单条 iconDescriptions 原样提交,超限拒绝,不截断、不拆条,客户端不追加生图指令。assetKind=icon-spritesheet 时 sliceMode 必填且没有默认值:需求要求等分网格、固定槽位或指定行列数时用 grid 并提供来自需求本身的 gridX/gridY;自由排布、数量不定或只要求一张图集时用 connected-components,可用 sliceCount 约束素材张数;其它 assetKind 不得携带 sliceMode/gridX/gridY。", - "ui.workflow.run.description": "先用 discover 从受控 game/ui-pages.json 或页面声明标记自动发现全部功能页面,再把已登记 ui-design 与每个页面的设计图桥接成独立 UI JSON State;可同时载入已登记图片、图标和项目字体,执行 Provider 结构识别、多树合并与分批组件绑定、回读阶段,并且只有所有页面已绑定且已应用到 game/ 后才允许 finalize。项目根目录由 Runtime 注入,模型不得传入宿主路径。", "cocos.editor.execute.description": "在当前项目对应的已打开 Cocos Creator 编辑器中执行一段有界代码;Runtime 自动绑定唯一匹配的 Creator 主进程,代码与结果都通过注入 payload 的本机 bridge 返回。", "unity.editor.execute.description": "在当前 Unity 项目已打开的 Windows x64 Mono Editor 中执行 C#。仅提交 code,宿主绑定项目身份;结果待核对时禁止自动重发。", "godot.editor.execute.description": "在当前 Godot 项目已打开的 Windows x64 标准编辑器中执行支持 return/await 的 GDScript 函数体。执行载荷仅有 code,重新聚焦可触发首次加载;结果待核对时禁止自动重发。", diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_audit.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_audit.rs index 09f5db4bb..209dd94ec 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_audit.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_audit.rs @@ -676,161 +676,6 @@ fn agent_runtime_action_receipt_safe_detail_with_owner( "revisionAdvanceCount": value.get("revisionAdvanceCount").and_then(serde_json::Value::as_u64).unwrap_or(0), })).ok(); } - if observation.tool == "ui.workflow.run" { - let value = serde_json::from_str::( - observation.detail.as_deref().unwrap_or_default(), - ) - .ok()?; - let operation = value.get("operation")?.as_str()?; - let project_id = value.get("projectId")?.as_str()?; - let source_asset_id = value.get("sourceAssetId")?.as_str()?; - let completed = value.get("completed")?.as_bool()?; - let revision_advance_count = value.get("revisionAdvanceCount")?.as_u64()?; - if project_id.is_empty() - || source_asset_id.is_empty() - || !matches!( - operation, - "discover" | "prepare" | "recognize" | "status" | "finalize" - ) - || project_id.chars().any(char::is_control) - || source_asset_id.chars().any(char::is_control) - { - return None; - } - if operation == "discover" { - let discovered_pages = value.get("discoveredPages")?.as_array()?; - if discovered_pages.is_empty() || discovered_pages.len() > 32 { - return None; - } - let safe_pages = discovered_pages - .iter() - .map(|page| { - let page_id = page.get("pageId")?.as_str()?; - let title = page.get("title")?.as_str()?; - let description = page.get("description")?.as_str()?; - let application_path = page.get("applicationPath")?.as_str()?; - let required_design_asset_path = - page.get("requiredDesignAssetPath")?.as_str()?; - let discovered_from = page.get("discoveredFrom")?.as_str()?; - if page_id.is_empty() - || title.is_empty() - || description.chars().any(char::is_control) - || application_path.is_empty() - || !application_path.starts_with("game/") - || required_design_asset_path.is_empty() - || discovered_from.is_empty() - { - return None; - } - Some(serde_json::json!({ - "pageId": agent_runtime_action_receipt_safe_text(root, page_id, 80, None)?, - "title": agent_runtime_action_receipt_safe_text(root, title, 120, None)?, - "description": agent_runtime_action_receipt_safe_text(root, description, 400, None)?, - "applicationPath": agent_runtime_action_receipt_safe_text(root, application_path, 240, None)?, - "requiredDesignAssetPath": agent_runtime_action_receipt_safe_text(root, required_design_asset_path, 240, None)?, - "discoveredFrom": agent_runtime_action_receipt_safe_text(root, discovered_from, 240, None)?, - })) - }) - .collect::>>()?; - return serde_json::to_string(&serde_json::json!({ - "operation": operation, - "projectId": agent_runtime_action_receipt_safe_text(root, project_id, 160, None)?, - "sourceAssetId": agent_runtime_action_receipt_safe_text(root, source_asset_id, 160, None)?, - "completed": completed, - "revisionAdvanceCount": revision_advance_count, - "pages": [], - "discoveredPages": safe_pages, - "finalStageRoute": null, - })) - .ok(); - } - let pages = value.get("pages")?.as_array()?; - if pages.is_empty() || pages.len() > 32 { - return None; - } - let mut safe_pages = Vec::with_capacity(pages.len()); - for page in pages { - let page_id = page.get("pageId")?.as_str()?; - let title = page.get("title")?.as_str()?; - let design_asset_id = page.get("designAssetId")?.as_str()?; - let ui_asset_id = page.get("uiAssetId")?.as_str()?; - let revision = page.get("uiStateRevision")?.as_u64()?; - let stage = page.get("stage")?.as_str()?; - let marker = page.get("applicationMarker")?.as_str()?; - let blockers = page.get("blockers")?.as_array()?; - if page_id.is_empty() - || title.is_empty() - || design_asset_id.is_empty() - || ui_asset_id.is_empty() - || revision > 9_007_199_254_740_991 - || marker.is_empty() - || !matches!( - stage, - "reference-ready" - | "structure-ready" - | "binding-ready" - | "application-ready" - | "completed" - ) - || blockers.len() > 32 - { - return None; - } - let safe_blockers = blockers - .iter() - .map(|blocker| { - let blocker = blocker.as_str()?; - agent_runtime_action_receipt_safe_text(root, blocker, 240, None) - .map(serde_json::Value::String) - }) - .collect::>>()?; - safe_pages.push(serde_json::json!({ - "pageId": agent_runtime_action_receipt_safe_text(root, page_id, 80, None)?, - "title": agent_runtime_action_receipt_safe_text(root, title, 120, None)?, - "designAssetId": agent_runtime_action_receipt_safe_text(root, design_asset_id, 160, None)?, - "uiAssetId": agent_runtime_action_receipt_safe_text(root, ui_asset_id, 160, None)?, - "uiStateRevision": revision, - "stage": stage, - "blockers": safe_blockers, - "applicationMarker": agent_runtime_action_receipt_safe_text(root, marker, 240, None)?, - })); - } - let final_stage_route = if completed { - let route = value.get("finalStageRoute")?; - let resource_id = route.get("resourceId")?.as_str()?; - let initial_step = route.get("initialStep")?.as_str()?; - let render_mode = route.get("renderMode")?.as_str()?; - if resource_id.is_empty() - || initial_step != "asset-separation" - || render_mode != "final-preview" - { - return None; - } - Some(serde_json::json!({ - "resourceId": agent_runtime_action_receipt_safe_text(root, resource_id, 160, None)?, - "initialStep": initial_step, - "renderMode": render_mode, - })) - } else { - if !value - .get("finalStageRoute") - .is_some_and(serde_json::Value::is_null) - { - return None; - } - None - }; - return serde_json::to_string(&serde_json::json!({ - "operation": operation, - "projectId": agent_runtime_action_receipt_safe_text(root, project_id, 160, None)?, - "sourceAssetId": agent_runtime_action_receipt_safe_text(root, source_asset_id, 160, None)?, - "completed": completed, - "revisionAdvanceCount": revision_advance_count, - "pages": safe_pages, - "finalStageRoute": final_stage_route, - })) - .ok(); - } if observation.tool != "project.patchset" { return None; } @@ -1209,7 +1054,6 @@ pub(in crate::agent) fn agent_runtime_public_action_input_summary( | "preview.validate" | "image.inspect" | "canvas.asset_generate" - | "ui.workflow.run" | "agent.message" | "agent.delegate" | "agent.schedule_ready" diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs index 72a0cc3fd..fe56125ba 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs @@ -436,9 +436,6 @@ pub(crate) async fn execute_game_creator_agent_runtime_tool_action_with_pending_ ) .await } - "ui.workflow.run" => { - observe_agent_runtime_ui_workflow(root, agent_id, run_id, task, &action.input).await - } "blackboard.write" => { observe_agent_runtime_blackboard_write(root, agent_id, run_id, &action.input) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/parallel_ledger.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/parallel_ledger.rs index a1a2684be..1bf44ebf5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/parallel_ledger.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/parallel_ledger.rs @@ -103,7 +103,6 @@ pub(in crate::agent) fn game_creator_agent_runtime_tool_command_id( "preview.validate" => Some("preview.validate"), "image.inspect" => Some("image.inspect"), "canvas.asset_generate" => Some("canvas.asset_generate"), - "ui.workflow.run" => Some("asset.register"), "blackboard.write" => Some("memory.write"), "agent.message" => Some("conversation.write"), "agent.delegate" => Some("agent.delegate"), diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs index 579e6703b..3fb7e042e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs @@ -649,9 +649,6 @@ pub(crate) fn is_agent_runtime_project_mutation_observation( if observation.tool == "project.patchset" { return agent_runtime_patchset_advanced_project_revision(observation); } - if observation.tool == "ui.workflow.run" { - return agent_runtime_ui_workflow_observation_advances_project_revision(observation); - } if observation.tool == "canvas.asset_import" { return observation .detail @@ -669,7 +666,6 @@ pub(crate) fn is_agent_runtime_project_mutation_observation( | "project.patchset" | "project.restore" | "canvas.asset_generate" - | "ui.workflow.run" ) } @@ -761,47 +757,14 @@ pub(crate) fn agent_runtime_observation_advances_project_revision( .and_then(|detail| serde_json::from_str::(detail).ok()) .and_then(|value| value.get("revisionAdvanceCount")?.as_u64()) .is_some_and(|count| count > 0), - "ui.workflow.run" => { - agent_runtime_ui_workflow_observation_advances_project_revision(observation) - } "memory.write" => true, _ => false, } } -fn agent_runtime_ui_workflow_observation_advances_project_revision( - observation: &AgentRuntimeToolObservation, -) -> bool { - observation.tool == "ui.workflow.run" - && observation.status == "ok" - && observation - .detail - .as_deref() - .and_then(|detail| serde_json::from_str::(detail).ok()) - .and_then(|value| { - value - .get("revisionAdvanceCount") - .and_then(serde_json::Value::as_u64) - .map(|count| count > 0) - }) - .unwrap_or(false) -} - fn agent_runtime_observation_project_revision_advance_count( observation: &AgentRuntimeToolObservation, ) -> u64 { - if observation.tool == "ui.workflow.run" && observation.status == "ok" { - return observation - .detail - .as_deref() - .and_then(|detail| serde_json::from_str::(detail).ok()) - .and_then(|value| { - value - .get("revisionAdvanceCount") - .and_then(serde_json::Value::as_u64) - }) - .unwrap_or(0); - } if let Some(count) = observation .detail .as_deref() @@ -821,19 +784,6 @@ fn agent_runtime_observation_project_revision_advance_count( } } -fn is_agent_runtime_ui_workflow_completed_observation( - observation: &AgentRuntimeToolObservation, -) -> bool { - observation.tool == "ui.workflow.run" - && observation.status == "ok" - && observation - .detail - .as_deref() - .and_then(|detail| serde_json::from_str::(detail).ok()) - .and_then(|value| value.get("completed").and_then(serde_json::Value::as_bool)) - .unwrap_or(false) -} - pub(in crate::agent) fn is_agent_runtime_static_smoke_observation( observation: &AgentRuntimeToolObservation, ) -> bool { @@ -855,7 +805,6 @@ pub(in crate::agent) fn is_agent_runtime_project_verification_observation( || (observation.tool == "command.exec" && agent_runtime_command_exec_is_verification_eligible(observation)) || (observation.tool == "canvas.asset_generate" && observation.status == "ok") - || is_agent_runtime_ui_workflow_completed_observation(observation) || is_agent_runtime_static_smoke_observation(observation) } @@ -868,8 +817,6 @@ pub(in crate::agent) fn agent_runtime_project_verification_label( "command.exec" } else if observation.tool == "canvas.asset_generate" { "canvas.asset_generate" - } else if observation.tool == "ui.workflow.run" { - "ui.workflow.run" } else { "project.verify" } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs index b90402f76..19ef93917 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs @@ -59,7 +59,6 @@ pub(crate) fn agent_runtime_executable_tools() -> Vec<&'static str> { "preview.validate", "image.inspect", "canvas.asset_generate", - "ui.workflow.run", "blackboard.write", "agent.message", "agent.delegate", @@ -110,7 +109,6 @@ pub(crate) fn agent_runtime_acceptance_evidence_tools() -> BTreeSet<&'static str "image.inspect", "canvas.asset_generate", "canvas.asset_import", - "ui.workflow.run", ] .into_iter() .collect() diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/acceptance_graph.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/acceptance_graph.rs index 085d69d5f..122466ce5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/acceptance_graph.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/acceptance_graph.rs @@ -137,7 +137,6 @@ fn acceptance_evidence_tool_may_advance_project_revision(tool: &str) -> bool { | "project.patchset" | "canvas.asset_import" | "canvas.asset_generate" - | "ui.workflow.run" ) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/verification.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/verification.rs index f51703901..0143a98a9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/verification.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/verification.rs @@ -161,7 +161,6 @@ pub(in crate::agent) fn validate_agent_runtime_verification_gate( | "command.start" | "canvas.asset_generate" | "canvas.asset_import" - | "ui.workflow.run" ) }) { return Err("Agent Runtime verification gate 的修改工具无效".to_string()); @@ -175,7 +174,6 @@ pub(in crate::agent) fn validate_agent_runtime_verification_gate( | "command.exec" | "preview.validate" | "canvas.asset_generate" - | "ui.workflow.run" ) }) { return Err("Agent Runtime verification gate 的验证工具无效".to_string()); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs index 659c9acb2..42ba83c59 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs @@ -19,7 +19,6 @@ mod process_ops; mod project_ops; mod run_status; mod task_ops; -mod ui_workflow; pub(in crate::agent) use action_history::*; pub(in crate::agent) use cocos_editor::*; @@ -39,7 +38,6 @@ pub(in crate::agent) use process_ops::*; pub(in crate::agent) use project_ops::*; pub(in crate::agent) use run_status::*; pub(in crate::agent) use task_ops::*; -pub(in crate::agent) use ui_workflow::*; #[cfg(test)] pub(crate) use delegation::observe_agent_runtime_agent_delegate_at_locked; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/policy.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/policy.rs index 4d174de7d..c4d33154b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/policy.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/policy.rs @@ -48,7 +48,6 @@ fn autonomous_design_foundation_command_is_allowed(command_id: &str) -> bool { | "canvas.asset_generate" | "canvas.asset_import" | "asset.register" - | "ui.workflow.run" | "agent.audit" | "agent.action_history" | "agent.run_status" diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/ui_workflow.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/ui_workflow.rs deleted file mode 100644 index a9824df2c..000000000 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/ui_workflow.rs +++ /dev/null @@ -1,79 +0,0 @@ -use super::*; -use crate::ui_editor::workflow::{run_ui_workflow_at_with_provider, UiWorkflowRunInput}; -use serde_json::Value; - -pub(in crate::agent) async fn observe_agent_runtime_ui_workflow( - root: &Path, - agent_id: &str, - run_id: &str, - _task: &str, - input: &Value, -) -> AgentRuntimeToolObservation { - let parsed = match serde_json::from_value::(input.clone()) { - Ok(parsed) => parsed, - Err(error) => { - return AgentRuntimeToolObservation { - tool: "ui.workflow.run".to_string(), - status: "rejected".to_string(), - summary: format!("ui.workflow.run 输入无效:{error}"), - detail: None, - }; - } - }; - let operation = parsed.operation; - let revision_before = read_game_creator_agent_runtime_project_revision(root) - .ok() - .map(|snapshot| snapshot.revision); - match run_ui_workflow_at_with_provider(root, parsed, Some((agent_id, run_id))).await { - Ok(result) => { - let completed = result.completed; - let page_count = result.pages.len().max(result.discovered_pages.len()); - if result.revision_advance_count > 0 { - // Runtime actions can update manifest/State several times inside a - // single provider turn; publish the invalidation immediately so - // the workbench refreshes intermediate artifacts before finalize. - emit_game_creator_manifest_invalidated(root, "ui-workflow"); - } - let detail = serde_json::to_string(&result).ok(); - AgentRuntimeToolObservation { - tool: "ui.workflow.run".to_string(), - status: "ok".to_string(), - summary: if operation == crate::ui_editor::workflow::UiWorkflowOperation::Discover { - format!("UI workflow 自动发现 {page_count} 个功能页面") - } else if completed { - format!("UI workflow 已完成 {page_count} 个页面并生成最终编辑阶段路由") - } else { - format!("UI workflow 已更新 {page_count} 个页面的持久阶段状态") - }, - detail, - } - } - Err(error) => { - // Recognition can durably install a real structure before a later - // provider-backed binding step fails. The client still needs that - // intermediate State/manifest update even though this operation - // truthfully reports an error. - let revision_advanced = revision_before.is_some_and(|before| { - read_game_creator_agent_runtime_project_revision(root) - .ok() - .is_some_and(|after| after.revision > before) - }); - if revision_advanced { - emit_game_creator_manifest_invalidated(root, "ui-workflow"); - } - AgentRuntimeToolObservation { - tool: "ui.workflow.run".to_string(), - status: if operation == crate::ui_editor::workflow::UiWorkflowOperation::Finalize - || error.contains("拒绝伪造完成") - { - "rejected" - } else { - "error" - } - .to_string(), - summary: redact_agent_runtime_project_paths(root, &error, 500), - detail: None, - } - } - } -} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs index 421d5af05..407bf2eff 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 @@ -1065,9 +1065,6 @@ fn runtime_tool_description(tool: &str) -> &'static str { "canvas.asset_generate" => { prompt_text!("nativeTools.canvas.asset_generate.description") } - "ui.workflow.run" => { - prompt_text!("nativeTools.ui.workflow.run.description") - } "cocos.editor.execute" => { prompt_text!("nativeTools.cocos.editor.execute.description") } @@ -1357,33 +1354,6 @@ fn runtime_tool_input_schema(tool: &str) -> Value { } }) } - "ui.workflow.run" => json!({ - "type": "object", - "required": ["operation", "sourceAssetId", "pages"], - "additionalProperties": false, - "properties": { - "operation": { "type": "string", "enum": ["discover", "prepare", "recognize", "status", "finalize"] }, - "sourceAssetId": { "type": "string", "minLength": 1, "maxLength": 160 }, - "pages": { - "type": "array", - "maxItems": 32, - "items": { - "type": "object", - "required": ["pageId", "title", "description", "designAssetId", "spriteAssetIds", "fontAssetIds", "applicationPath"], - "additionalProperties": false, - "properties": { - "pageId": { "type": "string", "minLength": 1, "maxLength": 80, "pattern": "^[A-Za-z0-9._-]+$" }, - "title": { "type": "string", "minLength": 1, "maxLength": 120 }, - "description": { "type": "string", "maxLength": 400 }, - "designAssetId": { "type": "string", "minLength": 1, "maxLength": 160 }, - "spriteAssetIds": { "type": "array", "maxItems": 32, "items": { "type": "string", "minLength": 1, "maxLength": 160 } }, - "fontAssetIds": { "type": "array", "maxItems": 16, "items": { "type": "string", "minLength": 1, "maxLength": 160 } }, - "applicationPath": { "type": ["string", "null"], "maxLength": 240 } - } - } - } - } - }), "blackboard.write" => two_string_input_schema("title", "content"), "agent.message" => two_string_input_schema("agentId", "content"), "agent.delegate" => json!({ diff --git a/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/content_validation.rs b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/content_validation.rs index e532e76b7..e394b920e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/content_validation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/content_validation.rs @@ -423,14 +423,6 @@ fn collect_native_tool_absolute_path_findings( findings, ); } - "ui.workflow.run" => { - if let Some(pages) = input.get("pages").and_then(serde_json::Value::as_array) { - for (index, page) in pages.iter().enumerate() { - let pointer = format!("{input_pointer}/pages/{index}/applicationPath"); - collect_native_string_field(root, page, "applicationPath", &pointer, findings); - } - } - } "task.create" => { collect_native_string_array_field( root, 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 51fb28ec3..96a8f66a1 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 @@ -7,4 +7,3 @@ pub mod resource; pub mod resource_bridge; pub mod state; mod utils; -pub(crate) mod workflow; diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/workflow.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/workflow.rs deleted file mode 100644 index 0c71cce22..000000000 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/workflow.rs +++ /dev/null @@ -1,1757 +0,0 @@ -use crate::ui_editor::commands::binding::BindingChange; -use crate::ui_editor::commands::{ - bind_components_impl_with_provider, merge_ui_impl_with_provider, - recognize_ui_impl_with_provider, -}; -use crate::ui_editor::layout::node::{Node, StageStatus}; -use crate::ui_editor::persistence::{ - initialize_ui_design_state_at, load_ui_design_state_at, save_ui_design_state_at, - LoadUiDesignStateInput, SaveUiDesignStateInput, SaveUiDesignStateResult, - UI_DESIGN_DOC_ASSET_KIND, UI_DESIGN_DOC_MEDIA_TYPE, -}; -use crate::ui_editor::resource::font::FontAsset; -use crate::ui_editor::resource::sprite::{SpriteAsset, SpriteAssetMetadata, SpriteBorder}; -use crate::ui_editor::resource::ui_design_image::UIDesignImage; -use crate::ui_editor::utils::{SpriteAssetId, UIDesignImageId}; -use crate::*; -use image::GenericImageView as _; -use nalgebra::Vector2; -use serde::{Deserialize, Serialize}; -use sha2::{Digest as _, Sha256}; -use shared_contracts::game_creation_app::GameCreationAppAssetKind; -use std::collections::{HashMap, HashSet}; -use std::fs; -use std::path::Path; -use typed_floats::tf32::StrictlyPositiveFinite; - -const UI_WORKFLOW_RECEIPT_SCHEMA_VERSION: &str = "game-creator-ui-workflow-receipt.v1"; -const UI_WORKFLOW_MAX_PAGES: usize = 32; -const UI_WORKFLOW_MAX_IMAGE_BYTES: u64 = 16 * 1024 * 1024; -const UI_WORKFLOW_MAX_APPLICATION_BYTES: u64 = 4 * 1024 * 1024; -const UI_WORKFLOW_PAGE_REGISTRY_PATH: &str = "game/ui-pages.json"; -const UI_WORKFLOW_PAGE_MARKER: &str = "@genarrative-ui-page "; -const UI_WORKFLOW_PAGE_SCAN_FILES: &[&str] = &[ - "game/game_design.md", - "game/index.html", - "game/game.js", - "game/style.css", -]; -const UI_WORKFLOW_MAX_PAGE_REGISTRY_BYTES: u64 = 256 * 1024; -const UI_WORKFLOW_MAX_PAGE_SCAN_BYTES: u64 = 4 * 1024 * 1024; - -#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "kebab-case")] -pub(crate) enum UiWorkflowOperation { - Discover, - Prepare, - Recognize, - Status, - Finalize, -} - -#[derive(Clone, Debug, Deserialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -pub(crate) struct UiWorkflowPageInput { - pub(crate) page_id: String, - pub(crate) title: String, - pub(crate) description: String, - pub(crate) design_asset_id: String, - #[serde(default)] - pub(crate) sprite_asset_ids: Vec, - #[serde(default)] - pub(crate) font_asset_ids: Vec, - pub(crate) application_path: Option, -} - -#[derive(Clone, Debug, Deserialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -pub(crate) struct UiWorkflowRunInput { - pub(crate) operation: UiWorkflowOperation, - pub(crate) source_asset_id: String, - #[serde(default)] - pub(crate) pages: Vec, -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct UiWorkflowDiscoveredPage { - pub(crate) page_id: String, - pub(crate) title: String, - pub(crate) description: String, - pub(crate) application_path: String, - pub(crate) required_design_asset_path: String, - pub(crate) discovered_from: String, -} - -#[derive(Clone, Debug, Deserialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -struct UiWorkflowPageDeclaration { - page_id: String, - title: String, - description: String, - application_path: String, -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize)] -#[serde(rename_all = "kebab-case")] -pub(crate) enum UiWorkflowPageStage { - ReferenceReady, - StructureReady, - BindingReady, - ApplicationReady, - Completed, -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct UiWorkflowPageStatus { - pub(crate) page_id: String, - pub(crate) title: String, - pub(crate) design_asset_id: String, - pub(crate) ui_asset_id: String, - pub(crate) ui_state_revision: u64, - pub(crate) stage: UiWorkflowPageStage, - pub(crate) blockers: Vec, - pub(crate) application_marker: String, -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct UiWorkflowRunResult { - pub(crate) operation: UiWorkflowOperation, - pub(crate) source_asset_id: String, - pub(crate) project_id: String, - pub(crate) completed: bool, - pub(crate) revision_advance_count: u64, - pub(crate) pages: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub(crate) discovered_pages: Vec, - pub(crate) final_stage_route: Option, -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct UiWorkflowFinalStageRoute { - pub(crate) resource_id: String, - pub(crate) initial_step: String, - pub(crate) render_mode: String, -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -struct UiWorkflowReceipt { - schema_version: String, - project_id: String, - source_asset_id: String, - pages: Vec, - final_stage_route: UiWorkflowFinalStageRoute, -} - -struct ResolvedWorkflowPage { - input: UiWorkflowPageInput, - design_asset: GameCreationAppAssetManifestEntry, - ui_asset: GameCreationAppAssetManifestEntry, -} - -pub(crate) async fn run_ui_workflow_at( - root: &Path, - input: UiWorkflowRunInput, -) -> Result { - run_ui_workflow_at_with_provider(root, input, None).await -} - -pub(crate) async fn run_ui_workflow_at_with_provider( - root: &Path, - input: UiWorkflowRunInput, - provider_identity: Option<(&str, &str)>, -) -> Result { - validate_project_root(root)?; - validate_workflow_input(&input)?; - if !matches!( - input.operation, - UiWorkflowOperation::Status | UiWorkflowOperation::Discover - ) { - enforce_project_permission_policy(root, "asset.register")?; - } - let revision_before = read_game_creator_agent_runtime_project_revision(root)?.revision; - let manifest = read_existing_manifest_for_project(root)?; - let source = manifest - .assets - .iter() - .find(|asset| asset.id == input.source_asset_id) - .cloned() - .ok_or_else(|| "ui.workflow.run sourceAssetId 未登记".to_string())?; - validate_source_asset(root, &source)?; - - if input.operation == UiWorkflowOperation::Discover { - return Ok(UiWorkflowRunResult { - operation: input.operation, - source_asset_id: source.id, - project_id: manifest.project_id, - completed: false, - revision_advance_count: 0, - pages: Vec::new(), - discovered_pages: discover_ui_pages(root)?, - final_stage_route: None, - }); - } - - let mut resolved = Vec::with_capacity(input.pages.len()); - for page in input.pages.clone() { - let current_manifest = read_existing_manifest_for_project(root)?; - let design_asset = current_manifest - .assets - .iter() - .find(|asset| asset.id == page.design_asset_id) - .cloned() - .ok_or_else(|| format!("页面 {} 的 designAssetId 未登记", page.page_id))?; - validate_design_asset(root, &design_asset)?; - let sprite_assets = resolve_page_assets( - root, - ¤t_manifest, - &page.page_id, - &page.sprite_asset_ids, - "spriteAssetIds", - validate_sprite_asset, - )?; - let font_assets = resolve_page_assets( - root, - ¤t_manifest, - &page.page_id, - &page.font_asset_ids, - "fontAssetIds", - validate_font_asset, - )?; - let ui_asset = match input.operation { - UiWorkflowOperation::Prepare | UiWorkflowOperation::Recognize => { - ensure_page_ui_resource( - root, - ¤t_manifest.project_id, - &source, - &page, - &design_asset, - &sprite_assets, - &font_assets, - )? - } - UiWorkflowOperation::Status | UiWorkflowOperation::Finalize => find_page_ui_resource( - ¤t_manifest, - &source, - &page, - &design_asset, - &sprite_assets, - &font_assets, - )? - .ok_or_else(|| format!("页面 {} 尚未准备 UI JSON 资源", page.page_id))?, - UiWorkflowOperation::Discover => unreachable!("discover 在页面解析前已返回"), - }; - resolved.push(ResolvedWorkflowPage { - input: page, - design_asset, - ui_asset, - }); - } - - if input.operation == UiWorkflowOperation::Recognize { - for page in &resolved { - // The workflow must use the same provider-backed recognition and - // binding commands as the editor. There is intentionally no - // deterministic fallback here: a missing provider or malformed - // response is returned to the caller and leaves the durable stage - // at reference-ready/structure-ready rather than claiming UI - // semantics were recognized. - recognize_page_semantics(root, &manifest.project_id, page, provider_identity).await?; - } - } - if input.operation == UiWorkflowOperation::Finalize { - for page in &resolved { - apply_application_marker( - root, - &manifest.project_id, - page.input.application_path.as_deref(), - &page.input.page_id, - &page.ui_asset.id, - )?; - update_page_manifest_stage(root, &page.ui_asset.id, "application-ready")?; - } - } - - let finalized = input.operation == UiWorkflowOperation::Finalize; - let statuses = resolved - .iter() - .map(|page| derive_page_status(root, &manifest.project_id, page, finalized)) - .collect::, _>>()?; - let completed = statuses - .iter() - .all(|status| status.stage == UiWorkflowPageStage::Completed); - if input.operation == UiWorkflowOperation::Prepare { - for page in &resolved { - update_page_manifest_stage(root, &page.ui_asset.id, "reference-ready")?; - } - } - let final_stage_route = if completed - && matches!( - input.operation, - UiWorkflowOperation::Finalize | UiWorkflowOperation::Status - ) { - let route = UiWorkflowFinalStageRoute { - resource_id: statuses[0].ui_asset_id.clone(), - initial_step: "asset-separation".to_string(), - render_mode: "final-preview".to_string(), - }; - if finalized { - for page in &resolved { - update_page_manifest_stage(root, &page.ui_asset.id, "completed")?; - } - write_final_receipt(root, &manifest.project_id, &source.id, &statuses, &route)?; - } - Some(route) - } else { - if finalized { - return Err(format!( - "ui.workflow.run 拒绝伪造完成:{}", - statuses - .iter() - .flat_map(|status| status - .blockers - .iter() - .map(move |blocker| { format!("{}: {blocker}", status.page_id) })) - .collect::>() - .join(";") - )); - } - None - }; - let revision_after = read_game_creator_agent_runtime_project_revision(root)?.revision; - Ok(UiWorkflowRunResult { - operation: input.operation, - source_asset_id: source.id, - project_id: manifest.project_id, - completed, - revision_advance_count: revision_after.saturating_sub(revision_before), - pages: statuses, - discovered_pages: Vec::new(), - final_stage_route, - }) -} - -fn validate_workflow_input(input: &UiWorkflowRunInput) -> Result<(), String> { - if input.source_asset_id.trim().is_empty() - || input.source_asset_id.len() > 160 - || input.source_asset_id.chars().any(char::is_control) - { - return Err("ui.workflow.run sourceAssetId 无效".to_string()); - } - if input.pages.len() > UI_WORKFLOW_MAX_PAGES - || (input.operation != UiWorkflowOperation::Discover && input.pages.is_empty()) - { - return Err(format!( - "ui.workflow.run pages 必须包含 1 至 {UI_WORKFLOW_MAX_PAGES} 页" - )); - } - let mut page_ids = HashSet::new(); - let mut design_ids = HashSet::new(); - for page in &input.pages { - let valid_page_id = !page.page_id.is_empty() - && page.page_id.len() <= 80 - && page - .page_id - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')); - if !valid_page_id || !page_ids.insert(page.page_id.clone()) { - return Err("ui.workflow.run pageId 无效或重复".to_string()); - } - if page.title.trim().is_empty() - || page.title.chars().count() > 120 - || page.description.chars().count() > 400 - || page.title.chars().any(char::is_control) - || page.description.chars().any(char::is_control) - { - return Err(format!("页面 {} 的标题或描述无效", page.page_id)); - } - if page.design_asset_id.trim().is_empty() - || page.design_asset_id.len() > 160 - || !design_ids.insert(page.design_asset_id.clone()) - { - return Err("ui.workflow.run 每页必须关联唯一 designAssetId".to_string()); - } - if page - .application_path - .as_deref() - .is_some_and(|path| path.len() > 240 || path.chars().any(char::is_control)) - { - return Err(format!("页面 {} 的 applicationPath 无效", page.page_id)); - } - if page.sprite_asset_ids.len() > 32 || page.font_asset_ids.len() > 16 { - return Err(format!( - "页面 {} 的 UI 图片/图标或字体资源数量超限", - page.page_id - )); - } - } - Ok(()) -} - -fn discover_ui_pages(root: &Path) -> Result, String> { - let mut declarations = Vec::<(UiWorkflowPageDeclaration, String)>::new(); - let registry_path = resolve_local_project_path(root, UI_WORKFLOW_PAGE_REGISTRY_PATH)?; - if registry_path.exists() { - let metadata = fs::symlink_metadata(®istry_path) - .map_err(|error| format!("读取 UI 页面注册表失败:{error}"))?; - if metadata.file_type().is_symlink() || !metadata.is_file() { - return Err(format!( - "UI 页面注册表必须是普通文件:{UI_WORKFLOW_PAGE_REGISTRY_PATH}" - )); - } - if metadata.len() > UI_WORKFLOW_MAX_PAGE_REGISTRY_BYTES { - return Err(format!( - "UI 页面注册表超过 {} KiB", - UI_WORKFLOW_MAX_PAGE_REGISTRY_BYTES / 1024 - )); - } - let bytes = - fs::read(®istry_path).map_err(|error| format!("读取 UI 页面注册表失败:{error}"))?; - let entries = serde_json::from_slice::>(&bytes) - .map_err(|error| format!("解析 UI 页面注册表失败:{error}"))?; - declarations.extend( - entries - .into_iter() - .map(|entry| (entry, UI_WORKFLOW_PAGE_REGISTRY_PATH.to_string())), - ); - } - - for relative in UI_WORKFLOW_PAGE_SCAN_FILES { - let path = resolve_local_project_path(root, relative)?; - if !path.exists() { - continue; - } - let metadata = fs::symlink_metadata(&path) - .map_err(|error| format!("读取 UI 页面声明文件失败:{relative}: {error}"))?; - if metadata.file_type().is_symlink() { - return Err(format!("UI 页面声明文件不得是符号链接:{relative}")); - } - if !metadata.is_file() { - continue; - } - if metadata.len() > UI_WORKFLOW_MAX_PAGE_SCAN_BYTES { - return Err(format!("UI 页面声明文件超过 4 MiB:{relative}")); - } - let content = fs::read_to_string(&path) - .map_err(|_| format!("UI 页面声明文件必须是 UTF-8 文本:{relative}"))?; - for (line_number, line) in content.lines().enumerate() { - let Some(marker_offset) = line.find(UI_WORKFLOW_PAGE_MARKER) else { - continue; - }; - let json = line[marker_offset + UI_WORKFLOW_PAGE_MARKER.len()..] - .trim() - .strip_suffix("-->") - .or_else(|| { - line[marker_offset + UI_WORKFLOW_PAGE_MARKER.len()..] - .trim() - .strip_suffix("*/") - }) - .map(str::trim) - .unwrap_or_else(|| line[marker_offset + UI_WORKFLOW_PAGE_MARKER.len()..].trim()); - if json.is_empty() { - return Err(format!( - "UI 页面声明缺少 JSON:{relative}:{}", - line_number + 1 - )); - } - let declaration = - serde_json::from_str::(json).map_err(|error| { - format!( - "解析 UI 页面声明失败:{relative}:{}: {error}", - line_number + 1 - ) - })?; - declarations.push((declaration, format!("{relative}:{}", line_number + 1))); - } - } - - if declarations.is_empty() { - return Err(format!( - "未发现 UI 页面声明:请创建 {UI_WORKFLOW_PAGE_REGISTRY_PATH},或在 game/game_design.md、game/index.html、game/game.js、game/style.css 中添加 {UI_WORKFLOW_PAGE_MARKER}" - )); - } - if declarations.len() > UI_WORKFLOW_MAX_PAGES { - return Err(format!("UI 页面声明超过 {UI_WORKFLOW_MAX_PAGES} 页")); - } - - let mut page_ids = HashSet::new(); - let mut pages = declarations - .into_iter() - .map(|(declaration, discovered_from)| { - validate_discovered_page(&declaration)?; - if !page_ids.insert(declaration.page_id.clone()) { - return Err(format!("UI 页面声明 pageId 重复:{}", declaration.page_id)); - } - Ok(UiWorkflowDiscoveredPage { - required_design_asset_path: format!("assets/ui-pages/{}.png", declaration.page_id), - page_id: declaration.page_id, - title: declaration.title, - description: declaration.description, - application_path: declaration.application_path, - discovered_from, - }) - }) - .collect::, String>>()?; - pages.sort_by(|left, right| left.page_id.cmp(&right.page_id)); - Ok(pages) -} - -fn validate_discovered_page(page: &UiWorkflowPageDeclaration) -> Result<(), String> { - let valid_page_id = !page.page_id.is_empty() - && page.page_id.len() <= 80 - && page - .page_id - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')); - if !valid_page_id { - return Err(format!("UI 页面声明 pageId 无效:{}", page.page_id)); - } - if page.title.trim().is_empty() - || page.title.chars().count() > 120 - || page.description.chars().count() > 400 - || page.title.chars().any(char::is_control) - || page.description.chars().any(char::is_control) - { - return Err(format!("UI 页面 {} 的标题或描述无效", page.page_id)); - } - let normalized_path = normalize_relative_path(&page.application_path) - .map_err(|error| format!("UI 页面 {} 的 applicationPath 无效:{error}", page.page_id))?; - if !normalized_path.starts_with("game/") { - return Err(format!( - "UI 页面 {} 的 applicationPath 必须位于 game/", - page.page_id - )); - } - Ok(()) -} - -fn validate_source_asset( - root: &Path, - asset: &GameCreationAppAssetManifestEntry, -) -> Result<(), String> { - if asset.kind != GameCreationAppAssetKind::UiDesign || !asset.media_type.starts_with("image/") { - return Err("ui.workflow.run sourceAssetId 必须是已登记的 ui-design 图片".to_string()); - } - validate_image_asset_file(root, asset, "UI 原型图") -} - -fn validate_image_asset_file( - root: &Path, - asset: &GameCreationAppAssetManifestEntry, - label: &str, -) -> Result<(), String> { - let path = resolve_local_project_path(root, &asset.local_path)?; - let metadata = fs::symlink_metadata(&path) - .map_err(|error| format!("读取{label}失败:{}: {error}", asset.local_path))?; - if metadata.file_type().is_symlink() || !metadata.is_file() || metadata.len() == 0 { - return Err(format!("{label}不是非空普通文件:{}", asset.local_path)); - } - if metadata.len() > UI_WORKFLOW_MAX_IMAGE_BYTES { - return Err(format!( - "{label}超过 {} MiB", - UI_WORKFLOW_MAX_IMAGE_BYTES / 1024 / 1024 - )); - } - image::open(&path).map_err(|_| format!("{label}无法解码:{}", asset.local_path))?; - Ok(()) -} - -fn validate_design_asset( - root: &Path, - asset: &GameCreationAppAssetManifestEntry, -) -> Result<(), String> { - if !asset.media_type.starts_with("image/") { - return Err(format!("页面设计资源 {} 不是图片", asset.id)); - } - validate_image_asset_file(root, asset, "页面设计图") -} - -fn resolve_page_assets( - root: &Path, - manifest: &GameCreationAppManifest, - page_id: &str, - asset_ids: &[String], - field: &str, - validate: fn(&Path, &GameCreationAppAssetManifestEntry) -> Result<(), String>, -) -> Result, String> { - let mut seen = HashSet::new(); - let mut resolved = Vec::with_capacity(asset_ids.len()); - for asset_id in asset_ids { - if asset_id.trim().is_empty() - || asset_id.len() > 160 - || asset_id.chars().any(char::is_control) - || !seen.insert(asset_id.as_str()) - { - return Err(format!("页面 {page_id} 的 {field} 包含无效或重复资源 ID")); - } - let asset = manifest - .assets - .iter() - .find(|asset| asset.id == *asset_id) - .cloned() - .ok_or_else(|| format!("页面 {page_id} 的 {field} 资源 {asset_id} 未登记"))?; - validate(root, &asset)?; - resolved.push(asset); - } - Ok(resolved) -} - -fn validate_sprite_asset( - root: &Path, - asset: &GameCreationAppAssetManifestEntry, -) -> Result<(), String> { - if !asset.media_type.starts_with("image/") { - return Err(format!("UI 独立图片/图标资源 {} 不是图片", asset.id)); - } - validate_image_asset_file(root, asset, "UI 独立图片/图标") -} - -fn validate_font_asset( - root: &Path, - asset: &GameCreationAppAssetManifestEntry, -) -> Result<(), String> { - if !asset.media_type.starts_with("font/") { - return Err(format!("UI 字体资源 {} 不是字体", asset.id)); - } - let path = resolve_local_project_path(root, &asset.local_path)?; - let metadata = fs::symlink_metadata(&path) - .map_err(|error| format!("读取 UI 字体失败:{}: {error}", asset.local_path))?; - if metadata.file_type().is_symlink() - || !metadata.is_file() - || metadata.len() == 0 - || metadata.len() > UI_WORKFLOW_MAX_IMAGE_BYTES - { - return Err(format!( - "UI 字体不是受控大小的非空普通文件:{}", - asset.local_path - )); - } - let bytes = fs::read(&path) - .map_err(|error| format!("读取 UI 字体失败:{}: {error}", asset.local_path))?; - FontAsset::from_verified_bytes( - asset.id.clone(), - asset.local_path.clone(), - Path::new(&asset.local_path) - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or("font"), - &bytes, - ) - .map(|_| ()) -} - -fn canonical_resource_id(asset: &GameCreationAppAssetManifestEntry) -> String { - asset - .source - .resource_id - .clone() - .or_else(|| asset.source.asset_object_id.clone()) - .unwrap_or_else(|| asset.id.clone()) -} - -fn workflow_resource_id(source: &GameCreationAppAssetManifestEntry, page_id: &str) -> String { - let digest = Sha256::digest(format!("{}\0{page_id}", source.id).as_bytes()); - format!("ui-workflow-{}", &format!("{digest:x}")[..24]) -} - -fn workflow_relative_path(source: &GameCreationAppAssetManifestEntry, page_id: &str) -> String { - format!("ui/{}.json", workflow_resource_id(source, page_id)) -} - -fn find_page_ui_resource( - manifest: &GameCreationAppManifest, - source: &GameCreationAppAssetManifestEntry, - page: &UiWorkflowPageInput, - design_asset: &GameCreationAppAssetManifestEntry, - sprite_assets: &[GameCreationAppAssetManifestEntry], - font_assets: &[GameCreationAppAssetManifestEntry], -) -> Result, String> { - let resource_id = workflow_resource_id(source, &page.page_id); - let matches = manifest - .assets - .iter() - .filter(|asset| asset.source.resource_id.as_deref() == Some(resource_id.as_str())) - .cloned() - .collect::>(); - if matches.len() > 1 { - return Err(format!("页面 {} 存在重复 UI workflow 资源", page.page_id)); - } - let Some(asset) = matches.into_iter().next() else { - return Ok(None); - }; - if asset.kind != UI_DESIGN_DOC_ASSET_KIND - || asset.media_type != UI_DESIGN_DOC_MEDIA_TYPE - || asset.local_path != workflow_relative_path(source, &page.page_id) - { - return Err(format!("页面 {} 的 UI workflow 资源身份冲突", page.page_id)); - } - let mut expected_references = vec![ - canonical_resource_id(source), - canonical_resource_id(design_asset), - ]; - expected_references.extend(sprite_assets.iter().map(canonical_resource_id)); - expected_references.extend(font_assets.iter().map(canonical_resource_id)); - if !expected_references.iter().all(|expected| { - asset - .source - .reference_resource_ids - .iter() - .any(|reference| reference == expected) - }) { - return Err(format!( - "页面 {} 的 UI workflow manifest 关联不完整", - page.page_id - )); - } - Ok(Some(asset)) -} - -fn ensure_page_ui_resource( - root: &Path, - project_id: &str, - source: &GameCreationAppAssetManifestEntry, - page: &UiWorkflowPageInput, - design_asset: &GameCreationAppAssetManifestEntry, - sprite_assets: &[GameCreationAppAssetManifestEntry], - font_assets: &[GameCreationAppAssetManifestEntry], -) -> Result { - let manifest = read_existing_manifest_for_project(root)?; - let ui_asset = if let Some(existing) = find_page_ui_resource( - &manifest, - source, - page, - design_asset, - sprite_assets, - font_assets, - )? { - existing - } else { - let relative_path = workflow_relative_path(source, &page.page_id); - let absolute_path = resolve_local_project_path(root, &relative_path)?; - if absolute_path.exists() { - return Err(format!( - "UI workflow 资源路径已存在但未登记:{relative_path}" - )); - } - fs::create_dir_all( - absolute_path - .parent() - .ok_or_else(|| "UI workflow 路径缺少父目录".to_string())?, - ) - .map_err(|error| format!("创建 UI workflow 目录失败:{error}"))?; - fs::write(&absolute_path, b"") - .map_err(|error| format!("创建 UI workflow 资源失败:{error}"))?; - let registered = register_local_asset_at( - root, - &relative_path, - GameCreationAppAssetKind::UiDesignDoc, - UI_DESIGN_DOC_MEDIA_TYPE, - "ui-workflow", - GameCreationAppAssetSource { - kind: GameCreationAppAssetSourceKind::Generated, - canvas_project_id: source.source.canvas_project_id.clone(), - resource_id: Some(workflow_resource_id(source, &page.page_id)), - asset_object_id: None, - task_id: source.source.task_id.clone(), - prompt: None, - model: None, - generation_route: None, - generation_kind: Some("ui-workflow".to_string()), - reference_resource_ids: { - let mut references = vec![ - canonical_resource_id(source), - canonical_resource_id(design_asset), - ]; - references.extend(sprite_assets.iter().map(canonical_resource_id)); - references.extend(font_assets.iter().map(canonical_resource_id)); - references - }, - }, - ) - .map_err(|error| { - let _ = fs::remove_file(&absolute_path); - error - })?; - let current = read_existing_manifest_for_project(root)?; - current - .assets - .into_iter() - .find(|asset| asset.id == registered.id) - .ok_or_else(|| "UI workflow 资源登记后无法回读".to_string())? - }; - - let initialized = load_ui_design_state_at(LoadUiDesignStateInput { - project_path: root.to_string_lossy().into_owned(), - expected_project_id: project_id.to_string(), - asset_id: ui_asset.id.clone(), - }); - if initialized.is_err() { - initialize_ui_design_state_at(root, project_id, &ui_asset.id)?; - } - let snapshot = load_ui_design_state_at(LoadUiDesignStateInput { - project_path: root.to_string_lossy().into_owned(), - expected_project_id: project_id.to_string(), - asset_id: ui_asset.id.clone(), - })?; - let image_id = UIDesignImageId::new(page.page_id.clone()) - .map_err(|error| format!("页面 image ID 无效:{error}"))?; - let expected_image = workflow_design_image(root, design_asset)?; - let mut state = snapshot.state; - match state.ui_design_images.get(&image_id) { - Some(current) if current != &expected_image => { - return Err(format!( - "页面 {} 已有不同 UI 设计图,拒绝静默替换", - page.page_id - )); - } - Some(_) => {} - None if state.ui_design_images.is_empty() => { - state - .ui_design_images - .insert(image_id, expected_image.clone()); - } - None => return Err(format!("页面 {} UI State 已含其他设计图", page.page_id)), - } - let sprite_id = SpriteAssetId::new(format!("page-reference-{}", page.page_id)) - .map_err(|error| format!("页面 sprite ID 无效:{error}"))?; - if !state.sprite_assets.contains_key(&sprite_id) { - let mut sprite = SpriteAsset::new( - sprite_id.clone(), - expected_image.pixel_size, - StrictlyPositiveFinite::new(1.0).map_err(|_| "页面 sprite 像素比例无效".to_string())?, - SpriteBorder::NONE, - ) - .map_err(|error| format!("页面 sprite 初始化失败:{error}"))?; - sprite.metadata = SpriteAssetMetadata { - name: format!("{} 页面视觉素材", page.title.trim()), - asset_type: "ui-page-reference".to_string(), - }; - sprite.path = expected_image.path.clone(); - state.sprite_assets.insert(sprite_id, sprite); - } - install_page_component_assets(root, &mut state, sprite_assets, font_assets)?; - match save_ui_design_state_at(SaveUiDesignStateInput { - project_path: root.to_string_lossy().into_owned(), - expected_project_id: project_id.to_string(), - asset_id: ui_asset.id.clone(), - expected_revision: snapshot.revision, - state, - })? { - SaveUiDesignStateResult::Saved { .. } | SaveUiDesignStateResult::Unchanged { .. } => {} - SaveUiDesignStateResult::Conflict { .. } => { - return Err(format!( - "页面 {} UI State revision 冲突,请重试", - page.page_id - )); - } - } - Ok(ui_asset) -} - -/// 比较 sprite 资源身份时忽略 `metadata.asset_type`。 -/// -/// `asset_type` 是随 canonical kind 派生的展示串,不构成资源身份;身份判定只看真正影响 -/// 渲染的字段,避免展示字段变化造成资源冲突。 -fn same_sprite_asset_identity(current: &SpriteAsset, next: &SpriteAsset) -> bool { - if current.metadata.asset_type == next.metadata.asset_type { - return current == next; - } - let mut current = current.clone(); - let mut next = next.clone(); - current.metadata.asset_type = String::new(); - next.metadata.asset_type = String::new(); - current == next -} - -fn install_page_component_assets( - root: &Path, - state: &mut crate::ui_editor::state::State, - sprite_assets: &[GameCreationAppAssetManifestEntry], - font_assets: &[GameCreationAppAssetManifestEntry], -) -> Result<(), String> { - for asset in sprite_assets { - let absolute = resolve_local_project_path(root, &asset.local_path)?; - let decoded = image::open(&absolute) - .map_err(|_| format!("UI 独立图片/图标无法解码:{}", asset.local_path))?; - let (width, height) = decoded.dimensions(); - let asset_id = SpriteAssetId::new(asset.id.clone()) - .map_err(|error| format!("UI 独立图片/图标 ID 无效:{error}"))?; - let mut sprite = SpriteAsset::new( - asset_id.clone(), - Vector2::new(width as f32, height as f32), - StrictlyPositiveFinite::new(1.0) - .map_err(|_| "UI 独立图片/图标像素比例无效".to_string())?, - SpriteBorder::NONE, - ) - .map_err(|error| format!("UI 独立图片/图标初始化失败:{error}"))?; - sprite.metadata = SpriteAssetMetadata { - name: Path::new(&asset.local_path) - .file_stem() - .and_then(|name| name.to_str()) - .unwrap_or("UI 素材") - .to_string(), - asset_type: asset.kind.to_string(), - }; - sprite.path = asset.local_path.clone(); - match state.sprite_assets.get(&asset_id) { - Some(current) if !same_sprite_asset_identity(current, &sprite) => { - return Err(format!( - "UI 独立图片/图标 {} 与已有 State 资源冲突", - asset.id - )); - } - Some(_) => {} - None => { - state.sprite_assets.insert(asset_id, sprite); - } - } - } - for asset in font_assets { - let absolute = resolve_local_project_path(root, &asset.local_path)?; - let bytes = fs::read(&absolute) - .map_err(|error| format!("读取 UI 字体失败:{}: {error}", asset.local_path))?; - let font = FontAsset::from_verified_bytes( - asset.id.clone(), - asset.local_path.clone(), - Path::new(&asset.local_path) - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or("font"), - &bytes, - )?; - let asset_id = font.asset_id.clone(); - match state.font_assets.get(&asset_id) { - Some(current) if current != &font => { - return Err(format!("UI 字体 {} 与已有 State 资源冲突", asset.id)); - } - Some(_) => {} - None => { - state.font_assets.insert(asset_id, font); - } - } - } - Ok(()) -} - -/// Runs the provider-backed editor pipeline for one workflow page. -/// -/// `recognize_ui_impl` owns multimodal semantic recognition and strict tool -/// response validation. `bind_components_impl` owns visual component binding -/// and its allowlisted sprite validation. This wrapper only persists their -/// DTOs under the UI State revision gate; it never manufactures a tree when a -/// provider is unavailable or returns an invalid result. -async fn recognize_page_semantics( - root: &Path, - project_id: &str, - page: &ResolvedWorkflowPage, - provider_identity: Option<(&str, &str)>, -) -> Result<(), String> { - let image_id = UIDesignImageId::new(page.input.page_id.clone()) - .map_err(|error| format!("页面 image ID 无效:{error}"))?; - let snapshot = load_ui_design_state_at(LoadUiDesignStateInput { - project_path: root.to_string_lossy().into_owned(), - expected_project_id: project_id.to_string(), - asset_id: page.ui_asset.id.clone(), - })?; - - let mut stage = page - .ui_asset - .source - .generation_kind - .as_deref() - .unwrap_or("ui-workflow") - .to_string(); - let has_page_tree = snapshot - .state - .ui_trees - .iter() - .filter(|tree| tree.src_ui_design == image_id) - .count() - == 1; - - // Every provider-backed phase is persisted independently. A retry resumes - // from the latest truthful manifest stage instead of repeating completed - // calls or manufacturing fallback output. - if !matches!( - stage.as_str(), - "ui-workflow.structure-ready" | "ui-workflow.merge-ready" | "ui-workflow.binding-ready" - ) { - let project_path = root.to_string_lossy().into_owned(); - let recognition = recognize_ui_impl_with_provider( - project_path, - snapshot.state.clone(), - provider_identity, - ) - .await - .map_err(|error| format!("页面 {} UI 语义识别失败:{error}", page.input.page_id))?; - if recognition.ui_trees.len() != 1 || recognition.ui_trees[0].src_ui_design != image_id { - return Err(format!( - "页面 {} UI 语义识别返回的树与页面设计图不匹配", - page.input.page_id - )); - } - let mut state = snapshot.state; - state.ui_trees = recognition.ui_trees; - match save_ui_design_state_at(SaveUiDesignStateInput { - project_path: root.to_string_lossy().into_owned(), - expected_project_id: project_id.to_string(), - asset_id: page.ui_asset.id.clone(), - expected_revision: snapshot.revision, - state, - })? { - SaveUiDesignStateResult::Saved { .. } | SaveUiDesignStateResult::Unchanged { .. } => {} - SaveUiDesignStateResult::Conflict { .. } => { - return Err(format!( - "页面 {} UI 语义识别保存 revision 冲突,请重试", - page.input.page_id - )); - } - } - update_page_manifest_stage(root, &page.ui_asset.id, "structure-ready")?; - stage = "ui-workflow.structure-ready".to_string(); - } else if !has_page_tree { - return Err(format!( - "页面 {} manifest 已记录语义识别阶段,但 UI State 缺少唯一结构树", - page.input.page_id - )); - } - - if stage == "ui-workflow.binding-ready" { - return Ok(()); - } - - if stage == "ui-workflow.structure-ready" { - let merge_snapshot = load_ui_design_state_at(LoadUiDesignStateInput { - project_path: root.to_string_lossy().into_owned(), - expected_project_id: project_id.to_string(), - asset_id: page.ui_asset.id.clone(), - })?; - let merged = merge_ui_impl_with_provider( - root.to_string_lossy().into_owned(), - merge_snapshot.state.clone(), - provider_identity, - ) - .await - .map_err(|error| format!("页面 {} UI 多树合并失败:{error}", page.input.page_id))?; - if merged.ui_tree.src_ui_design != image_id { - return Err(format!( - "页面 {} UI 多树合并结果未绑定主页面设计图", - page.input.page_id - )); - } - let mut state = merge_snapshot.state; - state.ui_trees = vec![merged.ui_tree]; - match save_ui_design_state_at(SaveUiDesignStateInput { - project_path: root.to_string_lossy().into_owned(), - expected_project_id: project_id.to_string(), - asset_id: page.ui_asset.id.clone(), - expected_revision: merge_snapshot.revision, - state, - })? { - SaveUiDesignStateResult::Saved { .. } | SaveUiDesignStateResult::Unchanged { .. } => {} - SaveUiDesignStateResult::Conflict { .. } => { - return Err(format!( - "页面 {} UI 多树合并保存 revision 冲突,请重试", - page.input.page_id - )); - } - } - update_page_manifest_stage(root, &page.ui_asset.id, "merge-ready")?; - stage = "ui-workflow.merge-ready".to_string(); - } - - if stage != "ui-workflow.merge-ready" { - return Err(format!( - "页面 {} UI workflow 阶段无法进入组件绑定:{stage}", - page.input.page_id - )); - } - - let mut binding_snapshot = load_ui_design_state_at(LoadUiDesignStateInput { - project_path: root.to_string_lossy().into_owned(), - expected_project_id: project_id.to_string(), - asset_id: page.ui_asset.id.clone(), - })?; - let mut sprite_ids = binding_snapshot - .state - .sprite_assets - .keys() - .map(|id| id.as_str().to_string()) - .collect::>(); - sprite_ids.sort(); - if sprite_ids.is_empty() { - return Err(format!( - "页面 {} UI 语义识别已完成,但没有可用于组件绑定的页面素材", - page.input.page_id - )); - } - let mut changed_nodes = 0usize; - for batch in sprite_ids.chunks(crate::ui_editor::commands::binding::ASSET_BATCH_SIZE) { - let binding = bind_components_impl_with_provider( - root.to_string_lossy().into_owned(), - binding_snapshot.state.clone(), - batch.to_vec(), - provider_identity, - ) - .await - .map_err(|error| format!("页面 {} UI 组件语义绑定失败:{error}", page.input.page_id))?; - if binding.changes.is_empty() { - continue; - } - let mut state = binding_snapshot.state.clone(); - let changes = binding - .changes - .into_iter() - .map(|change| (change.node_id.clone(), change)) - .collect::>(); - changed_nodes += apply_binding_changes(&mut state.ui_trees, &changes); - binding_snapshot = match save_ui_design_state_at(SaveUiDesignStateInput { - project_path: root.to_string_lossy().into_owned(), - expected_project_id: project_id.to_string(), - asset_id: page.ui_asset.id.clone(), - expected_revision: binding_snapshot.revision, - state, - })? { - SaveUiDesignStateResult::Saved { - state, revision, .. - } - | SaveUiDesignStateResult::Unchanged { - state, revision, .. - } => crate::ui_editor::persistence::UiDesignStateSnapshot { state, revision }, - SaveUiDesignStateResult::Conflict { .. } => { - return Err(format!( - "页面 {} UI 组件绑定保存 revision 冲突,请重试", - page.input.page_id - )); - } - }; - } - if changed_nodes == 0 || !state_has_renderable_component(&binding_snapshot.state) { - return Err(format!( - "页面 {} UI 组件语义绑定未形成可渲染组件,拒绝进入 binding-ready", - page.input.page_id - )); - } - let mut binding_blockers = Vec::new(); - let mut component_count = 0usize; - for tree in &binding_snapshot.state.ui_trees { - collect_binding_blockers(&tree.root, &mut component_count, &mut binding_blockers); - } - if !binding_blockers.is_empty() { - // Keep the provider result available for review, but do not claim the - // binding stage. A subsequent recognize operation can retry binding - // from the durable structure-ready state. - return Ok(()); - } - update_page_manifest_stage(root, &page.ui_asset.id, "binding-ready") -} - -fn apply_binding_changes( - trees: &mut [crate::ui_editor::state::UITree], - changes: &HashMap, -) -> usize { - fn apply_node( - node: &mut Node, - changes: &HashMap, - ) -> usize { - let mut changed = 0; - if let Some(change) = changes.get(&node.id) { - node.component = change.component.clone().into_option(); - node.metadata.component_status = change.component_status.clone(); - changed += 1; - } - for child in &mut node.children { - changed += apply_node(child, changes); - } - changed - } - - trees - .iter_mut() - .map(|tree| apply_node(&mut tree.root, changes)) - .sum() -} - -fn state_has_renderable_component(state: &crate::ui_editor::state::State) -> bool { - fn has_component(node: &Node) -> bool { - node.component.is_some() || node.children.iter().any(has_component) - } - state.ui_trees.iter().any(|tree| has_component(&tree.root)) -} - -/// Publishes the durable workflow stage alongside the UI JSON State. The -/// manifest is the workbench projection authority, so every stage transition -/// is revisioned and can invalidate the client projection immediately. -fn update_page_manifest_stage(root: &Path, asset_id: &str, stage: &str) -> Result<(), String> { - let _lock = acquire_project_write_lock(root, "ui.workflow.manifest_stage")?; - let changed = mutate_manifest_at(root, |manifest| { - let asset = manifest - .assets - .iter_mut() - .find(|asset| asset.id == asset_id) - .ok_or_else(|| format!("UI workflow 资源 {} 未登记", asset_id))?; - if asset.kind != UI_DESIGN_DOC_ASSET_KIND || asset.media_type != UI_DESIGN_DOC_MEDIA_TYPE { - return Err(format!("UI workflow 资源 {} 类型不匹配", asset_id)); - } - let next_kind = format!("ui-workflow.{stage}"); - let current_rank = asset - .source - .generation_kind - .as_deref() - .and_then(workflow_stage_rank) - .unwrap_or(0); - let next_rank = workflow_stage_rank(&next_kind).unwrap_or(0); - if current_rank >= next_rank { - return Ok(false); - } - asset.source.generation_route = Some("ui.workflow.run".to_string()); - asset.source.generation_kind = Some(next_kind); - Ok(true) - })?; - if changed { - advance_agent_runtime_project_revision_locked(root).map(|_| ()) - } else { - Ok(()) - } -} - -fn workflow_stage_rank(kind: &str) -> Option { - match kind { - "ui-workflow.reference-ready" => Some(1), - "ui-workflow.structure-ready" => Some(2), - "ui-workflow.merge-ready" => Some(3), - "ui-workflow.binding-ready" => Some(4), - "ui-workflow.application-ready" => Some(5), - "ui-workflow.completed" => Some(6), - _ => None, - } -} - -fn workflow_design_image( - root: &Path, - design_asset: &GameCreationAppAssetManifestEntry, -) -> Result { - let absolute = resolve_local_project_path(root, &design_asset.local_path)?; - let bytes = fs::read(&absolute) - .map_err(|error| format!("读取页面设计图失败:{}: {error}", design_asset.local_path))?; - let decoded = image::load_from_memory(&bytes) - .map_err(|_| format!("页面设计图无法解码:{}", design_asset.local_path))?; - let (width, height) = decoded.dimensions(); - if width == 0 || height == 0 { - return Err("页面设计图尺寸无效".to_string()); - } - Ok(UIDesignImage { - path: design_asset.local_path.clone(), - pixel_size: Vector2::new(width as f32, height as f32), - pixels_per_unit: StrictlyPositiveFinite::new(1.0) - .expect("1.0 is a positive finite pixels-per-unit"), - }) -} - -fn derive_page_status( - root: &Path, - project_id: &str, - page: &ResolvedWorkflowPage, - check_application: bool, -) -> Result { - let snapshot = load_ui_design_state_at(LoadUiDesignStateInput { - project_path: root.to_string_lossy().into_owned(), - expected_project_id: project_id.to_string(), - asset_id: page.ui_asset.id.clone(), - })?; - let image_id = UIDesignImageId::new(page.input.page_id.clone()) - .map_err(|error| format!("页面 image ID 无效:{error}"))?; - let mut stage = UiWorkflowPageStage::ReferenceReady; - let mut blockers = Vec::new(); - let matching_trees = snapshot - .state - .ui_trees - .iter() - .filter(|tree| tree.src_ui_design == image_id) - .collect::>(); - if matching_trees.len() != 1 { - blockers.push("尚未形成唯一的页面 UI 结构树".to_string()); - } else { - stage = UiWorkflowPageStage::StructureReady; - let mut component_count = 0usize; - collect_binding_blockers(&matching_trees[0].root, &mut component_count, &mut blockers); - if component_count == 0 { - blockers.push("UI 结构树尚未绑定任何可渲染组件".to_string()); - } - if blockers.is_empty() { - stage = UiWorkflowPageStage::BindingReady; - } - } - let marker = application_marker(&page.input.page_id, &page.ui_asset.id, snapshot.revision); - let manifest_completed = - page.ui_asset.source.generation_kind.as_deref() == Some("ui-workflow.completed"); - if stage == UiWorkflowPageStage::BindingReady { - let marker_installed = page - .input - .application_path - .as_deref() - .map(|_| { - validate_application_marker(root, page.input.application_path.as_deref(), &marker) - .is_ok() - }) - .unwrap_or(false); - if marker_installed { - stage = if check_application || manifest_completed { - UiWorkflowPageStage::Completed - } else { - UiWorkflowPageStage::ApplicationReady - }; - } else if check_application { - validate_application_marker(root, page.input.application_path.as_deref(), &marker) - .map_err(|error| format!("页面 {} 应用门禁失败:{error}", page.input.page_id))?; - } - } - Ok(UiWorkflowPageStatus { - page_id: page.input.page_id.clone(), - title: page.input.title.trim().to_string(), - design_asset_id: page.design_asset.id.clone(), - ui_asset_id: page.ui_asset.id.clone(), - ui_state_revision: snapshot.revision, - stage, - blockers, - application_marker: marker, - }) -} - -fn collect_binding_blockers(node: &Node, component_count: &mut usize, blockers: &mut Vec) { - *component_count += node.component.is_some() as usize; - if let StageStatus::NeedReview(reason) | StageStatus::Blocked(reason) = - &node.metadata.layout_status - { - blockers.push(format!("{} 布局未通过:{reason}", node.metadata.name)); - } - if let StageStatus::NeedReview(reason) | StageStatus::Blocked(reason) = - &node.metadata.component_status - { - blockers.push(format!("{} 组件未通过:{reason}", node.metadata.name)); - } - for child in &node.children { - collect_binding_blockers(child, component_count, blockers); - } -} - -fn application_marker(page_id: &str, ui_asset_id: &str, revision: u64) -> String { - format!("GENARRATIVE_UI_PAGE:{page_id}:{ui_asset_id}:{revision}") -} - -fn validate_application_marker( - root: &Path, - application_path: Option<&str>, - marker: &str, -) -> Result<(), String> { - let relative = application_path - .map(str::trim) - .filter(|value| !value.is_empty()) - .ok_or_else(|| "finalize 必须提供 applicationPath".to_string())?; - let normalized = normalize_relative_path(relative)?; - if !normalized.starts_with("game/") { - return Err("applicationPath 必须位于 game/".to_string()); - } - let path = resolve_local_project_path(root, &normalized)?; - let metadata = fs::symlink_metadata(&path) - .map_err(|error| format!("读取 applicationPath 失败:{error}"))?; - if metadata.file_type().is_symlink() || !metadata.is_file() || metadata.len() == 0 { - return Err("applicationPath 必须是非空普通文件".to_string()); - } - if metadata.len() > UI_WORKFLOW_MAX_APPLICATION_BYTES { - return Err("applicationPath 超出受控大小".to_string()); - } - let content = - fs::read_to_string(&path).map_err(|_| "applicationPath 必须是 UTF-8 文本".to_string())?; - if !content.contains(marker) { - return Err(format!( - "applicationPath 缺少当前 UI State revision 标记:{marker}" - )); - } - Ok(()) -} - -fn apply_application_marker( - root: &Path, - project_id: &str, - application_path: Option<&str>, - page_id: &str, - ui_asset_id: &str, -) -> Result<(), String> { - let relative = application_path - .map(str::trim) - .filter(|value| !value.is_empty()) - .ok_or_else(|| "finalize 必须提供 applicationPath".to_string())?; - let normalized = normalize_relative_path(relative)?; - if !normalized.starts_with("game/") { - return Err("applicationPath 必须位于 game/".to_string()); - } - let path = resolve_local_project_path(root, &normalized)?; - let metadata = fs::symlink_metadata(&path) - .map_err(|error| format!("读取 applicationPath 失败:{error}"))?; - if metadata.file_type().is_symlink() || !metadata.is_file() || metadata.len() == 0 { - return Err("applicationPath 必须是非空普通文件".to_string()); - } - if metadata.len() > UI_WORKFLOW_MAX_APPLICATION_BYTES { - return Err("applicationPath 超出受控大小".to_string()); - } - let snapshot = load_ui_design_state_at(LoadUiDesignStateInput { - project_path: root.to_string_lossy().into_owned(), - expected_project_id: project_id.to_string(), - asset_id: ui_asset_id.to_string(), - })?; - let marker = application_marker(page_id, ui_asset_id, snapshot.revision); - let _lock = acquire_project_write_lock(root, "ui.workflow.apply")?; - let content = - fs::read_to_string(&path).map_err(|_| "applicationPath 必须是 UTF-8 文本".to_string())?; - let marker_comment = format!(""); - if content.lines().any(|line| line.trim() == marker_comment) { - return Ok(()); - } - let next = format!("{content}\n{marker_comment}\n"); - fs::write(&path, next.as_bytes()) - .map_err(|error| format!("应用 UI 页面 {} 到游戏失败:{error}", page_id))?; - advance_agent_runtime_project_revision_locked(root) - .map(|_| ()) - .map_err(|error| format!("UI 页面 {} 已写入但项目 revision 未推进:{error}", page_id))?; - Ok(()) -} - -fn write_final_receipt( - root: &Path, - project_id: &str, - source_asset_id: &str, - pages: &[UiWorkflowPageStatus], - route: &UiWorkflowFinalStageRoute, -) -> Result<(), String> { - let digest = Sha256::digest(source_asset_id.as_bytes()); - let relative = format!(".agent/ui-workflows/{}.json", &format!("{digest:x}")[..24]); - let path = resolve_local_project_path(root, &relative)?; - fs::create_dir_all( - path.parent() - .ok_or_else(|| "UI workflow receipt 缺少父目录".to_string())?, - ) - .map_err(|error| format!("创建 UI workflow receipt 目录失败:{error}"))?; - let receipt = UiWorkflowReceipt { - schema_version: UI_WORKFLOW_RECEIPT_SCHEMA_VERSION.to_string(), - project_id: project_id.to_string(), - source_asset_id: source_asset_id.to_string(), - pages: pages.to_vec(), - final_stage_route: route.clone(), - }; - let bytes = serde_json::to_vec_pretty(&receipt) - .map_err(|error| format!("序列化 UI workflow receipt 失败:{error}"))?; - let temporary = path.with_extension("json.tmp"); - fs::write(&temporary, &bytes) - .map_err(|error| format!("写入 UI workflow receipt 临时文件失败:{error}"))?; - fs::rename(&temporary, &path) - .map_err(|error| format!("安装 UI workflow receipt 失败:{error}"))?; - let installed = - fs::read(&path).map_err(|error| format!("回读 UI workflow receipt 失败:{error}"))?; - if installed != bytes { - return Err("UI workflow receipt 安装后回读不一致".to_string()); - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn fixture_png(path: &Path) { - let image = image::RgbaImage::from_pixel(8, 8, image::Rgba([32, 48, 64, 255])); - image.save(path).expect("write workflow fixture png"); - } - - fn fixture_asset( - root: &Path, - relative_path: &str, - kind: GameCreationAppAssetKind, - resource_id: &str, - ) -> GameCreationAppAssetManifestEntry { - register_local_asset_at( - root, - relative_path, - kind, - "image/png", - "workflow-test", - GameCreationAppAssetSource { - kind: GameCreationAppAssetSourceKind::Generated, - canvas_project_id: None, - resource_id: Some(resource_id.to_string()), - asset_object_id: None, - task_id: None, - prompt: None, - model: None, - generation_route: None, - generation_kind: None, - reference_resource_ids: Vec::new(), - }, - ) - .expect("register workflow fixture asset"); - read_existing_manifest_for_project(root) - .expect("read workflow fixture manifest") - .assets - .into_iter() - .find(|asset| asset.local_path == relative_path) - .expect("find workflow fixture asset") - } - - #[test] - fn workflow_input_rejects_duplicate_page_or_design_ids() { - let input = UiWorkflowRunInput { - operation: UiWorkflowOperation::Prepare, - source_asset_id: "prototype".to_string(), - pages: vec![ - UiWorkflowPageInput { - page_id: "home".to_string(), - title: "首页".to_string(), - description: String::new(), - design_asset_id: "design-home".to_string(), - sprite_asset_ids: Vec::new(), - font_asset_ids: Vec::new(), - application_path: None, - }, - UiWorkflowPageInput { - page_id: "home".to_string(), - title: "首页副本".to_string(), - description: String::new(), - design_asset_id: "design-home-2".to_string(), - sprite_asset_ids: Vec::new(), - font_asset_ids: Vec::new(), - application_path: None, - }, - ], - }; - assert!(validate_workflow_input(&input) - .expect_err("duplicate page ids must be rejected") - .contains("pageId")); - } - - #[test] - fn application_marker_is_revision_bound() { - assert_eq!( - application_marker("home", "ui-1", 2), - "GENARRATIVE_UI_PAGE:home:ui-1:2" - ); - } - - #[test] - fn discover_ui_pages_reads_registry_and_sorts_stable_ids() { - let directory = tempfile::tempdir().expect("create discovery fixture project"); - let root = directory.path(); - init_local_game_project_at(root, "ui-discovery", "UI discovery") - .expect("init discovery fixture project"); - fs::write( - root.join(UI_WORKFLOW_PAGE_REGISTRY_PATH), - r#"[ - {"pageId":"settings","title":"设置","description":"调整偏好","applicationPath":"game/index.html"}, - {"pageId":"home","title":"首页","description":"开始游戏","applicationPath":"game/index.html"} - ]"#, - ) - .expect("write page registry"); - - let pages = discover_ui_pages(root).expect("discover registered pages"); - assert_eq!( - pages - .iter() - .map(|page| page.page_id.as_str()) - .collect::>(), - ["home", "settings"] - ); - assert_eq!( - pages[0].required_design_asset_path, - "assets/ui-pages/home.png" - ); - assert_eq!(pages[0].discovered_from, UI_WORKFLOW_PAGE_REGISTRY_PATH); - } - - #[test] - fn discover_ui_pages_reads_controlled_marker() { - let directory = tempfile::tempdir().expect("create marker discovery fixture project"); - let root = directory.path(); - init_local_game_project_at(root, "ui-marker-discovery", "UI marker discovery") - .expect("init marker discovery fixture project"); - let index = root.join("game/index.html"); - let existing = fs::read_to_string(&index).expect("read game index"); - fs::write( - &index, - format!( - "{existing}\n\n" - ), - ) - .expect("write page marker"); - - let pages = discover_ui_pages(root).expect("discover marker page"); - assert_eq!(pages.len(), 1); - assert_eq!(pages[0].page_id, "inventory"); - assert!(pages[0].discovered_from.starts_with("game/index.html:")); - } - - #[test] - fn discover_ui_pages_rejects_missing_declarations() { - let directory = tempfile::tempdir().expect("create empty discovery fixture project"); - let root = directory.path(); - init_local_game_project_at(root, "ui-empty-discovery", "UI empty discovery") - .expect("init empty discovery fixture project"); - - let error = discover_ui_pages(root).expect_err("empty discovery must block"); - assert!(error.contains("未发现 UI 页面声明")); - } - - #[tokio::test] - async fn workflow_prepare_does_not_claim_semantics_before_provider() { - let directory = tempfile::tempdir().expect("create workflow fixture project"); - let root = directory.path(); - init_local_game_project_at(root, "ui-workflow-project", "UI workflow") - .expect("init workflow fixture project"); - let source_path = root.join("assets/ui-prototype.png"); - let design_path = root.join("assets/home.png"); - fixture_png(&source_path); - fixture_png(&design_path); - let source = fixture_asset( - root, - "assets/ui-prototype.png", - GameCreationAppAssetKind::UiDesign, - "source-ui", - ); - let design = fixture_asset( - root, - "assets/home.png", - GameCreationAppAssetKind::UiDesign, - "design-home", - ); - let page = |operation| UiWorkflowRunInput { - operation, - source_asset_id: source.id.clone(), - pages: vec![UiWorkflowPageInput { - page_id: "home".to_string(), - title: "首页".to_string(), - description: "主界面".to_string(), - design_asset_id: design.id.clone(), - sprite_asset_ids: Vec::new(), - font_asset_ids: Vec::new(), - application_path: Some("game/index.html".to_string()), - }], - }; - - let prepared = run_ui_workflow_at(root, page(UiWorkflowOperation::Prepare)) - .await - .expect("prepare workflow"); - assert!(!prepared.completed); - assert_eq!(prepared.pages[0].stage, UiWorkflowPageStage::ReferenceReady); - assert!(prepared.revision_advance_count > 0); - - let status = run_ui_workflow_at(root, page(UiWorkflowOperation::Status)) - .await - .expect("status workflow"); - assert_eq!(status.pages[0].stage, UiWorkflowPageStage::ReferenceReady); - assert!(status.pages[0] - .blockers - .iter() - .any(|blocker| blocker.contains("结构树"))); - assert_eq!(status.revision_advance_count, 0); - let persisted = load_ui_design_state_at(LoadUiDesignStateInput { - project_path: root.to_string_lossy().into_owned(), - expected_project_id: "ui-workflow-project".to_string(), - asset_id: status.pages[0].ui_asset_id.clone(), - }) - .expect("read workflow UI state"); - assert!(persisted.state.ui_trees.is_empty()); - } - - #[tokio::test] - async fn workflow_prepare_installs_registered_sprite_and_font_assets() { - let directory = tempfile::tempdir().expect("create workflow component fixture"); - let root = directory.path(); - init_local_game_project_at(root, "ui-workflow-assets", "UI workflow assets") - .expect("init workflow component fixture"); - fs::create_dir_all(root.join("assets")).expect("create fixture assets"); - fixture_png(&root.join("assets/ui-prototype.png")); - fixture_png(&root.join("assets/home.png")); - fixture_png(&root.join("assets/start-button.png")); - let source = fixture_asset( - root, - "assets/ui-prototype.png", - GameCreationAppAssetKind::UiDesign, - "source-ui", - ); - let design = fixture_asset( - root, - "assets/home.png", - GameCreationAppAssetKind::UiDesign, - "design-home", - ); - let sprite = fixture_asset( - root, - "assets/start-button.png", - GameCreationAppAssetKind::Icon, - "start-button", - ); - let font_path = root.join("assets/ui-font.ttf"); - fs::copy( - Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../public/fusion-pixel.ttf"), - &font_path, - ) - .expect("copy checked-in font fixture"); - let font = register_local_asset_at( - root, - "assets/ui-font.ttf", - GameCreationAppAssetKind::Font, - "font/ttf", - "workflow-test", - GameCreationAppAssetSource { - kind: GameCreationAppAssetSourceKind::Generated, - canvas_project_id: None, - resource_id: Some("ui-font".to_string()), - asset_object_id: None, - task_id: None, - prompt: None, - model: None, - generation_route: None, - generation_kind: None, - reference_resource_ids: Vec::new(), - }, - ) - .expect("register font fixture"); - let prepared = run_ui_workflow_at( - root, - UiWorkflowRunInput { - operation: UiWorkflowOperation::Prepare, - source_asset_id: source.id, - pages: vec![UiWorkflowPageInput { - page_id: "home".to_string(), - title: "首页".to_string(), - description: "主界面".to_string(), - design_asset_id: design.id, - sprite_asset_ids: vec![sprite.id.clone()], - font_asset_ids: vec![font.id.clone()], - application_path: Some("game/index.html".to_string()), - }], - }, - ) - .await - .expect("prepare workflow component assets"); - let persisted = load_ui_design_state_at(LoadUiDesignStateInput { - project_path: root.to_string_lossy().into_owned(), - expected_project_id: "ui-workflow-assets".to_string(), - asset_id: prepared.pages[0].ui_asset_id.clone(), - }) - .expect("read workflow component state"); - assert!(persisted - .state - .sprite_assets - .keys() - .any(|id| id.as_str() == sprite.id)); - assert!(persisted - .state - .font_assets - .keys() - .any(|id| id.as_str() == font.id)); - assert!(persisted.state.ui_trees.is_empty()); - } -}