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
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:
+17
@@ -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
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
+4
-2
@@ -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()
|
||||
|
||||
+11
-1
@@ -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"), ¤t)?;
|
||||
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, &[], ®istered_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, ®istered_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
Reference in New Issue
Block a user