新增 run-workflow 编排、切图素材登记与检查点工具函数

- 新增 design_doc/run_workflow.rs:recognize → separate → write-back 编排,按检查点行恢复,文档漂移追加 outdated 并报错
- 新增 design_doc/cut_images.rs:按切分路径登记图片并构造 SpriteAsset,按路径复用 manifest 条目、失败不回滚
- persistence 新增 load_ui_design_document_snapshot_at 并让 load_ui_design_state_at 复用它
- checkpoint 新增 checkpoint_timestamp 秒级时间戳
- 切分路径归一下沉到 steps/mod.rs,separate 步骤改用共享实现
- 技术方案同步 design_doc 模块布局与 into-js 复用 persistence 的说明
This commit is contained in:
2026-09-23 21:08:52 +08:00
parent 3c4c546ed5
commit 116d9d5940
8 changed files with 477 additions and 17 deletions
@@ -9,6 +9,7 @@ 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;
@@ -124,6 +125,14 @@ impl WorkflowLog {
}
}
/// 检查点行的时间戳:秒级 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,
@@ -0,0 +1,157 @@
//! 登记自动切分产出的图片并构造切分素材。镜像前端 `useUiEditorPage.ts` 的
//! `import_local_project_image_assets` → `prepareSpriteAssetBatch` 两段。
//!
//! 登记按路径复用已有 manifest 条目,所以重放不会重复登记;登记失败的路径只回报
//! 说明,不回滚已经登记成功的资源。
use super::steps::normalize_cut_image_path;
use crate::import_local_project_assets_for_agent;
use crate::ui_editor::commands::SeparationDTO;
use crate::ui_editor::resource::sprite::SpriteAsset;
use crate::ui_editor::utils::SpriteAssetId;
use crate::{resolve_local_project_path, ImportedAsset};
use image::GenericImageView;
use nalgebra::Vector2;
use std::collections::HashMap;
use std::path::Path;
use typed_floats::tf32::StrictlyPositiveFinite;
/// 单批登记上限,与前端 `SEPARATION_IMPORT_BATCH_SIZE` 一致。
const CUT_IMAGE_IMPORT_BATCH_SIZE: usize = 100;
pub(crate) struct CutImageSprites {
/// 本次切分产出的素材(按资源 id 去重)。
pub(crate) sprites: Vec<SpriteAsset>,
/// 切分路径(含导入后回填的路径)到素材的映射,供回填按路径查找。
pub(crate) by_path: HashMap<String, SpriteAsset>,
/// 未能登记的路径说明。
pub(crate) errors: Vec<String>,
}
pub(crate) fn register_cut_image_sprites(
root: &Path,
dto: &SeparationDTO,
) -> Result<CutImageSprites, String> {
let unique_paths = unique_cut_image_paths(dto);
if unique_paths.is_empty() {
return Ok(CutImageSprites {
sprites: Vec::new(),
by_path: HashMap::new(),
errors: Vec::new(),
});
}
let mut imported_by_path: Vec<(String, ImportedAsset)> = Vec::new();
for batch in unique_paths.chunks(CUT_IMAGE_IMPORT_BATCH_SIZE) {
let imported = import_local_project_assets_for_agent(root, batch)?;
if imported.assets.len() != batch.len() {
return Err(format!(
"本地资源登记结果数量不匹配:请求 {} 个,返回 {} 个",
batch.len(),
imported.assets.len()
));
}
for (index, asset) in imported.assets.into_iter().enumerate() {
let normalized = normalize_cut_image_path(&asset.local_path);
if let Some(requested) = batch.get(index) {
// 导入可能把文件复制到 assets/uploads,切分路径与素材路径都要能查到。
imported_by_path.push((requested.clone(), asset.clone()));
}
imported_by_path.push((normalized, asset));
}
}
let mut errors = Vec::new();
for path in &unique_paths {
if !imported_by_path
.iter()
.any(|(imported, _)| imported == path)
{
errors.push(format!("未能登记自动切分素材图片:{path}"));
}
}
let mut sprites = Vec::new();
let mut sprite_by_id: HashMap<String, SpriteAsset> = HashMap::new();
for (_, asset) in &imported_by_path {
if sprite_by_id.contains_key(&asset.id) {
continue;
}
let sprite = sprite_from_registered_image(root, asset)?;
sprite_by_id.insert(asset.id.clone(), sprite.clone());
sprites.push(sprite);
}
let by_path = imported_by_path
.iter()
.filter_map(|(path, asset)| {
sprite_by_id
.get(&asset.id)
.map(|sprite| (path.clone(), sprite.clone()))
})
.collect();
Ok(CutImageSprites {
sprites,
by_path,
errors,
})
}
/// 按 `bound_nodes` 顺序取去重后的切分路径,镜像前端的 `new Set(map(...))`。
pub(crate) fn unique_cut_image_paths(dto: &SeparationDTO) -> Vec<String> {
let mut paths = Vec::new();
for bound in &dto.bound_nodes {
let path = normalize_cut_image_path(&bound.cut_image_path);
if !paths.contains(&path) {
paths.push(path);
}
}
paths
}
fn sprite_from_registered_image(root: &Path, asset: &ImportedAsset) -> Result<SpriteAsset, String> {
let absolute_path = resolve_local_project_path(root, &asset.local_path)?;
let dimensions = image::open(&absolute_path)
.map_err(|error| format!("读取自动切分素材失败:{}:{error}", asset.local_path))?
.dimensions();
if dimensions.0 == 0 || dimensions.1 == 0 {
return Err(format!("自动切分素材尺寸无效:{}", asset.local_path));
}
let asset_id = SpriteAssetId::new(asset.id.clone())
.map_err(|error| format!("自动切分素材 ID 无效:{}:{error}", asset.id))?;
let pixels_per_unit =
StrictlyPositiveFinite::new(1.0).map_err(|_| "自动切分素材像素单位无效".to_string())?;
SpriteAsset::from_registered_image(
asset_id,
normalize_cut_image_path(&asset.local_path),
Vector2::new(dimensions.0 as f32, dimensions.1 as f32),
pixels_per_unit,
)
.map_err(|error| format!("自动切分素材无效:{}:{error}", asset.local_path))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ui_editor::commands::separation::BoundNode;
use crate::ui_editor::utils::NodeId;
fn bound(cut_path: &str) -> BoundNode {
BoundNode {
node_id: NodeId::new("node-a".to_string()).expect("node id"),
cut_image_path: cut_path.to_string(),
}
}
#[test]
fn unique_paths_normalize_separators_and_drop_duplicates() {
let dto = SeparationDTO {
bound_nodes: vec![
bound("/ui/cut 1.png"),
bound("ui\\cut 1.png"),
bound("ui/cut 2.png"),
],
problematic_nodes: Vec::new(),
};
assert_eq!(
unique_cut_image_paths(&dto),
vec!["ui/cut 1.png".to_string(), "ui/cut 2.png".to_string()]
);
}
}
@@ -1,5 +1,7 @@
mod checkpoint;
mod creation;
mod cut_images;
mod run_workflow;
mod steps;
#[cfg(test)]
@@ -9,3 +11,4 @@ pub(crate) use creation::{
create_ui_design_doc_from_images, next_ui_design_path, CreateUiDesignDocFromImagesInput,
UiDesignDocCreated, UiDesignImageReference,
};
pub(crate) use run_workflow::{run_ui_design_doc_workflow, RunUiDesignDocWorkflowInput};
@@ -0,0 +1,272 @@
//! `ui-design-doc.run-workflow` 的编排:`recognize` → `separate` → `write-back`,
//! 以及基于 JSONL 检查点的崩溃恢复。
//!
//! 每步只做自己的事:识别结果落 State、切分结果落 State、最后写回文档。恢复判据
//! 只有一条——这一步有没有对应的检查点行;文档中途漂移时追加 `outdated` 并报错,
//! 不在同一次调用里自动重开新一轮。
use super::checkpoint::{
checkpoint_timestamp, CheckpointDocument, CheckpointLine, RoundOutcome, WorkflowLog,
WorkflowRound,
};
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::{
recognize_ui_impl_with_provider, separate_ui_impl, RecognitionDTO, SeparationDTO,
};
use crate::ui_editor::persistence::{
load_ui_design_document_snapshot_at, save_ui_design_state_at, SaveUiDesignStateInput,
SaveUiDesignStateResult, UiDesignStateSnapshot,
};
use crate::ui_editor::state::State;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::path::Path;
const WORKFLOW_COMMAND_ID: &str = "ui_design.workflow";
const DRIFT_REASON: &str = "doc-state-drift";
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub(crate) struct RunUiDesignDocWorkflowInput {
pub(crate) project_path: String,
pub(crate) expected_project_id: String,
pub(crate) asset_id: String,
}
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct RunUiDesignDocWorkflowOutput {
pub(crate) asset_id: String,
pub(crate) relative_path: String,
pub(crate) revision: u64,
pub(crate) recovered_from_checkpoint: bool,
pub(crate) recognized_tree_count: usize,
pub(crate) bound_node_count: usize,
pub(crate) problematic_node_count: usize,
pub(crate) backfill_errors: Vec<String>,
}
/// 一轮已经推进到哪一步:已记录的 DTO 直接复用,缺失的步骤才真正执行。
struct WorkflowProgress {
state: State,
recorded_recognize: Option<RecognitionDTO>,
recorded_separate: Option<SeparationDTO>,
backfill_errors: Vec<String>,
/// 文档已经是重建出的目标 State,只差补 `write-back` 行。
already_written_back: bool,
}
pub(crate) async fn run_ui_design_doc_workflow(
input: RunUiDesignDocWorkflowInput,
provider_identity: Option<(&str, &str)>,
) -> Result<RunUiDesignDocWorkflowOutput, String> {
let root = Path::new(input.project_path.trim());
enforce_project_permission_policy(root, WORKFLOW_COMMAND_ID)?;
let (relative_path, snapshot) =
load_ui_design_document_snapshot_at(root, &input.expected_project_id, &input.asset_id)?;
let log = WorkflowLog::open(root, &relative_path)?;
let resume = log
.last_round()?
.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)?,
None => {
let state = snapshot.state.clone();
log.append(&CheckpointLine::Run {
at: checkpoint_timestamp(),
doc: CheckpointDocument {
asset_id: input.asset_id.clone(),
},
revision: snapshot.revision,
state: to_checkpoint_value(&state)?,
})?;
WorkflowProgress {
state,
recorded_recognize: None,
recorded_separate: None,
backfill_errors: Vec::new(),
already_written_back: false,
}
}
};
let recognize_dto = match progress.recorded_recognize.take() {
Some(dto) => dto,
None => {
let dto = recognize_ui_impl_with_provider(
input.project_path.trim().to_string(),
progress.state.clone(),
provider_identity,
)
.await?;
log.append(&CheckpointLine::Recognize {
at: checkpoint_timestamp(),
dto: to_checkpoint_value(&dto)?,
})?;
dto
}
};
apply_recognition(&mut progress.state, &recognize_dto)?;
let separate_dto = match progress.recorded_separate.take() {
Some(dto) => dto,
None => {
let dto = separate_ui_impl(
input.project_path.trim().to_string(),
input.asset_id.clone(),
progress.state.clone(),
)
.await?;
log.append(&CheckpointLine::Separate {
at: checkpoint_timestamp(),
dto: to_checkpoint_value(&dto)?,
})?;
dto
}
};
// 切图登记按路径复用 manifest 条目;失败只回报说明,不回滚已登记资源。
let cut_images = register_cut_image_sprites(root, &separate_dto)?;
progress.backfill_errors.extend(cut_images.errors);
progress.state = add_sprite_assets(&progress.state, &cut_images.sprites)?;
progress.backfill_errors.extend(apply_separation(
&mut progress.state,
&separate_dto,
&cut_images.by_path,
));
let revision = if progress.already_written_back {
log.append(&CheckpointLine::WriteBack {
at: checkpoint_timestamp(),
revision: snapshot.revision,
})?;
snapshot.revision
} else {
write_back(root, &input, &log, snapshot.revision, &progress.state)?
};
Ok(RunUiDesignDocWorkflowOutput {
asset_id: input.asset_id,
relative_path,
revision,
recovered_from_checkpoint,
recognized_tree_count: recognize_dto.ui_trees.len(),
bound_node_count: separate_dto.bound_nodes.len(),
problematic_node_count: separate_dto.problematic_nodes.len(),
backfill_errors: progress.backfill_errors,
})
}
/// 恢复一轮未完成的工作流:先按记录重建目标 State,若文档已经等于目标 State,
/// 说明保存成功但缺 `write-back` 行,直接补行;否则文档必须仍是本轮起点,否则判
/// 漂移并追加 `outdated`。
fn resume_progress(
root: &Path,
log: &WorkflowLog,
snapshot: &UiDesignStateSnapshot,
round: &WorkflowRound,
) -> Result<WorkflowProgress, String> {
let state: State = serde_json::from_value(round.base_state.clone())
.map_err(|error| format!("工作流检查点的起始 State 无法还原:{error}"))?;
let recorded_recognize = round
.recognize_dto
.clone()
.map(serde_json::from_value::<RecognitionDTO>)
.transpose()
.map_err(|error| format!("工作流检查点的识别结果无法还原:{error}"))?;
let recorded_separate = round
.separate_dto
.clone()
.map(serde_json::from_value::<SeparationDTO>)
.transpose()
.map_err(|error| format!("工作流检查点的切分结果无法还原:{error}"))?;
if let Some(separate) = &recorded_separate {
let (target, backfill_errors) =
rebuild_target_state(root, &state, recorded_recognize.as_ref(), separate)?;
if snapshot.state == target {
return Ok(WorkflowProgress {
state: target,
recorded_recognize,
recorded_separate,
backfill_errors,
already_written_back: true,
});
}
}
if snapshot.state != state {
log.append(&CheckpointLine::Outdated {
at: checkpoint_timestamp(),
reason: DRIFT_REASON.to_string(),
})?;
return Err(
"UI 设计文档在本次工作流中途被改动,本轮已作废;请重新调用 run-workflow 开新一轮"
.to_string(),
);
}
Ok(WorkflowProgress {
state,
recorded_recognize,
recorded_separate,
backfill_errors: Vec::new(),
already_written_back: false,
})
}
/// 用已记录的 DTO 重建切分后的 State,供写回幂等判定复用。
fn rebuild_target_state(
root: &Path,
base: &State,
recognize: Option<&RecognitionDTO>,
separate: &SeparationDTO,
) -> Result<(State, Vec<String>), String> {
let mut state = base.clone();
if let Some(dto) = recognize {
apply_recognition(&mut state, dto)?;
}
let cut_images = register_cut_image_sprites(root, separate)?;
let mut backfill_errors = cut_images.errors;
state = add_sprite_assets(&state, &cut_images.sprites)?;
backfill_errors.extend(apply_separation(&mut state, separate, &cut_images.by_path));
Ok((state, backfill_errors))
}
fn write_back(
root: &Path,
input: &RunUiDesignDocWorkflowInput,
log: &WorkflowLog,
expected_revision: u64,
state: &State,
) -> Result<u64, String> {
match save_ui_design_state_at(SaveUiDesignStateInput {
project_path: input.project_path.trim().to_string(),
expected_project_id: input.expected_project_id.clone(),
asset_id: input.asset_id.clone(),
expected_revision,
state: state.clone(),
})? {
SaveUiDesignStateResult::Saved { revision, .. }
| SaveUiDesignStateResult::Unchanged { revision, .. } => {
log.append(&CheckpointLine::WriteBack {
at: checkpoint_timestamp(),
revision,
})?;
Ok(revision)
}
SaveUiDesignStateResult::Conflict { .. } => {
log.append(&CheckpointLine::Outdated {
at: checkpoint_timestamp(),
reason: DRIFT_REASON.to_string(),
})?;
Err(
"UI 设计文档在本次工作流中途被改动,本轮已作废;请重新调用 run-workflow 开新一轮"
.to_string(),
)
}
}
}
fn to_checkpoint_value<T: Serialize>(value: &T) -> Result<Value, String> {
serde_json::to_value(value).map_err(|error| format!("序列化工作流检查点行失败:{error}"))
}
@@ -1,2 +1,7 @@
pub(crate) mod recognize;
pub(crate) mod separate;
/// 镜像前端 `normalizeProjectRelativePath`:只做分隔符与根斜杠归一,不做路径校验。
pub(crate) fn normalize_cut_image_path(path: &str) -> String {
path.replace('\\', "/").trim_start_matches('/').to_string()
}
@@ -121,7 +121,7 @@ fn backfill_bound_node(
sprite_by_path: &HashMap<String, SpriteAsset>,
errors: &mut Vec<String>,
) {
let path = normalize_cut_path(&bound.cut_image_path);
let path = super::normalize_cut_image_path(&bound.cut_image_path);
let Some(sprite) = sprite_by_path.get(&path) else {
errors.push(format!(
"节点 {} 缺少已登记的自动切分素材图片:{path}",
@@ -166,11 +166,6 @@ fn backfill_bound_node(
}
}
/// 镜像前端 `normalizeProjectRelativePath`:只做分隔符与根斜杠归一,不做路径校验。
fn normalize_cut_path(path: &str) -> String {
path.replace('\\', "/").trim_start_matches('/').to_string()
}
fn sprite_asset_error(sprite: &SpriteAsset) -> Result<(), String> {
if sprite.asset_id().as_str().trim().is_empty() {
return Err("缺少素材 ID".to_string());
@@ -169,16 +169,28 @@ pub(crate) fn load_ui_design_state_at(
input: LoadUiDesignStateInput,
) -> Result<UiDesignStateSnapshot, String> {
let root = Path::new(input.project_path.trim());
let expected_project_id = required_identifier(&input.expected_project_id, "expectedProjectId")?;
let asset_id = required_identifier(&input.asset_id, "assetId")?;
Ok(load_ui_design_document_snapshot_at(root, &input.expected_project_id, &input.asset_id)?.1)
}
/// 读取文档的登记路径与当前快照。工作流编排要据此定位检查点日志与起始 revision。
pub(crate) fn load_ui_design_document_snapshot_at(
root: &Path,
expected_project_id: &str,
asset_id: &str,
) -> Result<(String, UiDesignStateSnapshot), String> {
let expected_project_id = required_identifier(expected_project_id, "expectedProjectId")?;
let asset_id = required_identifier(asset_id, "assetId")?;
let asset = ui_design_asset(root, &expected_project_id, &asset_id)?;
let document =
read_ui_design_document(root, &asset.local_path, &expected_project_id, &asset_id)?;
validate_document(&document, &expected_project_id, &asset_id)?;
Ok(UiDesignStateSnapshot {
revision: document.revision,
state: document.state,
})
Ok((
asset.local_path,
UiDesignStateSnapshot {
revision: document.revision,
state: document.state,
},
))
}
pub(crate) fn generate_ui_design_code_at(
@@ -64,14 +64,21 @@
```text
src/ui_editor/design_doc/
├─ mod.rs 三个工具的对外入口与入参校验
├─ creation.rs from-images:登记图片、建文档、manifest 注册、命名取号
├─ checkpoint.rs JSONL 追加、读取、轮次判定
├─ run_workflow.rs recognize → separate → write-back 编排与恢复
└─ into_js.rs 复用 persistence 的代码生成
├─ mod.rs 模块声明与三个工具的对外导出
├─ creation.rs from-images:登记图片、建文档、manifest 注册、命名取号
├─ checkpoint.rs JSONL 追加、读取、轮次判定
├─ cut_images.rs 切图图片登记与 SpriteAsset 构造
├─ run_workflow.rs recognize → separate → write-back 编排与恢复
├─ steps/
│ ├─ recognize.rs 识别 DTO 落 State
│ └─ separate.rs 切分 DTO 落 State(回填、清状态、写 NeedReview)
└─ test_support.rs design_doc 单测共用夹具
src/agent/runtime_tools/ui_design_doc.rs 工具参数解析与 Runtime 侧调用
```
`into-js` 不需要独立模块:它直接复用 `persistence.rs` 的 `generate_ui_design_code_at`,
该入口本来就只渲染 `ui/generated-<stem>-<digest>.js` 且不推进项目 revision。
每个文件只承担一件事;`checkpoint.rs` 不感知切分,`creation.rs` 不感知识别。
### 提示词目录模块