Merge pull request 'ui editor 页面优化' (#337) from feat/ui-editor-v3 into master
Project CI / AI game creator shell Rust crates (push) Successful in 1m13s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m52s
Project CI / Backend tests (push) Successful in 4m42s
Project CI / Native shell tests (push) Successful in 6m4s
Project CI / Frontend tests (push) Successful in 2m7s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 9m39s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 9m6s
Project CI / AI game creator shell web tests (push) Successful in 1m28s
Project CI / Repository checks (push) Successful in 1m53s

Reviewed-on: #337
This commit was merged in pull request #337.
This commit is contained in:
2026-09-24 19:45:46 +08:00
119 changed files with 6808 additions and 6264 deletions
+17
View File
@@ -48,6 +48,23 @@ _Avoid_: 无来源的静态素材、只显示在 UI 但不落工程资源记录
一组同类素材的统一批量生成方式,采用批量规划、sheet 生图、后端切图、透明化、OSS 持久化和局部重生成的通用流水线。
_Avoid_: 为每个玩法单独发明素材流水线、把系列素材建模成任一玩法专属 DTO
**UI 设计文档**:
AGC 本地项目里 `kind=ui-design-doc``mediaType=application/json` 的界面编辑载体,保存设计图集合、UI 树、组件绑定和 State revision;一份文档可同时承载多张设计图与各自的结构树,不强制收敛成一棵树。
_Avoid_: 把 UI 设计图当成设计文档、给设计文档再套一层「页面」概念
**设计图**:
UI 设计文档里的一张参考界面图,以它在 manifest 中的资产 ID 作为文档内身份,附带像素尺寸与像素比;文档内的每棵树都必须引用文档里已存在的设计图。
_Avoid_: 用文件名当设计图身份、设计图与设计文档同一概念
**UI 工作流步骤**:
对一份 UI 设计文档执行的一次受控处理阶段,当前只有「结构识别」与「素材切分」两步;步骤产物只有在写入文档并保存后才算完成。
_Avoid_: 把单次工具调用当成步骤、把中间产物当成步骤完成
**工作流检查点日志**:
一份 UI 设计文档旁按行追加的恢复用日志,每完成一个工作流步骤追加一行;某步是否有对应行即代表该步是否完成,恢复时从第一个缺失的行继续。
_Avoid_: 每步一个 sidecar 状态机、把切分专用的 SeparationState 泛化成通用检查点
## Language
### Puzzle Clear
@@ -157,11 +157,8 @@ const allowedUncalledTauriCommands = [
'set_active_game_creator_agent_session',
'start_game_creator_agent_goal',
'start_game_creator_supervisor_runtime_task',
// TODO: Remove the retired binding command after the legacy runtime path is removed.
'bind_components',
'chat_with_game_creator_agent',
'check_ui_editor_font_glyph_coverage',
'create_ui_design_resource',
// 图片类生成的同步变体:GUI 已改为 `start_local_project_asset_generation` + 项目内任务账本
// (提交即返回、后台生成)。这条命令**没有生产调用方**,只有 Rust 集成测试
// `src/tests/project.rs`)与 `commands.rs` 单测在调;待后续批次删除,或改为转调
@@ -1,11 +1,12 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "main",
"description": "AI 游戏创作主窗口允许读系统剪贴板图片,用于粘贴素材附件;允许弹出原生打开/保存对话框用于素材上传与导出。",
"description": "AI 游戏创作主窗口允许读系统剪贴板,用于粘贴素材附件和复制生成文件路径;允许弹出原生打开/保存对话框用于素材上传与导出。",
"windows": ["client"],
"permissions": [
"clipboard-manager:allow-read-image",
"clipboard-manager:allow-read-text",
"clipboard-manager:allow-write-text",
"core:image:allow-rgba",
"core:image:allow-size",
"core:resources:allow-close",
@@ -7,6 +7,7 @@
"execution": "texts/execution.json",
"media": "texts/media.json",
"nativeTools": "texts/native-tools.json",
"uiDesignDoc": "texts/ui-design-doc.json",
"recovery": "texts/recovery.json",
"interaction": "texts/interaction.json",
"goalContext": "texts/goal-context.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 编辑器中执行一段有界代码;仅提交 code,客户端负责绑定项目与编辑器进程。",
"unity.editor.execute.description": "在当前 Unity 项目已打开的编辑器中执行 C#。仅提交 code;结果待核对时禁止自动重发。",
"godot.editor.execute.description": "在当前 Godot 项目已打开的编辑器中执行支持 return/await 的 GDScript 函数体。仅提交 code;结果待核对时禁止自动重发。",
@@ -0,0 +1,8 @@
{
"from_images.description": "用一至四张设计图新建一份 UI 设计文档:输入给已登记资源的 assetId,或给项目内相对路径由本工具顺带登记;随后按 ui/UI 设计 N.json 取号创建文档、把设计图写进文档并登记为 ui-design-doc 资源,返回新文档的 assetId 与 relativePath。每次调用都新建文档,不复用既有文档。",
"from_images.parameters.images": "一至四张设计图。每张给 assets 里已登记图片资源的 assetId,或给项目内相对路径(相对路径会先登记成图片资源);文档内的设计图身份就是该图片资源的 assetId。",
"run_workflow.description": "对一份 UI 设计文档在 Rust 内依次执行结构识别与自动切分素材:把切分素材回填到 Image 组件的 target_graphic、清掉已处理节点的组件状态、给达到返工上限的问题节点写 NeedReview,最后写回文档并推进 revision。每完成一步追加一行检查点,崩溃后从第一个缺失的步骤继续;不做页面发现,也不做多树合并与独立组件绑定。文档在轮次中途被改动时本轮作废并报错,必须重新调用本工具开新一轮。",
"run_workflow.parameters.designDocAssetId": "目标 UI 设计文档在 assets 里的 assetId;文档内必须已有设计图。",
"into_js.description": "把一份 UI 设计文档的当前 revision 渲染成 ui/generated-<stem>-<digest>.js,返回相对路径与导出树。只读文档内容,不修改文档、不推进 revision。",
"into_js.parameters.designDocAssetId": "目标 UI 设计文档在 assets 里的 assetId。"
}
@@ -677,160 +677,14 @@ 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::<serde_json::Value>(
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::<Option<Vec<_>>>()?;
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::<Option<Vec<_>>>()?;
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 matches!(
observation.tool.as_str(),
"ui-design-doc.from-images" | "ui-design-doc.run-workflow"
) {
return agent_runtime_ui_design_doc_safe_detail(root, observation.detail.as_deref());
}
if observation.tool == "ui-design-doc.into-js" {
return agent_runtime_ui_design_code_safe_detail(root, observation.detail.as_deref());
}
if observation.tool != "project.patchset" {
return None;
@@ -1175,6 +1029,184 @@ pub(in crate::agent) fn agent_runtime_action_receipt_safe_text(
Some(sanitized)
}
/// UI 设计文档回执里身份类字段的长度上限:素材 id / 文档 id / 生成文件名都远小于这个值。
const AGENT_RUNTIME_UI_DESIGN_SAFE_ID_MAX_CHARS: usize = 96;
/// 文档相对路径的长度上限;同时要求必须落在 `ui/` 下,避免把项目别处的路径带进回执。
const AGENT_RUNTIME_UI_DESIGN_SAFE_RELATIVE_PATH_MAX_CHARS: usize = 160;
/// 单条回填说明的长度上限与总量上限;超出总量的部分不逐条外传,改用总数表达。
const AGENT_RUNTIME_UI_DESIGN_SAFE_ERROR_MAX_CHARS: usize = 96;
const AGENT_RUNTIME_UI_DESIGN_SAFE_ERROR_CHAR_BUDGET: usize = 240;
/// 一份文档最多四张设计图,逐张身份也按同一上限放行。
const AGENT_RUNTIME_UI_DESIGN_SAFE_IMAGE_ID_LIMIT: usize = 4;
/// UI 设计文档链路(新建文档 / 跑工作流)的回执明细:只放身份、相对路径、计数与回填说明。
/// 计数与身份必须完全合格;体积较大的清单(设计图身份、回填说明)在回执长度上限内能放多少放多少,
/// 放不下的部分用总数补齐,绝不把原始明细整体外传。
fn agent_runtime_ui_design_doc_safe_detail(root: &Path, detail: Option<&str>) -> Option<String> {
let detail = serde_json::from_str::<serde_json::Value>(detail.unwrap_or_default()).ok()?;
let mut safe = serde_json::Map::new();
safe.insert(
"assetId".to_string(),
agent_runtime_action_receipt_identity_text(
root,
detail.get("assetId")?.as_str()?,
AGENT_RUNTIME_UI_DESIGN_SAFE_ID_MAX_CHARS,
"assetId",
)
.ok()?
.into(),
);
safe.insert(
"relativePath".to_string(),
agent_runtime_ui_design_safe_relative_path(root, detail.get("relativePath")?.as_str()?)?
.into(),
);
for key in [
"revision",
"revisionAdvanceCount",
"recognizedTreeCount",
"boundNodeCount",
"problematicNodeCount",
] {
if let Some(value) = detail.get(key) {
safe.insert(key.to_string(), value.as_u64()?.into());
}
}
if let Some(value) = detail.get("recoveredFromCheckpoint") {
safe.insert(
"recoveredFromCheckpoint".to_string(),
value.as_bool()?.into(),
);
}
if !agent_runtime_action_receipt_detail_fits(&safe) {
return None;
}
if let Some(image_ids) = detail.get("imageIds") {
let image_ids = image_ids.as_array()?;
if image_ids.is_empty() || image_ids.len() > AGENT_RUNTIME_UI_DESIGN_SAFE_IMAGE_ID_LIMIT {
return None;
}
let image_ids = image_ids
.iter()
.map(|value| {
agent_runtime_action_receipt_identity_text(
root,
value.as_str()?,
AGENT_RUNTIME_UI_DESIGN_SAFE_ID_MAX_CHARS,
"imageIds",
)
.ok()
})
.collect::<Option<Vec<_>>>()?;
agent_runtime_action_receipt_insert_while_fits(
&mut safe,
"imageIds",
serde_json::json!(image_ids),
);
}
if let Some(errors) = detail.get("backfillErrors") {
let errors = errors.as_array()?;
let mut safe_errors = Vec::new();
for value in errors {
// 回填说明是自由文本:绝对路径按既有口径脱敏成占位符,控制字符或超长则整条明细失败关闭。
let raw = value.as_str()?.trim();
let error = agent_runtime_action_receipt_safe_text(
root,
raw,
AGENT_RUNTIME_UI_DESIGN_SAFE_ERROR_MAX_CHARS,
None,
)?;
if sanitize_agent_runtime_text(&error, AGENT_RUNTIME_UI_DESIGN_SAFE_ERROR_MAX_CHARS)
!= error
{
return None;
}
let mut candidate = safe_errors.clone();
candidate.push(serde_json::Value::String(error));
let mut probe = safe.clone();
probe.insert("backfillErrors".to_string(), serde_json::json!(candidate));
probe.insert(
"backfillErrorCount".to_string(),
serde_json::json!(errors.len()),
);
if !agent_runtime_action_receipt_detail_fits(&probe) {
break;
}
safe_errors = candidate;
}
if !safe_errors.is_empty()
&& serde_json::to_string(&serde_json::json!(safe_errors))
.map(|text| text.chars().count())
.unwrap_or(0)
<= AGENT_RUNTIME_UI_DESIGN_SAFE_ERROR_CHAR_BUDGET
{
safe.insert("backfillErrors".to_string(), serde_json::json!(safe_errors));
}
safe.insert(
"backfillErrorCount".to_string(),
serde_json::json!(errors.len()),
);
}
serde_json::to_string(&serde_json::Value::Object(safe)).ok()
}
/// 代码生成回执:相对路径与计数;导出名清单只用来核对数量,不逐项外传。
fn agent_runtime_ui_design_code_safe_detail(root: &Path, detail: Option<&str>) -> Option<String> {
let detail = serde_json::from_str::<serde_json::Value>(detail.unwrap_or_default()).ok()?;
let relative_path =
agent_runtime_ui_design_safe_relative_path(root, detail.get("relativePath")?.as_str()?)?;
let tree_count = detail.get("treeCount")?.as_u64()?;
let node_count = detail.get("nodeCount")?.as_u64()?;
if usize::try_from(tree_count).ok()? != detail.get("treeExports")?.as_array()?.len() {
return None;
}
let safe = serde_json::to_string(&serde_json::json!({
"relativePath": relative_path,
"treeCount": tree_count,
"nodeCount": node_count,
}))
.ok()?;
(sanitize_agent_runtime_text(&safe, AGENT_RUNTIME_ACTION_RECEIPT_SAFE_DETAIL_MAX_CHARS) == safe)
.then_some(safe)
}
/// `ui/` 下的项目相对路径:绝对路径、反斜杠、上跳与超长一律拒绝。
fn agent_runtime_ui_design_safe_relative_path(root: &Path, value: &str) -> Option<String> {
let path = normalize_relative_path(value).ok()?;
if !path.starts_with("ui/") {
return None;
}
agent_runtime_action_receipt_identity_text(
root,
&path,
AGENT_RUNTIME_UI_DESIGN_SAFE_RELATIVE_PATH_MAX_CHARS,
"relativePath",
)
.ok()
}
fn agent_runtime_action_receipt_detail_fits(
detail: &serde_json::Map<String, serde_json::Value>,
) -> bool {
serde_json::to_string(&serde_json::Value::Object(detail.clone())).is_ok_and(|text| {
sanitize_agent_runtime_text(&text, AGENT_RUNTIME_ACTION_RECEIPT_SAFE_DETAIL_MAX_CHARS)
== text
})
}
fn agent_runtime_action_receipt_insert_while_fits(
detail: &mut serde_json::Map<String, serde_json::Value>,
key: &str,
value: serde_json::Value,
) {
let mut candidate = detail.clone();
candidate.insert(key.to_string(), value);
if agent_runtime_action_receipt_detail_fits(&candidate) {
*detail = candidate;
}
}
pub(in crate::agent) fn agent_runtime_public_action_input_summary(
root: &Path,
tool: &str,
@@ -1210,12 +1242,14 @@ 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"
| "agent.action_history"
| "agent.run_status"
| "ui-design-doc.from-images"
| "ui-design-doc.run-workflow"
| "ui-design-doc.into-js"
);
if public_shape_only {
return agent_runtime_action_receipt_safe_text(root, input_summary, 320, None);
@@ -1749,8 +1783,82 @@ pub(crate) fn agent_runtime_tool_action_input_summary(
text(&["agentId", "agent_id", "targetAgentId", "target_agent_id"]),
text(&["delegationId", "delegation_id"])
),
// UI 设计文档三兄弟:入参只有设计图引用或目标文档 id,直接给出可读摘要,
// 不再退化成"只报哈希"——否则模型看不到自己这次到底传了什么。
"ui-design-doc.from-images" => ui_design_doc_images_input_summary(input),
"ui-design-doc.run-workflow" | "ui-design-doc.into-js" => format!(
"designDocAssetId={}",
text(&["designDocAssetId", "design_doc_asset_id", "assetId", "asset_id"])
),
_ => String::new(),
};
let summary = redact_agent_runtime_project_paths(root, &summary, 320);
(!summary.trim().is_empty()).then_some(summary)
}
/// 新建文档工具的设计图入参摘要:逐张给出身份(素材 id 或相对路径),绝对路径按既有口径降级成拒绝标记。
fn ui_design_doc_images_input_summary(input: &serde_json::Value) -> String {
let Some(images) = input.get("images").and_then(serde_json::Value::as_array) else {
return String::new();
};
let described = images
.iter()
.map(|image| {
if let Some(asset_id) = image.get("assetId").and_then(serde_json::Value::as_str) {
return format!("assetId={asset_id}");
}
match image.get("path").and_then(serde_json::Value::as_str) {
Some(path) if Path::new(path).is_absolute() => {
"[absolute path rejected]".to_string()
}
Some(path) => format!("path={path}"),
None => "input".to_string(),
}
})
.collect::<Vec<_>>()
.join(", ");
format!("images={} · {described}", images.len())
}
#[cfg(test)]
mod ui_design_doc_public_input_summary_tests {
use super::*;
#[test]
fn ui_design_doc_input_summaries_are_readable_not_hashed() {
let root = Path::new("/nonexistent-ui-design-doc-input-summary-project");
for (tool, summary) in [
(
"ui-design-doc.from-images",
"images=2 · assetId=image-1, path=assets/two.png",
),
(
"ui-design-doc.run-workflow",
"designDocAssetId=generated-ui-design-1",
),
(
"ui-design-doc.into-js",
"designDocAssetId=generated-ui-design-1",
),
] {
let public = agent_runtime_public_action_input_summary(root, tool, Some(summary))
.expect("input summary");
assert_eq!(public, summary, "{tool} 的入参不能被压成摘要哈希");
}
}
#[test]
fn from_images_input_summary_lists_each_image_reference() {
let input = serde_json::json!({
"images": [
{"assetId": "image-1"},
{"path": "assets/two.png"},
{"path": "/tmp/host-only.png"},
]
});
assert_eq!(
ui_design_doc_images_input_summary(&input),
"images=3 · assetId=image-1, path=assets/two.png, [absolute path rejected]"
);
}
}
@@ -153,6 +153,16 @@ pub(crate) async fn execute_game_creator_agent_runtime_tool_action_with_pending_
observe_agent_runtime_account_asset_library(root, &action.input).await
}
"canvas.asset_import" => observe_agent_runtime_asset_import(root, &action.input).await,
"ui-design-doc.from-images" => {
observe_agent_runtime_ui_design_doc_from_images(root, &action.input).await
}
"ui-design-doc.run-workflow" => {
observe_agent_runtime_ui_design_doc_run_workflow(root, agent_id, run_id, &action.input)
.await
}
"ui-design-doc.into-js" => {
observe_agent_runtime_ui_design_doc_into_js(root, &action.input).await
}
"project.index" => observe_agent_runtime_project_snapshot_with_lock(
root,
agent_id,
@@ -436,9 +446,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)
}
@@ -103,7 +103,9 @@ 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"),
"ui-design-doc.from-images" => Some("asset.register"),
"ui-design-doc.run-workflow" => Some("asset.register"),
"ui-design-doc.into-js" => Some("file.write"),
"blackboard.write" => Some("memory.write"),
"agent.message" => Some("conversation.write"),
"agent.delegate" => Some("agent.delegate"),
@@ -649,15 +649,16 @@ 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 agent_runtime_ui_design_doc_tool(&observation.tool) {
// 三个 UI 设计文档工具都在返回时改项目:`from-images` 登记设计图与文档,`run-workflow` 登记
// 切图并保存文档,`into-js` 重写 `ui/generated-*.js`。成功返回即算改过项目;失败路径只有明细
// 报出真实推进量时才算,避免漏掉"调用失败但素材已经登记进去"。
return observation.status == "ok"
|| agent_runtime_observation_detail_revision_advance_count(observation)
.is_some_and(|count| count > 0);
}
if observation.tool == "canvas.asset_import" {
return observation
.detail
.as_deref()
.and_then(|detail| serde_json::from_str::<serde_json::Value>(detail).ok())
.and_then(|value| value.get("revisionAdvanceCount")?.as_u64())
return agent_runtime_observation_detail_revision_advance_count(observation)
.is_some_and(|count| count > 0);
}
observation.status == "ok"
@@ -669,10 +670,28 @@ pub(crate) fn is_agent_runtime_project_mutation_observation(
| "project.patchset"
| "project.restore"
| "canvas.asset_generate"
| "ui.workflow.run"
)
}
/// UI 设计文档链路当前的三个 Agent 工具(`ui.workflow.run` 退役后由它们接管)。
fn agent_runtime_ui_design_doc_tool(tool: &str) -> bool {
matches!(
tool,
"ui-design-doc.from-images" | "ui-design-doc.run-workflow" | "ui-design-doc.into-js"
)
}
/// 明细里的真实项目 revision 推进量:登记类工具(`canvas.asset_import` 与 UI 设计文档工具)会写这个字段。
fn agent_runtime_observation_detail_revision_advance_count(
observation: &AgentRuntimeToolObservation,
) -> Option<u64> {
observation
.detail
.as_deref()
.and_then(|detail| serde_json::from_str::<serde_json::Value>(detail).ok())
.and_then(|value| value.get("revisionAdvanceCount")?.as_u64())
}
pub(in crate::agent) fn agent_runtime_command_start_advanced_project_revision(
observation: &AgentRuntimeToolObservation,
) -> bool {
@@ -735,16 +754,17 @@ pub(crate) fn agent_runtime_observation_advances_project_revision(
if agent_runtime_patchset_advanced_project_revision(observation) {
return true;
}
if agent_runtime_ui_design_doc_tool(&observation.tool) {
// `into-js` 只重写派生产物,不推进 revision;另外两个(登记设计图/文档、登记切图并写回)会推进。
return matches!(
observation.tool.as_str(),
"ui-design-doc.from-images" | "ui-design-doc.run-workflow"
) && (observation.status == "ok"
|| agent_runtime_observation_detail_revision_advance_count(observation)
.is_some_and(|count| count > 0));
}
if observation.status != "ok" {
return observation
.detail
.as_deref()
.and_then(|detail| serde_json::from_str::<serde_json::Value>(detail).ok())
.and_then(|value| {
value
.get("revisionAdvanceCount")
.and_then(serde_json::Value::as_u64)
})
return agent_runtime_observation_detail_revision_advance_count(observation)
.is_some_and(|count| count > 0);
}
match observation.tool.as_str() {
@@ -755,63 +775,19 @@ pub(crate) fn agent_runtime_observation_advances_project_revision(
| "project.restore"
| "blackboard.write"
| "canvas.asset_generate" => true,
"canvas.asset_import" => observation
.detail
.as_deref()
.and_then(|detail| serde_json::from_str::<serde_json::Value>(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)
"canvas.asset_import" => {
agent_runtime_observation_detail_revision_advance_count(observation)
.is_some_and(|count| count > 0)
}
"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::<serde_json::Value>(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::<serde_json::Value>(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()
.and_then(|detail| serde_json::from_str::<serde_json::Value>(detail).ok())
.and_then(|value| {
value
.get("revisionAdvanceCount")
.and_then(serde_json::Value::as_u64)
})
{
if let Some(count) = agent_runtime_observation_detail_revision_advance_count(observation) {
return count;
}
if agent_runtime_observation_advances_project_revision(observation) {
@@ -821,19 +797,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::<serde_json::Value>(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 +818,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 +830,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"
}
@@ -1830,3 +1790,107 @@ mod static_delegate_barrier_detail_gate_tests {
}
}
}
#[cfg(test)]
mod ui_design_doc_project_mutation_gate_tests {
use super::*;
fn observation(tool: &str, status: &str, detail: Option<&str>) -> AgentRuntimeToolObservation {
AgentRuntimeToolObservation {
tool: tool.to_string(),
status: status.to_string(),
summary: String::new(),
detail: detail.map(str::to_string),
}
}
#[test]
fn ui_design_doc_tools_count_as_project_mutation_when_they_return() {
for tool in [
"ui-design-doc.from-images",
"ui-design-doc.run-workflow",
"ui-design-doc.into-js",
] {
let observation = observation(tool, "ok", None);
assert!(
is_agent_runtime_project_mutation_observation(&observation),
"{tool} 成功返回后必须算改过项目"
);
}
}
#[test]
fn into_js_does_not_advance_project_revision() {
let observation = observation("ui-design-doc.into-js", "ok", None);
assert!(!agent_runtime_observation_advances_project_revision(
&observation
));
assert_eq!(
agent_runtime_observation_project_revision_advance_count(&observation),
0,
"只重写派生产物时不能虚报 revision 推进,否则会误报项目被别处改动"
);
}
#[test]
fn registered_ui_design_doc_tools_advance_project_revision() {
for tool in ["ui-design-doc.from-images", "ui-design-doc.run-workflow"] {
let observation = observation(tool, "ok", None);
assert!(agent_runtime_observation_advances_project_revision(
&observation
));
assert_eq!(
agent_runtime_observation_project_revision_advance_count(&observation),
1
);
}
}
#[test]
fn reported_revision_advance_count_is_used_verbatim() {
let observation = observation(
"ui-design-doc.run-workflow",
"ok",
Some(r#"{"revisionAdvanceCount":3}"#),
);
assert_eq!(
agent_runtime_observation_project_revision_advance_count(&observation),
3,
"登记类工具在明细里报出的真实推进量必须优先于兜底的 1"
);
}
#[test]
fn failed_ui_design_doc_tool_counts_only_when_it_already_advanced_revision() {
let rolled_back = observation("ui-design-doc.from-images", "error", None);
assert!(!is_agent_runtime_project_mutation_observation(&rolled_back));
assert!(!agent_runtime_observation_advances_project_revision(
&rolled_back
));
let partially_advanced = observation(
"ui-design-doc.run-workflow",
"error",
Some(r#"{"revisionAdvanceCount":2}"#),
);
assert!(is_agent_runtime_project_mutation_observation(
&partially_advanced
));
assert!(agent_runtime_observation_advances_project_revision(
&partially_advanced
));
assert_eq!(
agent_runtime_observation_project_revision_advance_count(&partially_advanced),
2
);
}
#[test]
fn non_project_tools_are_unaffected() {
let observation = observation("file.read", "ok", Some(r#"{"lineCount":12}"#));
assert!(!is_agent_runtime_project_mutation_observation(&observation));
assert!(!agent_runtime_observation_advances_project_revision(
&observation
));
}
}
@@ -59,7 +59,9 @@ pub(crate) fn agent_runtime_executable_tools() -> Vec<&'static str> {
"preview.validate",
"image.inspect",
"canvas.asset_generate",
"ui.workflow.run",
"ui-design-doc.from-images",
"ui-design-doc.run-workflow",
"ui-design-doc.into-js",
"blackboard.write",
"agent.message",
"agent.delegate",
@@ -110,7 +112,7 @@ pub(crate) fn agent_runtime_acceptance_evidence_tools() -> BTreeSet<&'static str
"image.inspect",
"canvas.asset_generate",
"canvas.asset_import",
"ui.workflow.run",
"ui-design-doc.run-workflow",
]
.into_iter()
.collect()
@@ -137,7 +137,7 @@ fn acceptance_evidence_tool_may_advance_project_revision(tool: &str) -> bool {
| "project.patchset"
| "canvas.asset_import"
| "canvas.asset_generate"
| "ui.workflow.run"
| "ui-design-doc.run-workflow"
)
}
@@ -850,6 +850,16 @@ mod tests {
.expect("append evidence receipt");
}
#[test]
fn ui_design_doc_workflow_evidence_may_advance_project_revision() {
// run-workflow 会写回文档并推进项目 revision;它同时是验收证据工具,
// 两处识别必须一致,否则真实执行后的取证会因 before != after 被判不匹配。
assert!(agent_runtime_acceptance_evidence_tools().contains("ui-design-doc.run-workflow"));
assert!(acceptance_evidence_tool_may_advance_project_revision(
"ui-design-doc.run-workflow"
));
}
#[test]
fn root_completion_requires_contract_and_every_required_node() {
let (_temporary, root, binding) = root_fixture();
@@ -162,7 +162,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());
@@ -176,7 +175,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());
@@ -19,7 +19,7 @@ mod process_ops;
mod project_ops;
mod run_status;
mod task_ops;
mod ui_workflow;
mod ui_design_doc;
pub(in crate::agent) use action_history::*;
pub(in crate::agent) use cocos_editor::*;
@@ -39,7 +39,7 @@ 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::*;
pub(in crate::agent) use ui_design_doc::*;
#[cfg(test)]
pub(crate) use delegation::observe_agent_runtime_agent_delegate_at_locked;
@@ -0,0 +1,314 @@
//! UI 设计文档三个工具在 Runtime 侧的参数解析与调用。
//!
//! 工具名形如 `ui-design-doc.<动作>`;项目根目录、项目 ID 与 provider 身份一律由
//! Runtime 注入,模型只给设计图引用与目标文档 assetId。
use super::*;
use crate::ui_editor::agent_tools::{
create_ui_design_doc_from_images, run_ui_design_doc_workflow, CreateUiDesignDocFromImagesInput,
RunUiDesignDocWorkflowInput, UiDesignImageReference,
};
use crate::ui_editor::persistence::{generate_ui_design_code_at, GenerateUiDesignCodeInput};
use serde::Deserialize;
use serde_json::Value;
pub(in crate::agent) const UI_DESIGN_DOC_FROM_IMAGES_TOOL: &str = "ui-design-doc.from-images";
pub(in crate::agent) const UI_DESIGN_DOC_RUN_WORKFLOW_TOOL: &str = "ui-design-doc.run-workflow";
pub(in crate::agent) const UI_DESIGN_DOC_INTO_JS_TOOL: &str = "ui-design-doc.into-js";
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
struct FromImagesArguments {
images: Vec<UiDesignImageReference>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
struct DesignDocArguments {
design_doc_asset_id: String,
}
pub(in crate::agent) async fn observe_agent_runtime_ui_design_doc_from_images(
root: &Path,
input: &Value,
) -> AgentRuntimeToolObservation {
let tool = UI_DESIGN_DOC_FROM_IMAGES_TOOL;
let arguments = match serde_json::from_value::<FromImagesArguments>(input.clone()) {
Ok(arguments) => arguments,
Err(error) => return rejected(root, tool, format!("{tool} 输入无效:{error}")),
};
let expected_project_id = match game_creator_agent_runtime_context_project_id(root) {
Ok(project_id) => project_id,
Err(error) => return rejected(root, tool, error),
};
let revision_before = read_game_creator_agent_runtime_project_revision(root)
.ok()
.map(|snapshot| snapshot.revision);
// 新建文档要逐张解码设计图并写 manifest:整段阻塞 IO 交给 blocking 线程池,
// 别把 async 执行器(同一个 runtime 还要继续跑其它动作)占住。
let project_path = root.to_string_lossy().into_owned();
let images = arguments.images;
let created = match tokio::task::spawn_blocking(move || {
create_ui_design_doc_from_images(CreateUiDesignDocFromImagesInput {
project_path,
expected_project_id,
images,
})
})
.await
{
Ok(created) => created,
Err(_) => {
return error_observation(root, tool, "后台创建 UI 设计文档任务未完成".to_string())
}
};
match created {
Ok(created) => {
let revision_advance = revision_before
.map(|before| created.committed_project_revision.saturating_sub(before));
if revision_before.is_none_or(|before| created.committed_project_revision > before) {
emit_game_creator_manifest_invalidated(root, tool);
}
AgentRuntimeToolObservation {
tool: tool.to_string(),
status: "ok".to_string(),
summary: format!(
"已新建 UI 设计文档 {}:{} 张设计图,路径 {}",
created.asset.id,
created.image_ids.len(),
created.relative_path
),
detail: serde_json::to_string(&serde_json::json!({
"assetId": created.asset.id,
"relativePath": created.relative_path,
"imageIds": created.image_ids,
"revisionAdvanceCount": revision_advance,
}))
.ok(),
}
}
Err(error) => error_observation_with_revision_advance(
root,
tool,
error,
project_revision_advance_since(root, revision_before),
),
}
}
pub(in crate::agent) async fn observe_agent_runtime_ui_design_doc_run_workflow(
root: &Path,
agent_id: &str,
run_id: &str,
input: &Value,
) -> AgentRuntimeToolObservation {
let tool = UI_DESIGN_DOC_RUN_WORKFLOW_TOOL;
let arguments = match serde_json::from_value::<DesignDocArguments>(input.clone()) {
Ok(arguments) => arguments,
Err(error) => return rejected(root, tool, format!("{tool} 输入无效:{error}")),
};
let expected_project_id = match game_creator_agent_runtime_context_project_id(root) {
Ok(project_id) => project_id,
Err(error) => return rejected(root, tool, error),
};
let revision_before = read_game_creator_agent_runtime_project_revision(root)
.ok()
.map(|snapshot| snapshot.revision);
match run_ui_design_doc_workflow(
RunUiDesignDocWorkflowInput {
project_path: root.to_string_lossy().into_owned(),
expected_project_id,
asset_id: arguments.design_doc_asset_id,
},
Some((agent_id, run_id)),
)
.await
{
Ok(result) => {
// result.revision 是文档自己的 revision(随文档重置),和项目 revision 不是同一个计数器;
// 重新读一次项目 revision,才能判断本次工作流是否真的推进了清单。
let revision_after = read_game_creator_agent_runtime_project_revision(root)
.ok()
.map(|snapshot| snapshot.revision);
let revision_advance = revision_before
.zip(revision_after)
.map(|(before, after)| after.saturating_sub(before));
if revision_before
.is_none_or(|before| revision_after.is_none_or(|after| after > before))
{
emit_game_creator_manifest_invalidated(root, tool);
}
let pending = result.backfill_errors.len();
AgentRuntimeToolObservation {
tool: tool.to_string(),
status: "ok".to_string(),
summary: format!(
"UI 设计文档工作流完成:识别 {} 棵树,回填 {} 个节点,{} 个待人工处理{}",
result.recognized_tree_count,
result.bound_node_count,
result.problematic_node_count,
if pending > 0 {
format!("{pending} 项未回填")
} else {
String::new()
}
),
detail: workflow_detail_with_revision_advance(&result, revision_advance),
}
}
Err(error) => error_observation_with_revision_advance(
root,
tool,
error,
project_revision_advance_since(root, revision_before),
),
}
}
pub(in crate::agent) async fn observe_agent_runtime_ui_design_doc_into_js(
root: &Path,
input: &Value,
) -> AgentRuntimeToolObservation {
let tool = UI_DESIGN_DOC_INTO_JS_TOOL;
let arguments = match serde_json::from_value::<DesignDocArguments>(input.clone()) {
Ok(arguments) => arguments,
Err(error) => return rejected(root, tool, format!("{tool} 输入无效:{error}")),
};
let expected_project_id = match game_creator_agent_runtime_context_project_id(root) {
Ok(project_id) => project_id,
Err(error) => return rejected(root, tool, error),
};
// 生成代码要读整份文档、算 SHA-256 再落盘,同样是阻塞 IO,放到 blocking 线程池。
let project_path = root.to_string_lossy().into_owned();
let asset_id = arguments.design_doc_asset_id;
let generated = match tokio::task::spawn_blocking(move || {
generate_ui_design_code_at(GenerateUiDesignCodeInput {
project_path,
expected_project_id,
asset_id,
})
})
.await
{
Ok(generated) => generated,
Err(_) => {
return error_observation(root, tool, "后台生成 UI 设计代码任务未完成".to_string())
}
};
match generated {
Ok(result) => AgentRuntimeToolObservation {
tool: tool.to_string(),
status: "ok".to_string(),
summary: format!(
"已生成 UI 设计代码 {}:{} 棵树 / {} 个节点",
result.relative_path, result.tree_count, result.node_count
),
detail: serde_json::to_string(&result).ok(),
},
Err(error) => error_observation(root, tool, error),
}
}
/// 输入解析失败或项目上下文缺失:统一脱敏,避免摘要把宿主路径带给 provider。
fn rejected(root: &Path, tool: &str, summary: String) -> AgentRuntimeToolObservation {
AgentRuntimeToolObservation {
tool: tool.to_string(),
status: "rejected".to_string(),
summary: redact_agent_runtime_project_paths(root, &summary, 240),
detail: None,
}
}
fn error_observation(root: &Path, tool: &str, error: String) -> AgentRuntimeToolObservation {
AgentRuntimeToolObservation {
tool: tool.to_string(),
status: "error".to_string(),
summary: redact_agent_runtime_project_paths(root, &error, 500),
detail: None,
}
}
/// 失败路径也可能已经改过项目(切图素材先登记、后面的步骤才失败,且按约定不回滚),所以重读一次项目
/// 版本号,把真实推进量写进明细交给门禁;推进量为 0 时不写字段,明细保持原样。
fn project_revision_advance_since(root: &Path, revision_before: Option<u64>) -> u64 {
let Some(before) = revision_before else {
return 0;
};
read_game_creator_agent_runtime_project_revision(root)
.map(|snapshot| snapshot.revision.saturating_sub(before))
.unwrap_or(0)
}
fn error_observation_with_revision_advance(
root: &Path,
tool: &str,
error: String,
revision_advance: u64,
) -> AgentRuntimeToolObservation {
let mut observation = error_observation(root, tool, error);
if revision_advance > 0 {
observation.detail =
Some(serde_json::json!({ "revisionAdvanceCount": revision_advance }).to_string());
}
observation
}
/// 工作流结果明细原样保留,只追加一项真实推进量,供项目变更门禁读取。
fn workflow_detail_with_revision_advance<T: serde::Serialize>(
result: &T,
revision_advance: Option<u64>,
) -> Option<String> {
let mut value = serde_json::to_value(result).ok()?;
if let Some(revision_advance) = revision_advance {
value
.as_object_mut()?
.insert("revisionAdvanceCount".to_string(), revision_advance.into());
}
serde_json::to_string(&value).ok()
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[tokio::test]
async fn from_images_rejects_unknown_fields_and_accepts_reference_pairs() {
let root = Path::new(".");
let rejected = observe_agent_runtime_ui_design_doc_from_images(
root,
&json!({ "images": [{ "assetId": "a", "path": null }], "extra": 1 }),
)
.await;
assert_eq!(rejected.status, "rejected");
assert!(rejected.summary.contains("extra"), "{}", rejected.summary);
// 参数合法但项目不存在:说明参数已经过解析,失败发生在项目校验之后。
let invalid_project = observe_agent_runtime_ui_design_doc_from_images(
root,
&json!({ "images": [{ "assetId": "a", "path": null }] }),
)
.await;
assert_eq!(invalid_project.status, "rejected");
assert!(!invalid_project.summary.contains("输入无效"));
}
#[tokio::test]
async fn design_doc_asset_id_must_be_a_string() {
// run-workflow 与 into-js 共用同一份入参解析,这里解析一次、各调用一次。
assert!(
serde_json::from_value::<DesignDocArguments>(json!({ "designDocAssetId": 1 })).is_err()
);
let observation = observe_agent_runtime_ui_design_doc_into_js(
Path::new("."),
&json!({ "designDocAssetId": 1 }),
)
.await;
assert_eq!(observation.status, "rejected");
assert!(
observation.summary.contains("输入无效"),
"{}",
observation.summary
);
}
}
@@ -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::<UiWorkflowRunInput>(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,
}
}
}
}
@@ -225,9 +225,11 @@ pub(crate) fn native_runtime_function_name(tool: &str) -> Option<String> {
}
fn native_runtime_function_name_for_tool(tool: &str) -> String {
// 工具 ID 允许 '-'(如 `ui-design-doc.from-images`),函数名只能是
// `[A-Za-z_][A-Za-z0-9_]*`,两种分隔符都要归一成下划线。
format!(
"{AGENT_RUNTIME_NATIVE_TOOL_PREFIX}{}",
tool.replace('.', "_")
tool.replace(['.', '-'], "_")
)
}
@@ -1051,9 +1053,9 @@ 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")
}
"ui-design-doc.from-images" => prompt_text!("uiDesignDoc.from_images.description"),
"ui-design-doc.run-workflow" => prompt_text!("uiDesignDoc.run_workflow.description"),
"ui-design-doc.into-js" => prompt_text!("uiDesignDoc.into_js.description"),
"cocos.editor.execute" => {
prompt_text!("nativeTools.cocos.editor.execute.description")
}
@@ -1160,6 +1162,37 @@ fn runtime_tool_input_schema(tool: &str) -> Value {
}
}
}),
"ui-design-doc.from-images" => json!({
"type": "object",
// 每张设计图给 assetId 或 path 之一,另一个显式传 null。
"required": ["images"],
"additionalProperties": false,
"properties": {
"images": {
"type": "array",
"minItems": 1,
"maxItems": 4,
"description": prompt_text!("uiDesignDoc.from_images.parameters.images"),
"items": {
"type": "object",
"required": ["assetId", "path"],
"additionalProperties": false,
"properties": {
"assetId": { "type": ["string", "null"], "minLength": 1, "maxLength": 512 },
"path": { "type": ["string", "null"], "minLength": 1, "maxLength": 512 }
}
}
}
}
}),
"ui-design-doc.run-workflow" => one_string_input_schema_with_description(
"designDocAssetId",
prompt_text!("uiDesignDoc.run_workflow.parameters.designDocAssetId"),
),
"ui-design-doc.into-js" => one_string_input_schema_with_description(
"designDocAssetId",
prompt_text!("uiDesignDoc.into_js.parameters.designDocAssetId"),
),
"project.search" => json!({
"type": "object", "required": ["query", "path", "maxResults", "caseSensitive"], "additionalProperties": false,
"properties": {
@@ -1343,33 +1376,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!({
@@ -1506,6 +1512,16 @@ fn one_string_input_schema(field: &str) -> Value {
})
}
/// 与 `one_string_input_schema` 同形,但把文案里的参数说明挂到字段上。
fn one_string_input_schema_with_description(field: &str, description: &str) -> Value {
json!({
"type": "object",
"required": [field],
"additionalProperties": false,
"properties": { (field): { "type": "string", "description": description } }
})
}
fn two_string_input_schema(first: &str, second: &str) -> Value {
json!({
"type": "object",
@@ -2171,4 +2187,32 @@ mod tests {
})
);
}
#[test]
fn native_ui_design_doc_tools_expose_dash_free_function_names() {
// 工具 ID 允许 '-'Function Calling 的函数名不允许;两者不能混用同一套归一。
let fallback = runtime_tool_description("ui-design-doc.unknown");
for tool in [
"ui-design-doc.from-images",
"ui-design-doc.run-workflow",
"ui-design-doc.into-js",
] {
let function_name = native_runtime_function_name_for_tool(tool);
assert!(
!function_name.contains(['.', '-']),
"{tool} 的函数名仍有非法字符:{function_name}"
);
assert_ne!(
runtime_tool_description(tool),
fallback,
"{tool} 缺少工具描述"
);
assert_eq!(runtime_tool_input_schema(tool)["type"], json!("object"));
}
assert_eq!(
runtime_tool_input_schema("ui-design-doc.from-images")["properties"]["images"]
["maxItems"],
json!(4)
);
}
}
@@ -2066,122 +2066,6 @@ pub(crate) fn register_local_asset(
)
}
#[tauri::command]
pub(crate) fn create_ui_design_resource(
project_path: String,
expected_project_id: String,
) -> Result<CreateUiDesignResourceResult, String> {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "asset.register")?;
let _lock = acquire_project_write_lock(root, "asset.register")?;
let manifest = read_existing_manifest_for_project(root)?;
if manifest.project_id != expected_project_id.trim() {
return Err("project-identity-conflict".to_string());
}
let (resource_name, relative_path) =
crate::ui_editor::resource_bridge::next_ui_design_path(root, &manifest)?;
let absolute_path = resolve_local_project_path(root, &relative_path)?;
if let Some(parent) = absolute_path.parent() {
ensure_game_creator_private_directory_tree(parent, "UI 资源目录")?;
prepare_game_creator_private_path_for_read(parent, true, "UI 资源目录")?;
}
if prepare_game_creator_private_path_for_read(&absolute_path, false, "UI 资源")? {
return Err("UI 设计资源路径已存在,拒绝覆盖".to_string());
}
let mut options = fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(windows)]
{
use std::os::windows::fs::OpenOptionsExt;
options.custom_flags(crate::PROJECT_FILE_FLAG_OPEN_REPARSE_POINT);
}
let file = options
.open(&absolute_path)
.map_err(|error| format!("创建 UI 资源失败:{}: {error}", absolute_path.display()))?;
if let Err(error) = harden_new_game_creator_private_path(&absolute_path, false, "UI 资源") {
drop(file);
let _ = fs::remove_file(&absolute_path);
return Err(error);
}
file.sync_all()
.map_err(|error| format!("同步 UI 资源失败:{}: {error}", absolute_path.display()))?;
drop(file);
let asset = match register_local_asset_at(
root,
&relative_path,
GameCreationAppAssetKind::UiDesignDoc,
crate::ui_editor::persistence::UI_DESIGN_DOC_MEDIA_TYPE,
"generated",
GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Generated,
canvas_project_id: None,
resource_id: Some(format!(
"ui:{}",
resource_name.trim_start_matches("UI 设计 ")
)),
asset_object_id: None,
task_id: None,
prompt: None,
model: None,
generation_route: None,
generation_kind: None,
reference_resource_ids: Vec::new(),
},
) {
Ok(asset) => asset,
Err(error) => {
let rollback = (|| {
write_manifest(&root.join(".agent/manifest.json"), &manifest)?;
fs::remove_file(&absolute_path).map_err(|remove_error| {
format!(
"删除未完成 UI 设计资源失败:{}: {remove_error}",
absolute_path.display()
)
})
})();
return match rollback {
Ok(()) => Err(error),
Err(rollback_error) => Err(format!(
"UI 设计资源登记失败:{error}reconciliation-required: 回滚未完成:{rollback_error}"
)),
};
}
};
if let Err(error) = ui_editor::persistence::initialize_ui_design_state_at(
root,
expected_project_id.trim(),
&asset.id,
) {
let rollback = (|| {
let mut current = read_existing_manifest_for_project(root)?;
current.assets.retain(|entry| entry.id != asset.id);
write_manifest(&root.join(".agent/manifest.json"), &current)?;
fs::remove_file(&absolute_path).map_err(|remove_error| {
format!(
"删除未完成 UI 设计资源失败:{}: {remove_error}",
absolute_path.display()
)
})
})();
return match rollback {
Ok(()) => Err(error),
Err(rollback_error) => Err(format!(
"UI 设计资源初始化失败:{error}reconciliation-required: 回滚未完成:{rollback_error}"
)),
};
}
advance_agent_runtime_project_revision_locked(root).map_err(|error| {
format!("reconciliation-required: UI 设计资源已创建,但项目 revision 未能推进:{error}")
})?;
let manifest = read_existing_manifest_for_project(root)?;
let revision = read_game_creator_agent_runtime_project_revision(root)?.revision;
Ok(CreateUiDesignResourceResult {
asset,
manifest,
committed_project_revision: revision,
})
}
#[tauri::command]
pub(crate) fn update_local_project_resource_classification(
input: UpdateLocalProjectResourceClassificationInput,
@@ -196,14 +196,6 @@ use runner::*;
use template_library::*;
use user_input::*;
use windows::*;
#[tauri::command]
async fn suggest_ui_design_semantic(
project_path: String,
state: ui_editor::state::State,
) -> Result<Vec<ui_editor::commands::UIDesignSuggestionTreeNode>, String> {
ui_editor::commands::suggest_ui_design_semantic_impl(project_path, state).await
}
#[tauri::command]
async fn recognize_ui(
project_path: String,
@@ -247,20 +239,6 @@ fn discard_separation_recovery(project_path: String, asset_id: String) -> Result
ui_editor::commands::separation::discard_separation_recovery(root, &asset_id)
}
#[tauri::command]
async fn merge_ui(state: ui_editor::state::State) -> Result<ui_editor::commands::MergeDTO, String> {
ui_editor::commands::merge_ui_impl(state).await
}
#[tauri::command]
async fn bind_components(
project_path: String,
state: ui_editor::state::State,
sprite_ids: Vec<String>,
) -> Result<ui_editor::commands::BindingDTO, String> {
ui_editor::commands::bind_components_impl(project_path, state, sprite_ids).await
}
#[tauri::command]
fn load_ui_design_state(
input: ui_editor::persistence::LoadUiDesignStateInput,
@@ -318,10 +296,10 @@ fn generate_ui_design_code(
}
#[tauri::command]
fn ensure_ui_design_resource_for_prototype(
input: ui_editor::resource_bridge::EnsureUiDesignResourceForPrototypeInput,
) -> Result<ui_editor::resource_bridge::EnsureUiDesignResourceForPrototypeResult, String> {
ui_editor::resource_bridge::ensure_ui_design_resource_for_prototype(input)
fn create_ui_design_doc_from_images(
input: ui_editor::agent_tools::CreateUiDesignDocFromImagesInput,
) -> Result<ui_editor::agent_tools::UiDesignDocCreated, String> {
ui_editor::agent_tools::create_ui_design_doc_from_images(input)
}
#[derive(Debug, Eq, PartialEq, Serialize)]
@@ -1121,14 +1099,6 @@ struct UploadLocalAssetResult {
manifest_path: String,
}
#[derive(Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct CreateUiDesignResourceResult {
asset: UploadLocalAssetResult,
manifest: GameCreationAppManifest,
committed_project_revision: u64,
}
#[derive(Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct ImportCanvasExportResult {
@@ -2694,7 +2664,7 @@ fn main() {
discover_game_creator_llm_models,
upload_local_asset,
register_local_asset,
create_ui_design_resource,
create_ui_design_doc_from_images,
update_local_project_resource_classification,
add_local_project_resource_tags,
derive_local_project_resource,
@@ -2711,18 +2681,14 @@ fn main() {
prepare_ui_editor_project_fonts,
read_ui_editor_font_bytes,
check_ui_editor_font_glyph_coverage,
suggest_ui_design_semantic,
recognize_ui,
separate_ui,
inspect_separation_recovery,
finalize_separation,
discard_separation_recovery,
merge_ui,
bind_components,
load_ui_design_state,
save_ui_design_state,
generate_ui_design_code,
ensure_ui_design_resource_for_prototype,
generate_platform_art_asset,
generate_local_project_asset,
start_local_project_asset_generation,
@@ -2771,7 +2771,7 @@ fn register_local_asset_keeps_explicit_category_when_kind_is_unchanged() {
/// 写侧 → 分类的端到端口径:现役写入侧直接写出的 canonical kind 必须落进明确栏目。
///
/// 这里的字面量与写入侧逐字一致:UI 设计资产是
/// `ui_editor/resource_bridge.rs` / `workflow.rs` / `persistence.rs` 的
/// `ui_editor/agent_tools/creation.rs` / `persistence.rs` 的
/// `register_local_asset_at` 使用 UI 文档 kind/media 常量,
/// 字体是 `commands.rs` 字体上传的 `register_local_asset_entry(root, path, "font", ...)`。
#[test]
@@ -2842,37 +2842,115 @@ fn register_local_asset_derives_category_from_real_write_side_kinds() {
fs::remove_dir_all(root).ok();
}
/// 已存在的 `ui/UI 设计 N.json` 不能让新建 UI 设计资源直接失败。
/// 已存在的 `ui/UI 设计 N.json` 不能让新建 UI 设计文档直接失败。
///
/// 编号按已登记的 UI 文档数推导,旧项目里 kind 已被收口为 `unknown` 的文档不再计入,
/// 只按计数取名就会撞上仍然存在的同名文件并报「路径已存在」。两个写入侧必须
/// `ui_editor::resource_bridge` 的「第一个空闲编号」规则,既不改名既有文件也不覆盖。
/// 只按计数取名就会撞上仍然存在的同名文件并报「路径已存在」。文档创建入口必须用
/// `ui_editor::agent_tools::next_ui_design_path` 的「第一个空闲编号」规则,
/// 既不改名既有文件也不覆盖。
#[test]
fn create_ui_design_resource_skips_a_taken_ui_design_path() {
fn create_ui_design_doc_from_images_skips_a_taken_ui_design_path() {
let root = unique_project_path();
init_local_game_project_at(&root, "project-1", "UI 设计编号避让").expect("project init");
write_local_project_file_at(&root, "ui/UI 设计 1.json", "{}").expect("ui asset file");
let result = crate::commands::create_ui_design_resource(
root.to_string_lossy().to_string(),
"project-1".to_string(),
let mut bytes = Vec::new();
image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(
64,
48,
image::Rgba([10, 20, 30, 255]),
))
.write_to(
&mut std::io::Cursor::new(&mut bytes),
image::ImageFormat::Png,
)
.expect("create UI design resource");
.expect("encode design image");
fs::create_dir_all(root.join("assets")).expect("assets dir");
fs::write(root.join("assets/page.png"), bytes).expect("write design image");
assert_eq!(result.asset.local_path, "ui/UI 设计 2.json");
let registered = result
let created = crate::ui_editor::agent_tools::create_ui_design_doc_from_images(
crate::ui_editor::agent_tools::CreateUiDesignDocFromImagesInput {
project_path: root.to_string_lossy().to_string(),
expected_project_id: "project-1".to_string(),
images: vec![crate::ui_editor::agent_tools::UiDesignImageReference {
asset_id: None,
path: Some("assets/page.png".to_string()),
}],
},
)
.expect("create UI design document");
assert_eq!(created.relative_path, "ui/UI 设计 2.json");
assert_eq!(created.image_ids.len(), 1);
assert_eq!(created.asset.local_path, "ui/UI 设计 2.json");
assert_eq!(created.asset.source.resource_id.as_deref(), Some("ui:2"));
assert_eq!(created.asset.kind, GameCreationAppAssetKind::UiDesignDoc);
let registered_image = created
.manifest
.assets
.iter()
.find(|asset| asset.id == result.asset.id)
.expect("registered UI design asset");
assert_eq!(registered.source.resource_id.as_deref(), Some("ui:2"));
assert_eq!(registered.kind, GameCreationAppAssetKind::UiDesignDoc);
.find(|asset| asset.id == created.image_ids[0])
.expect("registered design image asset");
assert_eq!(registered_image.local_path, "assets/page.png");
assert_eq!(registered_image.media_type, "image/png");
assert!(root.join("ui/UI 设计 1.json").is_file());
fs::remove_dir_all(root).ok();
}
/// 批次里后面某张设计图不合法时,前面已通过校验的图片不能留在 manifest 里。
///
/// 登记动作本身是 manifest 副作用:如果边校验边登记,一旦后续引用失败就没有回滚路径,
/// 会在 manifest 里留下没有任何文档引用的图片条目。整批先校验再登记才不会漏。
#[test]
fn create_ui_design_doc_from_images_leaves_no_orphan_assets_when_a_later_image_fails() {
let root = unique_project_path();
init_local_game_project_at(&root, "project-1", "UI 设计批次校验").expect("project init");
let mut bytes = Vec::new();
image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(
32,
24,
image::Rgba([1, 2, 3, 255]),
))
.write_to(
&mut std::io::Cursor::new(&mut bytes),
image::ImageFormat::Png,
)
.expect("encode design image");
fs::create_dir_all(root.join("assets")).expect("assets dir");
fs::write(root.join("assets/page.png"), bytes).expect("write design image");
let error = crate::ui_editor::agent_tools::create_ui_design_doc_from_images(
crate::ui_editor::agent_tools::CreateUiDesignDocFromImagesInput {
project_path: root.to_string_lossy().to_string(),
expected_project_id: "project-1".to_string(),
images: vec![
crate::ui_editor::agent_tools::UiDesignImageReference {
asset_id: None,
path: Some("assets/page.png".to_string()),
},
crate::ui_editor::agent_tools::UiDesignImageReference {
asset_id: None,
path: Some("assets/missing.png".to_string()),
},
],
},
)
.expect_err("批次中存在缺失的设计图时必须整批失败");
assert!(error.contains("missing.png"), "{error}");
let manifest = crate::read_existing_manifest_for_project(&root).expect("read manifest");
assert!(
manifest
.assets
.iter()
.all(|asset| asset.local_path != "assets/page.png"),
"整批校验失败时不该把已校验通过的图片登记进 manifest"
);
assert!(!root.join("ui/UI 设计 1.json").is_file());
fs::remove_dir_all(root).ok();
}
#[test]
fn register_local_asset_rejects_missing_or_unsafe_path() {
let root = unique_project_path();
@@ -2230,3 +2230,145 @@ fn seed_refresh_preserves_completed_visual_tasks_when_registered_file_is_missing
fs::remove_dir_all(root).ok();
}
#[test]
fn ui_design_doc_receipts_keep_safe_detail_instead_of_dropping_it() {
let root = unique_project_path();
init_local_game_project_at(&root, "project-1", "UI 设计文档回执脱敏测试")
.expect("project init");
let from_images = AgentRuntimeToolObservation {
tool: "ui-design-doc.from-images".to_string(),
status: "ok".to_string(),
summary: "已新建 UI 设计文档".to_string(),
detail: Some(
serde_json::json!({
"assetId": "generated-ui-design-1",
"relativePath": "ui/UI 设计 1.json",
"imageIds": ["image-1", "image-2"],
"revisionAdvanceCount": 2,
})
.to_string(),
),
};
let public = agent_runtime_action_receipt_public_safe_detail_for_test(&root, &from_images)
.expect("from-images 必须留下可用明细");
assert!(public.chars().count() <= 500, "{public}");
let public = serde_json::from_str::<Value>(&public).expect("parse from-images detail");
assert_eq!(public["assetId"], "generated-ui-design-1");
assert_eq!(public["relativePath"], "ui/UI 设计 1.json");
assert_eq!(public["imageIds"].as_array().map(Vec::len), Some(2));
assert_eq!(public["revisionAdvanceCount"], 2);
let run_workflow = AgentRuntimeToolObservation {
tool: "ui-design-doc.run-workflow".to_string(),
status: "ok".to_string(),
summary: "UI 设计文档工作流完成".to_string(),
detail: Some(
serde_json::json!({
"assetId": "generated-ui-design-1",
"relativePath": "ui/UI 设计 1.json",
"revision": 3,
"recoveredFromCheckpoint": true,
"recognizedTreeCount": 2,
"boundNodeCount": 9,
"problematicNodeCount": 1,
"backfillErrors": [
"未能登记自动切分素材图片:assets/cut.png",
format!("读取资源失败:{}", root.join("assets/cut.png").display()),
],
"revisionAdvanceCount": 3,
})
.to_string(),
),
};
let public = agent_runtime_action_receipt_public_safe_detail_for_test(&root, &run_workflow)
.expect("run-workflow 必须留下可用明细");
assert!(public.chars().count() <= 500, "{public}");
assert!(
!public.contains(root.to_string_lossy().as_ref()),
"回填说明里的宿主路径必须脱敏:{public}"
);
let public = serde_json::from_str::<Value>(&public).expect("parse run-workflow detail");
assert_eq!(public["recoveredFromCheckpoint"], true);
assert_eq!(public["problematicNodeCount"], 1);
assert_eq!(public["backfillErrorCount"], 2);
assert_eq!(
public["backfillErrors"][0],
"未能登记自动切分素材图片:assets/cut.png"
);
let into_js = AgentRuntimeToolObservation {
tool: "ui-design-doc.into-js".to_string(),
status: "ok".to_string(),
summary: "已生成 UI 设计代码".to_string(),
detail: Some(
serde_json::json!({
"relativePath": "ui/generated-ui-design-1-0123456789abcdef.js",
"treeExports": ["treeA", "treeB"],
"treeCount": 2,
"nodeCount": 12,
})
.to_string(),
),
};
let public = agent_runtime_action_receipt_public_safe_detail_for_test(&root, &into_js)
.expect("into-js 必须留下可用明细");
let public = serde_json::from_str::<Value>(&public).expect("parse into-js detail");
assert_eq!(public["treeCount"], 2);
assert_eq!(public["nodeCount"], 12);
assert!(
public.get("treeExports").is_none(),
"导出名清单只用于核对数量,不逐项外传:{public}"
);
fs::remove_dir_all(root).ok();
}
#[test]
fn ui_design_doc_receipts_reject_out_of_scope_paths_and_malformed_detail() {
let root = unique_project_path();
init_local_game_project_at(&root, "project-1", "UI 设计文档回执边界测试")
.expect("project init");
let outside_ui_directory = AgentRuntimeToolObservation {
tool: "ui-design-doc.from-images".to_string(),
status: "ok".to_string(),
summary: "已新建 UI 设计文档".to_string(),
detail: Some(
serde_json::json!({
"assetId": "generated-ui-design-1",
"relativePath": "game/UI 设计 1.json",
"imageIds": ["image-1"],
"revisionAdvanceCount": 1,
})
.to_string(),
),
};
assert!(
agent_runtime_action_receipt_public_safe_detail_for_test(&root, &outside_ui_directory)
.is_none(),
"不在 ui/ 下的相对路径必须失败关闭"
);
let absolute_document_id = AgentRuntimeToolObservation {
tool: "ui-design-doc.run-workflow".to_string(),
status: "ok".to_string(),
summary: "UI 设计文档工作流完成".to_string(),
detail: Some(
serde_json::json!({
"assetId": root.join("ui/UI 设计 1.json").to_string_lossy(),
"relativePath": "ui/UI 设计 1.json",
"revisionAdvanceCount": 1,
})
.to_string(),
),
};
assert!(
agent_runtime_action_receipt_public_safe_detail_for_test(&root, &absolute_document_id)
.is_none(),
"身份字段不能是宿主路径"
);
fs::remove_dir_all(root).ok();
}
@@ -423,11 +423,12 @@ 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);
"ui-design-doc.from-images" => {
// 设计图可以给项目内相对路径,交接阶段先按同一套判据拒绝绝对路径逃逸。
if let Some(images) = input.get("images").and_then(serde_json::Value::as_array) {
for (index, image) in images.iter().enumerate() {
let pointer = format!("{input_pointer}/images/{index}/path");
collect_native_string_field(root, image, "path", &pointer, findings);
}
}
}
@@ -0,0 +1,480 @@
//! 工作流检查点日志:`ui/.<文档名>-workflow.jsonl`。
//!
//! 这个模块只管日志机制——行格式、原子追加、半行截断、轮次扫描——不认识任何
//! 业务 DTO 与 State 类型,步骤载荷一律按 `serde_json::Value` 原样存回。
//! 方案见 `docs/technical/【技术方案】UI编辑器Agent工具化重写-2026-09-23.md`。
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::fs::OpenOptions;
use std::io::{Read, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
const WORKFLOW_LOG_SUFFIX: &str = "-workflow.jsonl";
const TORN_TAIL_SCAN_BYTES: u64 = 64 * 1024;
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[serde(tag = "type", rename_all = "kebab-case")]
pub(crate) enum CheckpointLine {
/// 一轮的起点:原始 State 快照与起始 revision。
Run {
at: u64,
doc: CheckpointDocument,
revision: u64,
state: Value,
},
Recognize {
at: u64,
dto: Value,
/// 这一步在内存里应用完之后的 State 快照。恢复时直接读回来,
/// 不再按 dto 重放这一步的 delta。旧版日志没有这一栏,缺失即视为该步不可恢复。
state: Option<Value>,
},
Separate {
at: u64,
dto: Value,
state: Option<Value>,
/// 这一步产出的回填说明:恢复不再重跑该步,所以和状态快照一起存。
#[serde(default)]
backfill_errors: Vec<String>,
},
/// 一轮的结束:文档已写回这个 revision。
WriteBack { at: u64, revision: u64 },
/// 一轮的结束:本轮作废,不再尝试恢复。
Outdated { at: u64, reason: String },
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct CheckpointDocument {
pub(crate) asset_id: String,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) enum RoundOutcome {
/// 还没有结束行:这一轮需要恢复。
Open,
WrittenBack {
revision: u64,
},
Outdated {
reason: String,
},
}
/// 最后一轮的可恢复内容。`base_state` 是 `run` 行的原始 State 快照,
/// 各步的 `*_state` 是那一步做完之后的 State 快照。
#[derive(Clone, Debug)]
pub(crate) struct WorkflowRound {
pub(crate) at: u64,
pub(crate) document_asset_id: String,
pub(crate) base_revision: u64,
pub(crate) base_state: Value,
pub(crate) recognize_dto: Option<Value>,
pub(crate) recognize_state: Option<Value>,
pub(crate) separate_dto: Option<Value>,
pub(crate) separate_state: Option<Value>,
pub(crate) backfill_errors: Vec<String>,
pub(crate) outcome: RoundOutcome,
}
pub(crate) struct WorkflowLog {
path: PathBuf,
}
impl WorkflowLog {
pub(crate) fn open(root: &Path, document_relative_path: &str) -> Result<Self, String> {
Ok(Self {
path: workflow_log_path(root, document_relative_path)?,
})
}
/// 读取最后一轮。文件不存在或没有完整 `run` 行时返回 `None`。
pub(crate) fn last_round(&self) -> Result<Option<WorkflowRound>, String> {
let Some(content) = read_log_content(&self.path)? else {
return Ok(None);
};
parse_last_round(&self.path, &content)
}
/// 一次性追加一行。崩溃可能留下写了一半的最后一行,追加前先把日志截断到最后
/// 一个换行,避免半行夹在日志中间让后续扫描失败。
pub(crate) fn append(&self, line: &CheckpointLine) -> Result<(), String> {
let mut bytes =
serde_json::to_vec(line).map_err(|error| format!("序列化工作流检查点失败:{error}"))?;
bytes.push(b'\n');
if let Some(parent) = self.path.parent() {
std::fs::create_dir_all(parent).map_err(|error| {
format!("创建工作流检查点目录失败:{}{error}", parent.display())
})?;
}
let mut file = OpenOptions::new()
.create(true)
.read(true)
.append(true)
.open(&self.path)
.map_err(|error| {
format!("打开工作流检查点日志失败:{}{error}", self.path.display())
})?;
truncate_torn_tail(&mut file, &self.path)?;
file.write_all(&bytes)
.map_err(|error| format!("追加工作流检查点失败:{}{error}", self.path.display()))?;
file.sync_all()
.map_err(|error| format!("同步工作流检查点失败:{}{error}", self.path.display()))?;
Ok(())
}
}
/// 检查点行的时间戳:秒级 Unix 时间。
pub(crate) fn checkpoint_timestamp() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|elapsed| elapsed.as_secs())
.unwrap_or(0)
}
/// 文档旁的同级隐藏日志:`ui/UI 设计 1.json` → `ui/.UI 设计 1-workflow.jsonl`。
pub(crate) fn workflow_log_path(
root: &Path,
document_relative_path: &str,
) -> Result<PathBuf, String> {
let document_relative_path = crate::normalize_relative_path(document_relative_path.trim())?;
let document_path = Path::new(&document_relative_path);
let stem = document_path
.file_stem()
.and_then(|value| value.to_str())
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| "UI 设计文档路径缺少文件名".to_string())?;
let directory = document_path.parent().unwrap_or_else(|| Path::new(""));
Ok(root
.join(directory)
.join(format!(".{stem}{WORKFLOW_LOG_SUFFIX}")))
}
fn read_log_content(path: &Path) -> Result<Option<String>, String> {
match std::fs::read(path) {
Ok(bytes) => String::from_utf8(bytes)
.map(Some)
.map_err(|error| format!("工作流检查点日志不是 UTF-8{}{error}", path.display())),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(error) => Err(format!(
"读取工作流检查点日志失败:{}{error}",
path.display()
)),
}
}
fn truncate_torn_tail(file: &mut std::fs::File, path: &Path) -> Result<(), String> {
let length = file
.metadata()
.map_err(|error| format!("读取工作流检查点长度失败:{}{error}", path.display()))?
.len();
if length == 0 {
return Ok(());
}
let start = length.saturating_sub(TORN_TAIL_SCAN_BYTES);
let mut tail = Vec::new();
file.seek(SeekFrom::Start(start))
.and_then(|_| file.take(length - start).read_to_end(&mut tail))
.map_err(|error| format!("读取工作流检查点末尾失败:{}{error}", path.display()))?;
if tail.last() == Some(&b'\n') {
return Ok(());
}
if let Some(index) = tail.iter().rposition(|byte| *byte == b'\n') {
return file
.set_len(start + index as u64 + 1)
.map_err(|error| format!("截断工作流检查点半行失败:{}{error}", path.display()));
}
// 末尾 64 KiB 一个换行都没有:退回到整文件扫描,避免把完整行截掉。
let mut whole = Vec::new();
file.seek(SeekFrom::Start(0))
.and_then(|_| file.read_to_end(&mut whole))
.map_err(|error| format!("读取工作流检查点失败:{}{error}", path.display()))?;
let keep = whole
.iter()
.rposition(|byte| *byte == b'\n')
.map(|index| index as u64 + 1)
.unwrap_or(0);
file.set_len(keep)
.map_err(|error| format!("截断工作流检查点半行失败:{}{error}", path.display()))
}
fn parse_last_round(path: &Path, content: &str) -> Result<Option<WorkflowRound>, String> {
let chunks: Vec<&str> = content.split('\n').collect();
let last_index = chunks.len().saturating_sub(1);
let mut round: Option<WorkflowRound> = None;
for (index, raw) in chunks.iter().enumerate() {
if raw.is_empty() {
continue;
}
let line = match serde_json::from_str::<CheckpointLine>(raw) {
Ok(line) => line,
// 没有换行收尾的最后一段是崩溃留下的半行,整段丢弃且不算完成。
Err(_) if index == last_index && !content.ends_with('\n') => break,
Err(error) => {
return Err(format!(
"工作流检查点日志第 {} 行无法解析:{}{error}",
index + 1,
path.display()
))
}
};
apply_line(&mut round, line);
}
Ok(round)
}
fn apply_line(round: &mut Option<WorkflowRound>, line: CheckpointLine) {
match line {
CheckpointLine::Run {
at,
doc,
revision,
state,
} => {
*round = Some(WorkflowRound {
at,
document_asset_id: doc.asset_id,
base_revision: revision,
base_state: state,
recognize_dto: None,
recognize_state: None,
separate_dto: None,
separate_state: None,
backfill_errors: Vec::new(),
outcome: RoundOutcome::Open,
});
}
CheckpointLine::Recognize { dto, state, .. } => {
if let Some(round) = round.as_mut() {
round.recognize_dto = Some(dto);
round.recognize_state = state;
}
}
CheckpointLine::Separate {
dto,
state,
backfill_errors,
..
} => {
if let Some(round) = round.as_mut() {
round.separate_dto = Some(dto);
round.separate_state = state;
round.backfill_errors = backfill_errors;
}
}
CheckpointLine::WriteBack { revision, .. } => {
if let Some(round) = round.as_mut() {
if round.outcome == RoundOutcome::Open {
round.outcome = RoundOutcome::WrittenBack { revision };
}
}
}
CheckpointLine::Outdated { reason, .. } => {
if let Some(round) = round.as_mut() {
if round.outcome == RoundOutcome::Open {
round.outcome = RoundOutcome::Outdated { reason };
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn log_path(directory: &tempfile::TempDir) -> PathBuf {
workflow_log_path(directory.path(), "ui/UI 设计 1.json").expect("workflow log path")
}
fn log(directory: &tempfile::TempDir) -> WorkflowLog {
WorkflowLog::open(directory.path(), "ui/UI 设计 1.json").expect("open workflow log")
}
fn run_line(at: u64, revision: u64) -> CheckpointLine {
CheckpointLine::Run {
at,
doc: CheckpointDocument {
asset_id: "generated-1-1".to_string(),
},
revision,
state: json!({ "ui_trees": [] }),
}
}
#[test]
fn log_path_sits_next_to_the_document() {
let directory = tempfile::tempdir().expect("temp dir");
assert_eq!(
workflow_log_path(directory.path(), "ui/UI 设计 1.json").expect("path"),
directory
.path()
.join("ui")
.join(".UI 设计 1-workflow.jsonl")
);
}
#[test]
fn open_round_reports_recorded_steps() {
let directory = tempfile::tempdir().expect("temp dir");
let log = log(&directory);
assert!(log.last_round().expect("empty log").is_none());
log.append(&run_line(10, 3)).expect("append run");
log.append(&CheckpointLine::Recognize {
at: 11,
dto: json!({ "ui_trees": [{ "src_ui_design": "page" }] }),
state: None,
})
.expect("append recognize");
let round = log.last_round().expect("scan").expect("round");
assert_eq!(round.base_revision, 3);
assert_eq!(round.document_asset_id, "generated-1-1");
assert!(round.recognize_dto.is_some());
assert!(round.recognize_state.is_none());
assert!(round.separate_dto.is_none());
assert_eq!(round.outcome, RoundOutcome::Open);
}
#[test]
fn step_snapshots_and_backfill_errors_round_trip() {
let directory = tempfile::tempdir().expect("temp dir");
let log = log(&directory);
log.append(&run_line(10, 1)).expect("append run");
log.append(&CheckpointLine::Recognize {
at: 11,
dto: json!({ "ui_trees": [] }),
state: Some(json!({ "ui_trees": [], "recognized": true })),
})
.expect("append recognize");
log.append(&CheckpointLine::Separate {
at: 12,
dto: json!({ "bound_nodes": [], "problematic_nodes": [] }),
state: Some(json!({ "ui_trees": [], "recognized": true, "separated": true })),
backfill_errors: vec!["素材缺失:assets/cut-1.png".to_string()],
})
.expect("append separate");
let round = log.last_round().expect("scan").expect("round");
assert_eq!(
round.recognize_state,
Some(json!({ "ui_trees": [], "recognized": true }))
);
assert_eq!(
round.separate_state,
Some(json!({ "ui_trees": [], "recognized": true, "separated": true }))
);
assert_eq!(
round.backfill_errors,
vec!["素材缺失:assets/cut-1.png".to_string()]
);
}
#[test]
fn legacy_lines_without_step_snapshots_still_parse() {
let directory = tempfile::tempdir().expect("temp dir");
std::fs::create_dir_all(log_path(&directory).parent().expect("parent"))
.expect("create dir");
// 旧版日志只有 dto、没有状态快照:必须仍然可读,缺失的步骤由调用方按未完成处理。
std::fs::write(
log_path(&directory),
concat!(
"{\"type\":\"run\",\"at\":1,\"doc\":{\"assetId\":\"generated-1-1\"},\"revision\":3,",
"\"state\":{\"ui_trees\":[]}}\n",
"{\"type\":\"recognize\",\"at\":2,\"dto\":{\"ui_trees\":[]}}\n",
"{\"type\":\"separate\",\"at\":3,\"dto\":{\"bound_nodes\":[],\"problematic_nodes\":[]}}\n",
),
)
.expect("write legacy log");
let round = log(&directory)
.last_round()
.expect("legacy log parses")
.expect("round");
assert!(round.recognize_dto.is_some());
assert!(round.recognize_state.is_none());
assert!(round.separate_dto.is_some());
assert!(round.separate_state.is_none());
assert!(round.backfill_errors.is_empty());
}
#[test]
fn later_rounds_shadow_earlier_ones() {
let directory = tempfile::tempdir().expect("temp dir");
let log = log(&directory);
log.append(&run_line(10, 1)).expect("append run");
log.append(&CheckpointLine::WriteBack {
at: 11,
revision: 2,
})
.expect("append write back");
log.append(&run_line(20, 2)).expect("append second run");
log.append(&CheckpointLine::Outdated {
at: 21,
reason: "doc-revision-drift".to_string(),
})
.expect("append outdated");
let round = log.last_round().expect("scan").expect("round");
assert_eq!(round.base_revision, 2);
assert_eq!(
round.outcome,
RoundOutcome::Outdated {
reason: "doc-revision-drift".to_string(),
}
);
}
#[test]
fn written_back_round_is_closed() {
let directory = tempfile::tempdir().expect("temp dir");
let log = log(&directory);
log.append(&run_line(10, 1)).expect("append run");
log.append(&CheckpointLine::WriteBack {
at: 11,
revision: 2,
})
.expect("append write back");
let round = log.last_round().expect("scan").expect("round");
assert_eq!(round.outcome, RoundOutcome::WrittenBack { revision: 2 });
}
#[test]
fn half_written_line_is_dropped_on_append() {
let directory = tempfile::tempdir().expect("temp dir");
let log = log(&directory);
log.append(&run_line(10, 1)).expect("append run");
{
let mut file = OpenOptions::new()
.append(true)
.open(log_path(&directory))
.expect("open raw log");
file.write_all(b"{\"type\":\"separate\",\"at\":12,\"dt")
.expect("write torn tail");
}
let round = log.last_round().expect("scan").expect("round");
assert!(round.separate_dto.is_none());
assert_eq!(round.outcome, RoundOutcome::Open);
log.append(&CheckpointLine::WriteBack {
at: 13,
revision: 2,
})
.expect("append after torn tail");
let round = log.last_round().expect("scan").expect("round");
assert_eq!(round.outcome, RoundOutcome::WrittenBack { revision: 2 });
let content = std::fs::read_to_string(log_path(&directory)).expect("read log");
assert!(!content.contains("\"dt"));
assert!(content.ends_with('\n'));
}
#[test]
fn malformed_complete_line_fails_closed() {
let directory = tempfile::tempdir().expect("temp dir");
let log = log(&directory);
std::fs::create_dir_all(log_path(&directory).parent().expect("parent"))
.expect("create dir");
std::fs::write(log_path(&directory), "{\"type\":\"nonsense\"}\n").expect("write bad line");
assert!(log.last_round().is_err());
}
}
@@ -0,0 +1,391 @@
use crate::ui_editor::persistence::{
initialize_ui_design_state_with_images_at, UiDesignDocumentImage, UI_DESIGN_DOC_MEDIA_TYPE,
UI_DESIGN_STATE_MAX_IMAGES,
};
use crate::{
acquire_project_write_lock, advance_agent_runtime_project_revision_locked,
enforce_project_permission_policy, ensure_game_creator_private_directory_tree,
harden_new_game_creator_private_path, normalize_relative_path,
prepare_game_creator_private_path_for_read, read_existing_manifest_for_project,
register_local_asset_at, resolve_local_project_path, write_manifest, GameCreationAppAssetKind,
GameCreationAppAssetManifestEntry, GameCreationAppAssetSource, GameCreationAppAssetSourceKind,
GameCreationAppManifest,
};
use image::GenericImageView;
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
use std::fs;
use std::path::Path;
/// 一张设计图的输入引用:给已登记资源的 `assetId`,或给项目内相对路径。
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub(crate) struct UiDesignImageReference {
pub(crate) asset_id: Option<String>,
pub(crate) path: Option<String>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub(crate) struct CreateUiDesignDocFromImagesInput {
pub(crate) project_path: String,
pub(crate) expected_project_id: String,
pub(crate) images: Vec<UiDesignImageReference>,
}
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct UiDesignDocCreated {
pub(crate) asset: GameCreationAppAssetManifestEntry,
pub(crate) manifest: GameCreationAppManifest,
pub(crate) relative_path: String,
pub(crate) image_ids: Vec<String>,
pub(crate) committed_project_revision: u64,
}
/// 每次调用都新建一份 UI 设计文档:不做「原型 → 已存在文档」的复用查找。
pub(crate) fn create_ui_design_doc_from_images(
input: CreateUiDesignDocFromImagesInput,
) -> Result<UiDesignDocCreated, String> {
let root = Path::new(input.project_path.trim());
let expected_project_id = input.expected_project_id.trim();
enforce_project_permission_policy(root, "asset.register")?;
if input.images.is_empty() {
return Err("至少需要一张设计图".to_string());
}
if input.images.len() > UI_DESIGN_STATE_MAX_IMAGES {
return Err(format!(
"一份 UI 设计文档最多 {UI_DESIGN_STATE_MAX_IMAGES} 张设计图"
));
}
let _lock = acquire_project_write_lock(root, "asset.register")?;
let manifest = read_existing_manifest_for_project(root)?;
if manifest.project_id != expected_project_id {
return Err("project-identity-conflict".to_string());
}
let (images, registered_image_ids) = prepare_design_images(root, &manifest, &input.images)?;
let (resource_name, relative_path) = next_ui_design_path(root, &manifest)?;
let absolute_path = create_ui_design_document_file(root, &relative_path)?;
let asset = match register_local_asset_at(
root,
&relative_path,
GameCreationAppAssetKind::UiDesignDoc,
UI_DESIGN_DOC_MEDIA_TYPE,
"generated",
GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Generated,
canvas_project_id: None,
resource_id: Some(format!(
"ui:{}",
resource_name.trim_start_matches("UI 设计 ")
)),
asset_object_id: None,
task_id: None,
prompt: None,
model: None,
generation_route: None,
generation_kind: None,
reference_resource_ids: Vec::new(),
},
) {
Ok(asset) => asset,
Err(error) => {
let _ = fs::remove_file(&absolute_path);
// 文档没登记成功时,本次顺带登记的设计图也不该留在 manifest 里。
return match drop_manifest_assets(root, &[], &registered_image_ids) {
Ok(()) => Err(error),
Err(rollback_error) => Err(format!(
"UI 设计文档登记失败:{error}reconciliation-required: 回滚未完成:{rollback_error}"
)),
};
}
};
if let Err(error) =
initialize_ui_design_state_with_images_at(root, expected_project_id, &asset.id, &images)
{
return match rollback_created_document(root, &asset.id, &registered_image_ids, &absolute_path)
{
Ok(()) => Err(error),
Err(rollback_error) => Err(format!(
"UI 设计文档初始化失败:{error}reconciliation-required: 回滚未完成:{rollback_error}"
)),
};
}
let committed_project_revision =
advance_agent_runtime_project_revision_locked(root).map_err(|error| {
format!("reconciliation-required: UI 设计文档已创建,但项目 revision 未能推进:{error}")
})?;
let manifest = read_existing_manifest_for_project(root)?;
let asset = manifest
.assets
.iter()
.find(|entry| entry.id == asset.id)
.cloned()
.ok_or_else(|| "UI 设计文档创建后无法从 manifest 回读".to_string())?;
Ok(UiDesignDocCreated {
asset,
manifest,
relative_path,
image_ids: images.into_iter().map(|image| image.image_id).collect(),
committed_project_revision,
})
}
/// 只读校验出来的设计图引用:资源身份已确定,但还没写 manifest。
enum ResolvedDesignImage {
/// manifest 里已有这张图片,本次直接复用它的资源身份。
Registered {
asset_id: String,
relative_path: String,
pixel_size: (u32, u32),
},
/// 本次调用需要新登记的设计图,登记后才有 assetId。
Pending {
relative_path: String,
pixel_size: (u32, u32),
media_type: &'static str,
},
}
impl ResolvedDesignImage {
/// 同批次查重键:已登记图片按资源身份,待登记图片按归一化路径。
fn identity(&self) -> String {
match self {
ResolvedDesignImage::Registered { asset_id, .. } => asset_id.clone(),
ResolvedDesignImage::Pending { relative_path, .. } => {
format!("path:{relative_path}")
}
}
}
}
/// 先整批校验、再登记,任何一张不合法都不会在 manifest 里留下无人引用的图片条目。
fn prepare_design_images(
root: &Path,
manifest: &GameCreationAppManifest,
references: &[UiDesignImageReference],
) -> Result<(Vec<UiDesignDocumentImage>, Vec<String>), String> {
let mut resolved = Vec::with_capacity(references.len());
let mut seen = BTreeSet::new();
for reference in references {
let image = resolve_design_image(root, manifest, reference)?;
if !seen.insert(image.identity()) {
return Err("同一次调用不能重复登记同一张设计图".to_string());
}
resolved.push(image);
}
let mut images = Vec::with_capacity(resolved.len());
let mut registered_ids = Vec::new();
for image in resolved {
let (asset_id, relative_path, pixel_size) = match image {
ResolvedDesignImage::Registered {
asset_id,
relative_path,
pixel_size,
} => (asset_id, relative_path, pixel_size),
ResolvedDesignImage::Pending {
relative_path,
pixel_size,
media_type,
} => {
let registered = register_local_asset_at(
root,
&relative_path,
GameCreationAppAssetKind::UiDesign,
media_type,
"ui-design",
GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Uploaded,
canvas_project_id: None,
resource_id: None,
asset_object_id: None,
task_id: None,
prompt: None,
model: None,
generation_route: None,
generation_kind: None,
reference_resource_ids: Vec::new(),
},
)?;
registered_ids.push(registered.id.clone());
(registered.id, relative_path, pixel_size)
}
};
images.push(UiDesignDocumentImage {
image_id: asset_id,
path: relative_path,
pixel_size,
});
}
Ok((images, registered_ids))
}
/// 解析一张设计图引用并读取它的真实尺寸:只读,不写 manifest。
fn resolve_design_image(
root: &Path,
manifest: &GameCreationAppManifest,
reference: &UiDesignImageReference,
) -> Result<ResolvedDesignImage, String> {
let (asset_id, relative_path, media_type): (Option<String>, String, Option<&'static str>) =
match (reference.asset_id.as_deref(), reference.path.as_deref()) {
(Some(asset_id), None) => {
let asset_id = asset_id.trim();
let asset = manifest
.assets
.iter()
.find(|asset| asset.id == asset_id)
.ok_or_else(|| format!("设计图资源不存在:{asset_id}"))?;
require_image_media_type(&asset.media_type)?;
(Some(asset.id.clone()), asset.local_path.clone(), None)
}
(None, Some(path)) => {
let normalized_path = normalize_relative_path(path.trim())?;
match manifest
.assets
.iter()
.find(|asset| asset.local_path == normalized_path)
{
// 已登记图片复用原资源身份,不因为本次调用改写它的 kind 与分类。
Some(existing) => {
require_image_media_type(&existing.media_type)?;
(Some(existing.id.clone()), existing.local_path.clone(), None)
}
None => {
let absolute_path = resolve_local_project_path(root, &normalized_path)?;
let media_type = design_image_media_type(&absolute_path)?;
(None, normalized_path, Some(media_type))
}
}
}
_ => return Err("每张设计图必须且只能给 assetId 或 path".to_string()),
};
let absolute_path = resolve_local_project_path(root, &relative_path)?;
let pixel_size = image::open(&absolute_path)
.map_err(|error| format!("读取设计图失败:{relative_path}{error}"))?
.dimensions();
if pixel_size.0 == 0 || pixel_size.1 == 0 {
return Err(format!("设计图尺寸无效:{relative_path}"));
}
match (asset_id, media_type) {
(Some(asset_id), _) => Ok(ResolvedDesignImage::Registered {
asset_id,
relative_path,
pixel_size,
}),
(None, Some(media_type)) => Ok(ResolvedDesignImage::Pending {
relative_path,
pixel_size,
media_type,
}),
// 上面两条已覆盖 (assetId) 与 (path 未登记) 两种解析结果。
(None, None) => Err("每张设计图必须且只能给 assetId 或 path".to_string()),
}
}
fn require_image_media_type(media_type: &str) -> Result<(), String> {
if media_type.to_ascii_lowercase().starts_with("image/") {
Ok(())
} else {
Err("设计图必须是图片资源".to_string())
}
}
fn design_image_media_type(absolute_path: &Path) -> Result<&'static str, String> {
let format = image::ImageFormat::from_path(absolute_path)
.map_err(|error| format!("无法识别设计图格式:{}{error}", absolute_path.display()))?;
match format {
image::ImageFormat::Png => Ok("image/png"),
image::ImageFormat::Jpeg => Ok("image/jpeg"),
image::ImageFormat::Gif => Ok("image/gif"),
image::ImageFormat::WebP => Ok("image/webp"),
image::ImageFormat::Bmp => Ok("image/bmp"),
_ => Err("设计图只支持 png/jpeg/gif/webp/bmp".to_string()),
}
}
fn create_ui_design_document_file(
root: &Path,
relative_path: &str,
) -> Result<std::path::PathBuf, String> {
let absolute_path = resolve_local_project_path(root, relative_path)?;
if let Some(parent) = absolute_path.parent() {
ensure_game_creator_private_directory_tree(parent, "UI 资源目录")?;
prepare_game_creator_private_path_for_read(parent, true, "UI 资源目录")?;
}
if prepare_game_creator_private_path_for_read(&absolute_path, false, "UI 资源")? {
return Err("UI 设计资源路径已存在,拒绝覆盖".to_string());
}
let mut options = fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(windows)]
{
use std::os::windows::fs::OpenOptionsExt;
options.custom_flags(crate::PROJECT_FILE_FLAG_OPEN_REPARSE_POINT);
}
let file = options
.open(&absolute_path)
.map_err(|error| format!("创建 UI 资源失败:{}{error}", absolute_path.display()))?;
if let Err(error) = harden_new_game_creator_private_path(&absolute_path, false, "UI 资源") {
drop(file);
let _ = fs::remove_file(&absolute_path);
return Err(error);
}
drop(file);
Ok(absolute_path)
}
fn rollback_created_document(
root: &Path,
asset_id: &str,
registered_image_ids: &[String],
absolute_path: &Path,
) -> Result<(), String> {
drop_manifest_assets(root, &[asset_id], registered_image_ids)?;
fs::remove_file(absolute_path).map_err(|error| format!("删除未完成 UI 设计资源失败:{error}"))
}
/// 把本次新建或新登记、但已判定失败的资源条目从 manifest 里摘掉。
fn drop_manifest_assets(
root: &Path,
asset_ids: &[&str],
registered_image_ids: &[String],
) -> Result<(), String> {
let mut manifest = read_existing_manifest_for_project(root)?;
manifest.assets.retain(|entry| {
!asset_ids.contains(&entry.id.as_str()) && !registered_image_ids.contains(&entry.id)
});
write_manifest(&root.join(".agent/manifest.json"), &manifest)
}
/// UI 设计文档的确定性命名:从已登记文档数 + 1 起找第一个既未被占用、
/// 也未登记进 manifest 的 `ui/UI 设计 N.json`,不覆盖任何既有文件。
pub(crate) fn next_ui_design_path(
root: &Path,
manifest: &GameCreationAppManifest,
) -> Result<(String, String), String> {
let mut index = manifest
.assets
.iter()
.filter(|asset| {
asset.kind == GameCreationAppAssetKind::UiDesignDoc
&& asset.media_type == UI_DESIGN_DOC_MEDIA_TYPE
})
.count()
+ 1;
loop {
let resource_name = format!("UI 设计 {index}");
let relative_path = format!("ui/{resource_name}.json");
let path = resolve_local_project_path(root, &relative_path)?;
if !path.exists()
&& !manifest
.assets
.iter()
.any(|asset| asset.local_path == relative_path)
{
return Ok((resource_name, relative_path));
}
index = index
.checked_add(1)
.ok_or_else(|| "UI 设计资源编号已达到上限".to_string())?;
}
}
@@ -0,0 +1,13 @@
mod checkpoint;
mod creation;
mod run_workflow;
mod steps;
#[cfg(test)]
mod test_support;
pub(crate) use creation::{
create_ui_design_doc_from_images, CreateUiDesignDocFromImagesInput, UiDesignDocCreated,
UiDesignImageReference,
};
pub(crate) use run_workflow::{run_ui_design_doc_workflow, RunUiDesignDocWorkflowInput};

Some files were not shown because too many files have changed in this diff Show More