diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/design_doc/checkpoint.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/design_doc/checkpoint.rs index ac122e83a..28daca2f2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/design_doc/checkpoint.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/design_doc/checkpoint.rs @@ -85,10 +85,6 @@ impl WorkflowLog { }) } - pub(crate) fn path(&self) -> &Path { - &self.path - } - /// 读取最后一轮。文件不存在或没有完整 `run` 行时返回 `None`。 pub(crate) fn last_round(&self) -> Result, String> { let Some(content) = read_log_content(&self.path)? else { @@ -274,6 +270,10 @@ 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") } @@ -368,7 +368,7 @@ mod tests { { let mut file = OpenOptions::new() .append(true) - .open(log.path()) + .open(log_path(&directory)) .expect("open raw log"); file.write_all(b"{\"type\":\"separate\",\"at\":12,\"dt") .expect("write torn tail"); @@ -383,7 +383,7 @@ mod tests { .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()).expect("read log"); + let content = std::fs::read_to_string(log_path(&directory)).expect("read log"); assert!(!content.contains("\"dt")); assert!(content.ends_with('\n')); } @@ -392,8 +392,9 @@ mod tests { fn malformed_complete_line_fails_closed() { let directory = tempfile::tempdir().expect("temp dir"); let log = log(&directory); - std::fs::create_dir_all(log.path().parent().expect("parent")).expect("create dir"); - std::fs::write(log.path(), "{\"type\":\"nonsense\"}\n").expect("write bad line"); + 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()); } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/design_doc/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/design_doc/mod.rs index 66ecc22cc..e65f7d914 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/design_doc/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/design_doc/mod.rs @@ -8,7 +8,7 @@ mod steps; mod test_support; pub(crate) use creation::{ - create_ui_design_doc_from_images, next_ui_design_path, CreateUiDesignDocFromImagesInput, - UiDesignDocCreated, UiDesignImageReference, + create_ui_design_doc_from_images, CreateUiDesignDocFromImagesInput, UiDesignDocCreated, + UiDesignImageReference, }; pub(crate) use run_workflow::{run_ui_design_doc_workflow, RunUiDesignDocWorkflowInput}; diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/design_doc/run_workflow.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/design_doc/run_workflow.rs index 48089eed2..b2aa5bb94 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/design_doc/run_workflow.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/design_doc/run_workflow.rs @@ -13,6 +13,7 @@ use super::cut_images::register_cut_image_sprites; use super::steps::recognize::apply_recognition; use super::steps::separate::{add_sprite_assets, apply_separation}; use crate::enforce_project_permission_policy; +use crate::ui_editor::commands::separation::finalize_separation; use crate::ui_editor::commands::{ recognize_ui_impl_with_provider, separate_ui_impl, RecognitionDTO, SeparationDTO, }; @@ -73,7 +74,7 @@ pub(crate) async fn run_ui_design_doc_workflow( .filter(|round| round.outcome == RoundOutcome::Open); let recovered_from_checkpoint = resume.is_some(); let mut progress = match &resume { - Some(round) => resume_progress(root, &log, &snapshot, round)?, + Some(round) => resume_progress(root, &log, &snapshot, round, &input.asset_id)?, None => { let state = snapshot.state.clone(); log.append(&CheckpointLine::Run { @@ -145,8 +146,13 @@ pub(crate) async fn run_ui_design_doc_workflow( })?; snapshot.revision } else { - write_back(root, &input, &log, snapshot.revision, &progress.state)? + write_back(&input, &log, snapshot.revision, &progress.state)? }; + // 与前端切分链路一致:干净跑完才清理 sidecar;有回填问题或问题节点时保留, + // 交给编辑器显示恢复入口。切分 op 内部的细粒度进度仍由 SeparationState 承担。 + if progress.backfill_errors.is_empty() && separate_dto.problematic_nodes.is_empty() { + finalize_separation(root, &input.asset_id)?; + } Ok(RunUiDesignDocWorkflowOutput { asset_id: input.asset_id, relative_path, @@ -167,7 +173,14 @@ fn resume_progress( log: &WorkflowLog, snapshot: &UiDesignStateSnapshot, round: &WorkflowRound, + asset_id: &str, ) -> Result { + if round.document_asset_id != asset_id { + return Err(format!( + "工作流检查点属于其它 UI 设计文档({}),拒绝按本轮恢复", + round.document_asset_id + )); + } let state: State = serde_json::from_value(round.base_state.clone()) .map_err(|error| format!("工作流检查点的起始 State 无法还原:{error}"))?; let recorded_recognize = round @@ -200,10 +213,7 @@ fn resume_progress( at: checkpoint_timestamp(), reason: DRIFT_REASON.to_string(), })?; - return Err( - "UI 设计文档在本次工作流中途被改动,本轮已作废;请重新调用 run-workflow 开新一轮" - .to_string(), - ); + return Err(drift_message(snapshot.revision, round)); } Ok(WorkflowProgress { state, @@ -233,7 +243,6 @@ fn rebuild_target_state( } fn write_back( - root: &Path, input: &RunUiDesignDocWorkflowInput, log: &WorkflowLog, expected_revision: u64, @@ -254,19 +263,27 @@ fn write_back( })?; Ok(revision) } - SaveUiDesignStateResult::Conflict { .. } => { + SaveUiDesignStateResult::Conflict { current } => { log.append(&CheckpointLine::Outdated { at: checkpoint_timestamp(), reason: DRIFT_REASON.to_string(), })?; - Err( - "UI 设计文档在本次工作流中途被改动,本轮已作废;请重新调用 run-workflow 开新一轮" - .to_string(), - ) + Err(format!( + "UI 设计文档在本次工作流中途被改动(写回期望 revision {expected_revision},当前 {}),本轮已作废;请重新调用 run-workflow 开新一轮", + current.revision + )) } } } +/// 漂移说明带上检查点轮次的基线,便于判断是外部保存还是本轮重放。 +fn drift_message(current_revision: u64, round: &WorkflowRound) -> String { + format!( + "UI 设计文档在本次工作流中途被改动(检查点第 {} 轮基线 revision {},当前 revision {current_revision}),本轮已作废;请重新调用 run-workflow 开新一轮", + round.at, round.base_revision + ) +} + fn to_checkpoint_value(value: &T) -> Result { serde_json::to_value(value).map_err(|error| format!("序列化工作流检查点行失败:{error}")) } diff --git a/docs/technical/【技术方案】UI编辑器Agent工具化重写-2026-09-23.md b/docs/technical/【技术方案】UI编辑器Agent工具化重写-2026-09-23.md index a0cd25d80..2371ef9bf 100644 --- a/docs/technical/【技术方案】UI编辑器Agent工具化重写-2026-09-23.md +++ b/docs/technical/【技术方案】UI编辑器Agent工具化重写-2026-09-23.md @@ -35,7 +35,7 @@ | --- | --- | --- | | `recognize` | `recognize_ui_impl_with_provider` | `ui_trees`(每棵树的 `src_ui_design` 指向文档内设计图) | | `separate` | `separate_ui_impl` | 切分图落盘 → 登记 asset → 转 `SpriteAsset` → 写入 State → 回填 `target_graphic`、清 `component_status`、写 `NeedReview` | -| `write-back` | `save_ui_design_state_at` | 文档新 `revision` | +| `write-back` | `save_ui_design_state_at` | 文档新 `revision`;没有回填问题且没有问题节点时再 `finalize_separation` 清理 sidecar | 不再有合并、组件绑定与页面级 profile/finalize 阶段;`recognize` 之前不做任何前置发现。 @@ -55,6 +55,8 @@ - 漂移:文档在轮次中途被改动(当前 State 与 `run` 行快照不一致)时,追加一行 `outdated` 并**返回错误**,不在同一次调用里自动重开新一轮;下一次调用看到 `outdated` 才从头开新一轮,且以当前文档为基准。 - 写回幂等:`save` 成功但 `write-back` 行没追加时,重放会重建出同一份目标 State,此时按「目标 State 与文档当前 State 相等」判定为已写完,直接补 `write-back` 行并返回成功。这条判据成立的前提是工作流这条路不产生随机身份:识别树的根节点 id 来自 DTO,切图资源 id 走 manifest 的 by-path 复用。 - 切图资源不回滚:切分产出的图片与已登记资源在失败后保留,重放靠 by-path 复用接上,不做回滚清理。 +- 写回成功后才清理 sidecar:与前端切分链路一致,`backfill_errors` 为空且 `problematic_nodes` 为空时调用 + `finalize_separation`;有回填问题或问题节点时保留 sidecar,交给编辑器显示恢复入口。 - 切分 op 内部的细粒度恢复仍由 `SeparationState` 承担(`ui-editor-separation-state.v2` sidecar),日志只记录工作流层面的步骤完成,不复制它的进度。 - 不引入跨语言 fixture 比对:镜像的四个 seam 都是简单变换,靠同语义实现与各自单测覆盖,不为它们额外维护一套 golden。