新增 UI 设计文档创建入口 create_ui_design_doc_from_images

- 新增 ui_editor/design_doc/creation.rs:由一至四张设计图新建文档,未登记路径顺带登记成 ui-design 图片资源,文档内设计图身份取图片 assetId
- persistence.rs 把单图初始化泛化为 initialize_ui_design_state_with_images_at,支持多张设计图并拒绝重复
- 删除 resource_bridge.rs 与 create_ui_design_resource、ensure_ui_design_resource_for_prototype 两个命令,取消「原型 → 已存在文档」的幂等查找
- main.rs 注册 create_ui_design_doc_from_images 并移除两个旧命令注册项
- 前端 uiDesignResourceBridge 改为调用新命令,project-development 去掉 ui-workflow.completed 的自动打开与阶段跳转分支
- 代码地图同步 design_doc 模块与关键命令列表,测试改为覆盖新入口的编号避让
This commit is contained in:
2026-09-23 18:14:15 +08:00
parent 665788760e
commit 648ead2150
11 changed files with 456 additions and 674 deletions
@@ -2140,122 +2140,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 mut file = options
.open(&absolute_path)
.map_err(|error| format!("创建 UI 资源失败:{}: {error}", absolute_path.display()))?;
if let Err(error) = harden_new_game_creator_private_path(&absolute_path, false, "UI 资源") {
drop(file);
let _ = fs::remove_file(&absolute_path);
return Err(error);
}
file.sync_all()
.map_err(|error| format!("同步 UI 资源失败:{}: {error}", absolute_path.display()))?;
drop(file);
let asset = match register_local_asset_at(
root,
&relative_path,
GameCreationAppAssetKind::UiDesignDoc,
crate::ui_editor::persistence::UI_DESIGN_DOC_MEDIA_TYPE,
"generated",
GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Generated,
canvas_project_id: None,
resource_id: Some(format!(
"ui:{}",
resource_name.trim_start_matches("UI 设计 ")
)),
asset_object_id: None,
task_id: None,
prompt: None,
model: None,
generation_route: None,
generation_kind: None,
reference_resource_ids: Vec::new(),
},
) {
Ok(asset) => asset,
Err(error) => {
let rollback = (|| {
write_manifest(&root.join(".agent/manifest.json"), &manifest)?;
fs::remove_file(&absolute_path).map_err(|remove_error| {
format!(
"删除未完成 UI 设计资源失败:{}: {remove_error}",
absolute_path.display()
)
})
})();
return match rollback {
Ok(()) => Err(error),
Err(rollback_error) => Err(format!(
"UI 设计资源登记失败:{error};reconciliation-required: 回滚未完成:{rollback_error}"
)),
};
}
};
if let Err(error) = ui_editor::persistence::initialize_ui_design_state_at(
root,
expected_project_id.trim(),
&asset.id,
) {
let rollback = (|| {
let mut current = read_existing_manifest_for_project(root)?;
current.assets.retain(|entry| entry.id != asset.id);
write_manifest(&root.join(".agent/manifest.json"), &current)?;
fs::remove_file(&absolute_path).map_err(|remove_error| {
format!(
"删除未完成 UI 设计资源失败:{}: {remove_error}",
absolute_path.display()
)
})
})();
return match rollback {
Ok(()) => Err(error),
Err(rollback_error) => Err(format!(
"UI 设计资源初始化失败:{error};reconciliation-required: 回滚未完成:{rollback_error}"
)),
};
}
advance_agent_runtime_project_revision_locked(root).map_err(|error| {
format!("reconciliation-required: UI 设计资源已创建,但项目 revision 未能推进:{error}")
})?;
let manifest = read_existing_manifest_for_project(root)?;
let revision = read_game_creator_agent_runtime_project_revision(root)?.revision;
Ok(CreateUiDesignResourceResult {
asset,
manifest,
committed_project_revision: revision,
})
}
#[tauri::command]
pub(crate) fn update_local_project_resource_classification(
input: UpdateLocalProjectResourceClassificationInput,
@@ -268,10 +268,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::design_doc::CreateUiDesignDocFromImagesInput,
) -> Result<ui_editor::design_doc::UiDesignDocCreated, String> {
ui_editor::design_doc::create_ui_design_doc_from_images(input)
}
#[derive(Debug, Eq, PartialEq, Serialize)]
@@ -1077,14 +1077,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 {
@@ -2626,7 +2618,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,
@@ -2651,7 +2643,6 @@ fn main() {
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,
@@ -2823,7 +2823,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/design_doc/creation.rs` / `persistence.rs` 的
/// `register_local_asset_at` 使用 UI 文档 kind/media 常量,
/// 字体是 `commands.rs` 字体上传的 `register_local_asset_entry(root, path, "font", ...)`。
#[test]
@@ -2894,32 +2894,56 @@ 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::design_doc::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::design_doc::create_ui_design_doc_from_images(
crate::ui_editor::design_doc::CreateUiDesignDocFromImagesInput {
project_path: root.to_string_lossy().to_string(),
expected_project_id: "project-1".to_string(),
images: vec![crate::ui_editor::design_doc::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();
@@ -0,0 +1,321 @@
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);
return Err(error);
}
};
if let Err(error) =
initialize_ui_design_state_with_images_at(root, expected_project_id, &asset.id, &images)
{
return match rollback_created_document(root, &asset.id, &registered_image_ids, &absolute_path)
{
Ok(()) => Err(error),
Err(rollback_error) => Err(format!(
"UI 设计文档初始化失败:{error};reconciliation-required: 回滚未完成:{rollback_error}"
)),
};
}
let committed_project_revision =
advance_agent_runtime_project_revision_locked(root).map_err(|error| {
format!("reconciliation-required: UI 设计文档已创建,但项目 revision 未能推进:{error}")
})?;
let manifest = read_existing_manifest_for_project(root)?;
let asset = manifest
.assets
.iter()
.find(|entry| entry.id == asset.id)
.cloned()
.ok_or_else(|| "UI 设计文档创建后无法从 manifest 回读".to_string())?;
Ok(UiDesignDocCreated {
asset,
manifest,
relative_path,
image_ids: images.into_iter().map(|image| image.image_id).collect(),
committed_project_revision,
})
}
/// 解析并登记设计图,返回要装进文档的图片与本次调用新登记的图片资源 id。
fn prepare_design_images(
root: &Path,
manifest: &GameCreationAppManifest,
references: &[UiDesignImageReference],
) -> Result<(Vec<UiDesignDocumentImage>, Vec<String>), String> {
let mut images = Vec::with_capacity(references.len());
let mut registered_ids = Vec::new();
let mut seen_ids = BTreeSet::new();
for reference in references {
let (image, registered_id) = prepare_design_image(root, manifest, reference)?;
if let Some(id) = registered_id {
registered_ids.push(id);
}
if !seen_ids.insert(image.image_id.clone()) {
return Err("同一次调用不能重复登记同一张设计图".to_string());
}
images.push(image);
}
Ok((images, registered_ids))
}
fn prepare_design_image(
root: &Path,
manifest: &GameCreationAppManifest,
reference: &UiDesignImageReference,
) -> Result<(UiDesignDocumentImage, Option<String>), String> {
let (asset_id, relative_path, registered_id) =
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)?;
(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)?;
(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)?;
let registered = register_local_asset_at(
root,
&normalized_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.id.clone(), normalized_path, Some(registered.id))
}
}
}
_ => return Err("每张设计图必须且只能给 assetId 或 path".to_string()),
};
let absolute_path = resolve_local_project_path(root, &relative_path)?;
let dimensions = image::open(&absolute_path)
.map_err(|error| format!("读取设计图失败:{relative_path}:{error}"))?
.dimensions();
if dimensions.0 == 0 || dimensions.1 == 0 {
return Err(format!("设计图尺寸无效:{relative_path}"));
}
Ok((
UiDesignDocumentImage {
image_id: asset_id,
path: relative_path,
pixel_size: dimensions,
},
registered_id,
))
}
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> {
let mut manifest = read_existing_manifest_for_project(root)?;
manifest
.assets
.retain(|entry| entry.id != asset_id && !registered_image_ids.contains(&entry.id));
write_manifest(&root.join(".agent/manifest.json"), &manifest)?;
fs::remove_file(absolute_path).map_err(|error| format!("删除未完成 UI 设计资源失败:{error}"))
}
/// 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,6 @@
mod creation;
pub(crate) use creation::{
create_ui_design_doc_from_images, next_ui_design_path, CreateUiDesignDocFromImagesInput,
UiDesignDocCreated, UiDesignImageReference,
};
@@ -1,9 +1,9 @@
pub mod commands;
pub mod component;
pub(crate) mod design_doc;
pub(crate) mod html_renderer;
pub mod layout;
pub mod persistence;
pub mod resource;
pub mod resource_bridge;
pub mod state;
mod utils;
@@ -23,7 +23,7 @@ pub(crate) use shared_contracts::game_creation_app::{
};
const UI_DESIGN_STATE_MAX_BYTES: usize = 2 * 1024 * 1024;
const UI_DESIGN_CODE_MAX_BYTES: usize = UI_DESIGN_STATE_MAX_BYTES * 8;
const UI_DESIGN_STATE_MAX_IMAGES: usize = 4;
pub(crate) const UI_DESIGN_STATE_MAX_IMAGES: usize = 4;
const UI_DESIGN_STATE_MAX_SPRITES: usize = 1_024;
pub(crate) const UI_DESIGN_STATE_MAX_NODES: usize = 10_000;
const UI_DESIGN_STATE_MAX_DEPTH: usize = 128;
@@ -112,36 +112,53 @@ pub(crate) fn initialize_ui_design_state_at(
Ok(())
}
/// Initializes a newly bridged UI design with the source prototype as its first
/// design image. The bridge holds the project write lock, so this helper only
/// installs revision zero and never advances the project revision itself.
pub(crate) fn initialize_ui_design_state_with_source_image_at(
/// 新建文档时要装进文档的设计图:文档内身份取图片在 manifest 里的 assetId。
pub(crate) struct UiDesignDocumentImage {
pub(crate) image_id: String,
pub(crate) path: String,
pub(crate) pixel_size: (u32, u32),
}
/// Installs the given design images into a brand new UI design document at
/// revision zero. Callers hold the project write lock, so this helper never
/// advances the project revision itself.
pub(crate) fn initialize_ui_design_state_with_images_at(
root: &Path,
project_id: &str,
asset_id: &str,
source_image_id: &str,
source_image_path: &str,
pixel_size: (u32, u32),
images: &[UiDesignDocumentImage],
) -> Result<(), String> {
let asset = ui_design_asset(root, project_id, asset_id)?;
let source_image_id = required_identifier(source_image_id, "sourceImageId")?;
let source_image_path = normalize_relative_path(source_image_path.trim())?;
if pixel_size.0 == 0 || pixel_size.1 == 0 {
return Err("源 UI 原型图片尺寸无效".to_string());
if images.is_empty() {
return Err("UI 设计文档至少需要一张设计图".to_string());
}
let pixels_per_unit =
StrictlyPositiveFinite::new(1.0).map_err(|_| "UI 原型图片像素比例无效".to_string())?;
StrictlyPositiveFinite::new(1.0).map_err(|_| "UI 设计图像素比例无效".to_string())?;
let mut document = empty_document(project_id, asset_id);
let source_image_id = UIDesignImageId::new(source_image_id)
.map_err(|error| format!("sourceImageId 无效:{error}"))?;
document.state.ui_design_images.insert(
source_image_id,
UIDesignImage {
path: source_image_path,
pixel_size: Vector2::new(pixel_size.0 as f32, pixel_size.1 as f32),
pixels_per_unit,
},
);
for image in images {
let image_id = required_identifier(&image.image_id, "designImageId")?;
let path = normalize_relative_path(image.path.trim())?;
if image.pixel_size.0 == 0 || image.pixel_size.1 == 0 {
return Err("UI 设计图尺寸无效".to_string());
}
let image_id = UIDesignImageId::new(image_id)
.map_err(|error| format!("designImageId 无效:{error}"))?;
if document
.state
.ui_design_images
.insert(
image_id,
UIDesignImage {
path,
pixel_size: Vector2::new(image.pixel_size.0 as f32, image.pixel_size.1 as f32),
pixels_per_unit,
},
)
.is_some()
{
return Err("UI 设计文档的设计图不能重复".to_string());
}
}
write_ui_design_document(root, &asset.local_path, &document)?;
let installed = read_ui_design_document(root, &asset.local_path, project_id, asset_id)?;
validate_document(&installed, project_id, asset_id)?;
@@ -1,339 +0,0 @@
use crate::ui_editor::persistence::{
initialize_ui_design_state_with_source_image_at, UI_DESIGN_DOC_ASSET_KIND,
UI_DESIGN_DOC_MEDIA_TYPE,
};
use crate::{
acquire_project_write_lock, advance_agent_runtime_project_revision_locked,
enforce_project_permission_policy, read_existing_manifest_for_project,
read_game_creator_agent_runtime_project_revision, register_local_asset_at,
resolve_local_project_path, write_manifest, GameCreationAppAssetManifestEntry,
GameCreationAppAssetSource, GameCreationAppAssetSourceKind, GameCreationAppManifest,
};
use image::GenericImageView;
use serde::{Deserialize, Serialize};
use shared_contracts::game_creation_app::GameCreationAppAssetKind;
use std::fs;
use std::path::Path;
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub(crate) struct EnsureUiDesignResourceForPrototypeInput {
pub(crate) project_path: String,
pub(crate) expected_project_id: String,
pub(crate) prototype_asset_id: String,
}
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct EnsureUiDesignResourceForPrototypeResult {
pub(crate) asset: GameCreationAppAssetManifestEntry,
pub(crate) manifest: GameCreationAppManifest,
pub(crate) committed_project_revision: u64,
pub(crate) created: bool,
}
pub(crate) fn ensure_ui_design_resource_for_prototype(
input: EnsureUiDesignResourceForPrototypeInput,
) -> Result<EnsureUiDesignResourceForPrototypeResult, String> {
let root = Path::new(input.project_path.trim());
let expected_project_id = input.expected_project_id.trim();
let prototype_asset_id = input.prototype_asset_id.trim();
if expected_project_id.is_empty() || prototype_asset_id.is_empty() {
return Err("UI 原型桥接参数不能为空".to_string());
}
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 {
return Err("project-identity-conflict".to_string());
}
let prototype = manifest
.assets
.iter()
.find(|asset| asset.id == prototype_asset_id)
.ok_or_else(|| "UI 原型资产不存在".to_string())?;
if prototype.kind != GameCreationAppAssetKind::UiDesign
|| !prototype
.media_type
.to_ascii_lowercase()
.starts_with("image/")
{
return Err("目标资产不是可桥接的 UI 原型图片".to_string());
}
let source_path = prototype.local_path.clone();
let source_reference_ids = [
Some(prototype_asset_id),
prototype.source.resource_id.as_deref(),
prototype.source.asset_object_id.as_deref(),
]
.into_iter()
.flatten()
.filter(|reference| !reference.trim().is_empty())
.collect::<Vec<_>>();
let association_reference_id = prototype
.source
.resource_id
.as_deref()
.filter(|reference| !reference.trim().is_empty())
.or_else(|| {
prototype
.source
.asset_object_id
.as_deref()
.filter(|reference| !reference.trim().is_empty())
})
.unwrap_or(prototype_asset_id)
.to_string();
let source_absolute_path = resolve_local_project_path(root, &source_path)?;
let dimensions = image::open(&source_absolute_path)
.map_err(|error| format!("读取 UI 原型图片失败:{error}"))?
.dimensions();
if dimensions.0 == 0 || dimensions.1 == 0 {
return Err("UI 原型图片尺寸无效".to_string());
}
if let Some(asset) = manifest.assets.iter().find(|asset| {
asset.kind == UI_DESIGN_DOC_ASSET_KIND
&& asset.media_type == UI_DESIGN_DOC_MEDIA_TYPE
&& asset.source.reference_resource_ids.iter().any(|reference| {
source_reference_ids
.iter()
.any(|expected| reference == expected)
})
}) {
let revision = read_game_creator_agent_runtime_project_revision(root)?.revision;
return Ok(EnsureUiDesignResourceForPrototypeResult {
asset: asset.clone(),
manifest,
committed_project_revision: revision,
created: false,
});
}
let (resource_name, relative_path) = next_ui_design_path(root, &manifest)?;
let absolute_path = resolve_local_project_path(root, &relative_path)?;
if let Some(parent) = absolute_path.parent() {
fs::create_dir_all(parent)
.map_err(|error| format!("创建 UI 资源目录失败:{}: {error}", parent.display()))?;
}
fs::write(&absolute_path, "")
.map_err(|error| format!("创建 UI 资源失败:{}: {error}", absolute_path.display()))?;
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![association_reference_id],
},
) {
Ok(result) => result,
Err(error) => {
let _ = fs::remove_file(&absolute_path);
return Err(error);
}
};
if let Err(error) = initialize_ui_design_state_with_source_image_at(
root,
expected_project_id,
&asset.id,
prototype_asset_id,
&source_path,
dimensions,
) {
let rollback = (|| {
let mut current = read_existing_manifest_for_project(root)?;
current.assets.retain(|entry| entry.id != asset.id);
write_manifest(&root.join(".agent/manifest.json"), &current)?;
fs::remove_file(&absolute_path)
.map_err(|remove_error| format!("删除未完成 UI 设计资源失败:{remove_error}"))
})();
return match rollback {
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(EnsureUiDesignResourceForPrototypeResult {
asset,
manifest,
committed_project_revision,
created: true,
})
}
/// 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 == UI_DESIGN_DOC_ASSET_KIND && 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())?;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ui_editor::persistence::{load_ui_design_state_at, LoadUiDesignStateInput};
use crate::ui_editor::utils::UIDesignImageId;
use crate::{init_local_game_project_at, register_local_asset_entry};
use shared_contracts::game_creation_app::GameCreationAppAssetCategory;
use std::io::Cursor;
fn fixture() -> tempfile::TempDir {
let directory = tempfile::tempdir().expect("create UI bridge project");
init_local_game_project_at(directory.path(), "ui-bridge-project", "UI bridge")
.expect("init project");
let source_path = directory.path().join("assets/ui-prototype.png");
fs::create_dir_all(source_path.parent().expect("source parent"))
.expect("create source parent");
let mut bytes = Vec::new();
image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(
320,
180,
image::Rgba([255, 128, 64, 255]),
))
.write_to(&mut Cursor::new(&mut bytes), image::ImageFormat::Png)
.expect("encode source image bytes");
fs::write(&source_path, bytes).expect("write source image");
register_local_asset_entry(
directory.path(),
"assets/ui-prototype.png",
GameCreationAppAssetKind::UiDesign,
"image/png",
"canvas",
GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Canvas,
canvas_project_id: Some("canvas-project".to_string()),
resource_id: Some("prototype-resource".to_string()),
asset_object_id: None,
task_id: Some("design-foundation".to_string()),
prompt: None,
model: None,
generation_route: None,
generation_kind: Some("ui-design".to_string()),
reference_resource_ids: Vec::new(),
},
)
.expect("register source asset");
directory
}
#[test]
fn bridge_is_idempotent_and_installs_source_image() {
let directory = fixture();
let root = directory.path();
let manifest = read_existing_manifest_for_project(root).expect("manifest");
let source_id = manifest
.assets
.iter()
.find(|asset| asset.kind == GameCreationAppAssetKind::UiDesign)
.expect("source asset")
.id
.clone();
let input = EnsureUiDesignResourceForPrototypeInput {
project_path: root.to_string_lossy().into_owned(),
expected_project_id: "ui-bridge-project".to_string(),
prototype_asset_id: source_id.clone(),
};
let first = ensure_ui_design_resource_for_prototype(input.clone()).expect("bridge");
assert!(first.created);
assert_eq!(first.asset.kind, UI_DESIGN_DOC_ASSET_KIND);
// 写侧 → 分类的端到端断言:这条路径走的是与
// 写侧与 persistence/workflow 共用 UI 文档 kind/media 常量。
assert_eq!(
first.asset.category,
GameCreationAppAssetCategory::UiInteraction
);
assert_eq!(
first.asset.source.reference_resource_ids,
vec!["prototype-resource".to_string()]
);
let snapshot = load_ui_design_state_at(LoadUiDesignStateInput {
project_path: root.to_string_lossy().into_owned(),
expected_project_id: "ui-bridge-project".to_string(),
asset_id: first.asset.id.clone(),
})
.expect("load bridged state");
let source_image_id = UIDesignImageId::new(source_id.clone()).expect("source image id");
let image = snapshot
.state
.ui_design_images
.get(&source_image_id)
.expect("source image");
assert_eq!(image.path, "assets/ui-prototype.png");
assert_eq!(image.pixel_size.x, 320.0);
assert_eq!(image.pixel_size.y, 180.0);
let second = ensure_ui_design_resource_for_prototype(input).expect("idempotent bridge");
assert!(!second.created);
assert_eq!(second.asset.id, first.asset.id);
assert_eq!(
second.committed_project_revision,
first.committed_project_revision
);
assert_eq!(
second
.manifest
.assets
.iter()
.filter(|asset| {
asset.kind == UI_DESIGN_DOC_ASSET_KIND
&& asset.media_type == UI_DESIGN_DOC_MEDIA_TYPE
})
.count(),
1
);
}
}
@@ -2,120 +2,45 @@ import type {
GameCreationAppAssetManifestEntry,
GameCreationAppManifest,
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
import {
GAME_CREATION_APP_UI_DESIGN_ASSET_KIND,
isGameCreationAppUiDesignDocAsset,
parseGameCreationAppAssetKind,
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
export type UiDesignResourceBridgeResult = {
asset: GameCreationAppAssetManifestEntry;
manifest: GameCreationAppManifest;
committedProjectRevision: number;
created: boolean;
/** 一张设计图的输入引用:给已登记资源的 assetId,或给项目内相对路径。 */
export type UiDesignDocImageReference = {
assetId?: string;
path?: string;
};
export type UiDesignResourceBridgeInvoke = <T>(
export type UiDesignDocCreated = {
asset: GameCreationAppAssetManifestEntry;
manifest: GameCreationAppManifest;
relativePath: string;
imageIds: Array<string>;
committedProjectRevision: number;
};
export type UiDesignDocInvoke = <T>(
command: string,
args?: Record<string, unknown>,
) => Promise<T>;
function uiWorkflowStagePriority(
generationKind: string | null | undefined,
): number {
switch (generationKind) {
case 'ui-workflow.completed':
return 3;
case 'ui-workflow.binding-ready':
return 2;
case 'ui-workflow.reference-ready':
return 1;
default:
return 0;
}
}
export function findLinkedUiDesignResource(
manifest: GameCreationAppManifest,
prototypeAssetId: string,
) {
const normalizedId = prototypeAssetId.trim();
if (!normalizedId) return null;
const prototype = manifest.assets.find((asset) => asset.id === normalizedId);
const referenceIds = new Set(
[
normalizedId,
prototype?.source.resourceId,
prototype?.source.assetObjectId,
].filter((value): value is string => Boolean(value?.trim())),
);
return (
manifest.assets
.filter(
(asset) =>
isGameCreationAppUiDesignDocAsset(asset) &&
asset.source.referenceResourceIds?.some((reference) =>
referenceIds.has(reference),
),
)
.sort(
(left, right) =>
uiWorkflowStagePriority(right.source.generationKind) -
uiWorkflowStagePriority(left.source.generationKind),
)[0] ?? null
);
}
export async function ensureUiDesignResourceForPrototype({
/**
* 新建一份 UI 设计文档。每次调用都新建,不做「原型 → 已存在文档」的复用查找;
* 未登记的相对路径由 Rust 侧顺带登记成图片资源。
*/
export async function createUiDesignDocFromImages({
projectPath,
manifest,
prototypeAssetId,
expectedProjectId,
images,
invoke,
}: {
projectPath: string;
manifest: GameCreationAppManifest;
prototypeAssetId: string;
invoke: UiDesignResourceBridgeInvoke;
}): Promise<UiDesignResourceBridgeResult> {
const normalizedPrototypeAssetId = prototypeAssetId.trim();
if (!normalizedPrototypeAssetId) {
throw new Error('UI 原型资产身份不能为空');
expectedProjectId: string;
images: Array<UiDesignDocImageReference>;
invoke: UiDesignDocInvoke;
}): Promise<UiDesignDocCreated> {
if (images.length === 0) {
throw new Error('请至少选择一张界面图');
}
const prototype = manifest.assets.find(
(asset) => asset.id === normalizedPrototypeAssetId,
);
if (
!prototype ||
parseGameCreationAppAssetKind(
prototype.kind,
'ui-design-resource-bridge.prototype',
) !== GAME_CREATION_APP_UI_DESIGN_ASSET_KIND
) {
throw new Error('目标资源不是 UI 原型图片');
}
if (!prototype.mediaType.toLowerCase().startsWith('image/')) {
throw new Error('UI 原型资源必须是图片');
}
const linked = findLinkedUiDesignResource(
manifest,
normalizedPrototypeAssetId,
);
if (linked) {
return {
asset: linked,
manifest,
committedProjectRevision: 0,
created: false,
};
}
return invoke<UiDesignResourceBridgeResult>(
'ensure_ui_design_resource_for_prototype',
{
input: {
projectPath,
expectedProjectId: manifest.projectId,
prototypeAssetId: normalizedPrototypeAssetId,
},
},
);
return invoke<UiDesignDocCreated>('create_ui_design_doc_from_images', {
input: { projectPath, expectedProjectId, images },
});
}
@@ -263,7 +263,7 @@ import {
replaceVersionResource,
} from '../../features/resource-canvas/resourceVersionReplacementTransport';
import { useResourceCanvasGenerationPlaceholders } from '../../features/resource-canvas/useResourceCanvasGenerationPlaceholders';
import { ensureUiDesignResourceForPrototype } from '../../features/ui-editor/uiDesignResourceBridge';
import { createUiDesignDocFromImages } from '../../features/ui-editor/uiDesignResourceBridge';
import {
currentPlatformSessionGeneration,
requestPlatformSessionRefresh,
@@ -2182,7 +2182,6 @@ export default function ProjectDevelopmentView({
const [uiEditorRoute, setUiEditorRoute] = useState<UiEditorRoute | null>(
null,
);
const autoOpenedUiWorkflowResourceRef = useRef<string | null>(null);
const [resourceWorkbenchNotice, setResourceWorkbenchNotice] = useState('');
/**
* 打开项目时读盘归并旧分区 / 跳过无法对齐的坐标都是当场写回 sidecar 的副作用,
@@ -6322,10 +6321,10 @@ export default function ProjectDevelopmentView({
setUiEditorRoute(null);
setResourceWorkbenchNotice('正在准备 UI 编辑资源…');
try {
const result = await ensureUiDesignResourceForPrototype({
const result = await createUiDesignDocFromImages({
projectPath,
manifest,
prototypeAssetId,
expectedProjectId: manifest.projectId,
images: [{ assetId: prototypeAssetId }],
invoke,
});
if (canvasOpenEpochRef.current !== openEpoch) return;
@@ -6335,26 +6334,18 @@ export default function ProjectDevelopmentView({
) {
throw new Error('UI 编辑资源结果与当前项目不一致');
}
if (result.created) {
onManifestChange?.(projectPath, result.manifest, {
projectId: result.manifest.projectId,
revision: result.committedProjectRevision,
source: 'asset-command',
commitId: `ui-design-bridge:${result.asset.id}`,
});
}
onManifestChange?.(projectPath, result.manifest, {
projectId: result.manifest.projectId,
revision: result.committedProjectRevision,
source: 'asset-command',
commitId: `ui-design-doc:${result.asset.id}`,
});
setResourceWorkbenchNotice('');
setUiEditorRoute({
resourceId: result.asset.id,
resourceLabel:
result.asset.localPath.split(/[\\/]/u).filter(Boolean).pop() ??
result.relativePath.split(/[\\/]/u).filter(Boolean).pop() ??
resource.label,
...(result.asset.source.generationKind === 'ui-workflow.completed'
? {
initialStep: 'asset-separation',
initialFurthestStepIndex: 1,
}
: {}),
});
} catch (error) {
if (canvasOpenEpochRef.current === openEpoch) {
@@ -6398,19 +6389,10 @@ export default function ProjectDevelopmentView({
setUiEditorRoute({
resourceId: resource.manifestAssetId,
resourceLabel: resource.label,
...(manifest.assets.find(
(asset) => asset.id === resource.manifestAssetId,
)?.source.generationKind === 'ui-workflow.completed'
? {
initialStep: 'asset-separation' as const,
initialFurthestStepIndex: 1,
}
: {}),
});
},
[
advanceFocusGeneration,
manifest.assets,
openUiDesignEditor,
resourceCardPreviews.identityByResourceId,
resourceCardPreviews.previews,
@@ -6483,35 +6465,6 @@ export default function ProjectDevelopmentView({
resources,
]);
useEffect(() => {
if (uiEditorRoute) return;
const completed = manifest.assets.find(
(asset) =>
isGameCreationAppUiDesignDocAsset(asset) &&
asset.source.generationKind === 'ui-workflow.completed',
);
if (
!completed ||
autoOpenedUiWorkflowResourceRef.current === completed.id
) {
return;
}
autoOpenedUiWorkflowResourceRef.current = completed.id;
canvasOpenEpochRef.current += 1;
advanceFocusGeneration();
activeFocusFlowIdRef.current = null;
pendingResourceFocusRef.current = null;
setResourceWorkbenchNotice('');
setUiEditorRoute({
resourceId: completed.id,
resourceLabel:
completed.localPath.split(/[\\/]/u).filter(Boolean).pop() ??
'UI 设计资源',
initialStep: 'asset-separation',
initialFurthestStepIndex: 1,
});
}, [advanceFocusGeneration, manifest.assets, uiEditorRoute]);
const beginPendingResourceEditAction = useCallback((operationId: string) => {
if (pendingResourceEditActionIdsRef.current.has(operationId)) return false;
const next = new Set(pendingResourceEditActionIdsRef.current);
@@ -27,12 +27,12 @@ view/ui-editor (页面/组件)
| `component/` | 组件枚举 `Component::{Image, Text}`、`NodeComponent`(LLM 工具载荷的 `PureNode`/`WithComponent` 判别式) |
| `resource/` | 界面图(`path` / `pixel_size` / `pixels_per_unit`)、sprite(含 `SpriteBorder` 九宫格)、字体(格式/媒体类型/CSS format)资源描述 |
| `persistence.rs` | 文档读写、`revision` 乐观并发保存、领域校验(重复 ID、树/资源引用、组件状态)、代码生成写盘 |
| `resource_bridge.rs` | 原型图 → `ui_design` 资源的桥接建项(持项目写锁,装 revision 0,仅首个设计图) |
| `design_doc/` | UI 设计文档入口:`creation.rs` 用一至四张设计图新建文档(登记未登记图片、按 `ui/UI 设计 N.json` 取号、持项目写锁装 revision 0、失败回滚) |
| `html_renderer/` | 由 State 生成 HTML 片段与 JS(maud + 布局/组件 CSS 映射),供预览与 `ui/generated-*.js` |
| `commands/` | LLM 工具链:`recognition`(结构识别)、`separation/`(自动切分素材)、`utils.rs`(LLM 请求、重试、`required_tool_arguments`) |
| `commands/separation/` | 切分批处理、截图/预切、sidecar 恢复(inspect / finalize / discard)、patch 回写 |
关键命令(`main.rs` 注册):`load_ui_design_state`、`save_ui_design_state`、`generate_ui_design_code`、`ensure_ui_design_resource_for_prototype`、`recognize_ui`、`separate_ui`、`inspect_separation_recovery`、`finalize_separation`、`discard_separation_recovery`。
关键命令(`main.rs` 注册):`load_ui_design_state`、`save_ui_design_state`、`generate_ui_design_code`、`create_ui_design_doc_from_images`、`recognize_ui`、`separate_ui`、`inspect_separation_recovery`、`finalize_separation`、`discard_separation_recovery`。
保存语义(`save_ui_design_state_at`):`Saved` / `Unchanged` / `Conflict`(返回当前快照)三态;先 `validate_state` 再持锁重读比对 `expected_revision`,成功后推进项目 revision。代码生成只接受已保存的 revision,产物路径为 `ui/generated-<stem>-<digest>.js`。
@@ -46,7 +46,7 @@ view/ui-editor (页面/组件)
- 结果应用 seam:`recognition.ts`、`separationStatus.ts`(问题节点 → `NeedReview`)。
- 概览 projection:`stageStatusOverview.ts`、`separationOverview.ts`。
- 前置校验:`requisites.ts` 在发起 LLM 操作前检查必需资源与结果完整性。
- 适配器:`importAdapter.ts`(图片解码、批量导入、字体准备)、`uiDesignResourceBridge.ts`(原型桥接)、`useUiEditorFontFaces.ts`(私有字体族加载)、`spriteBorder.ts`。
- 适配器:`importAdapter.ts`(图片解码、批量导入、字体准备)、`uiDesignResourceBridge.ts`(调用 `create_ui_design_doc_from_images` 新建文档)、`useUiEditorFontFaces.ts`(私有字体族加载)、`spriteBorder.ts`。
- `utils/`:State → CSS 映射(`componentToCss`、`controlLayoutToCss`、`textStyleToCss`、`transform/tf2css`)、`treeUtils`。
## 前端:`src/view/ui-editor`(表现与编排)