实现 UI 编辑器参考图语义建议
新增 Rust 参考图语义建议命令与严格 DTO 校验 支持名称、描述、角色和归属页面建议的前端合并 允许界面图与独立素材名称为空并增强导入错误详情 在 Inspector 暴露界面图描述编辑并保留调试入口
This commit is contained in:
@@ -110,6 +110,13 @@ use runner::*;
|
||||
use swarm_cli::*;
|
||||
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::UIDesignSuggestion>, String> {
|
||||
ui_editor::commands::suggest_ui_design_semantic_impl(project_path, state).await
|
||||
}
|
||||
|
||||
#[derive(Debug, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -2244,6 +2251,7 @@ fn main() {
|
||||
sync_canvas_project_assets,
|
||||
import_ui_editor_local_files,
|
||||
import_ui_editor_remote_assets,
|
||||
suggest_ui_design_semantic,
|
||||
generate_platform_art_asset,
|
||||
open_canvas_project,
|
||||
get_game_creation_agent_capabilities,
|
||||
|
||||
@@ -1 +1,5 @@
|
||||
mod ui_design_suggestion;
|
||||
pub mod ui_design_suggestion;
|
||||
pub mod recognition;
|
||||
|
||||
pub(crate) use ui_design_suggestion::suggest_ui_design_semantic_impl;
|
||||
pub use ui_design_suggestion::UIDesignSuggestion;
|
||||
|
||||
+141
-4
@@ -1,9 +1,146 @@
|
||||
use crate::config::build_game_creator_llm_client_from_config;
|
||||
use crate::ui_editor::resource::ui_design_image::UIDesignImageRole;
|
||||
use crate::ui_editor::state::State;
|
||||
use crate::ui_editor::utils::UIDesignImageId;
|
||||
use base64::Engine as _;
|
||||
use platform_llm::{LlmMessage, LlmMessageContentPart, LlmRunRequest};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
use ts_rs::TS;
|
||||
|
||||
const SYSTEM_PROMPT: &str = r#"
|
||||
请识别这些 UI 参考图的界面语义。你的响应必须是严格 JSON 数组;不要输出 Markdown、代码围栏、注释、额外字段或解释文字。
|
||||
|
||||
TypeScript 等价类型如下(仅用于说明契约,实际响应仍必须是 JSON):
|
||||
type UIDesignSuggestion = {
|
||||
ui_design_image_id: string;
|
||||
name: string | null;
|
||||
description: string | null;
|
||||
role: "Page" | "Section" | "Modal" | "Drawer" | "Popover" | "State" | "Scrolled" | "Detail" | null;
|
||||
slave_to: string | null;
|
||||
};
|
||||
|
||||
字段含义和 null 规则:
|
||||
- ui_design_image_id:必填,必须逐字匹配当前输入参考图的 id。它标识“这条建议属于哪张图”,不是新 ID,不能为 null。
|
||||
- name:要写入该图片 metadata 的简短、可读名称;
|
||||
- description:要写入该图片 metadata 的简短语义描述,例如“带底部导航的主游戏页面”。
|
||||
- role:要写入该图片 metadata 的界面角色。只能使用 Page、Section、Modal、Drawer、Popover、State、Scrolled、Detail 之一;。Page 表示完整主页面,Section 表示同一主页面中的子界面或页签,Modal/Drawer/Popover 表示浮层或局部覆盖界面,State 表示同一界面的状态变体,Scrolled 表示滚动或分页后的内容,Detail 表示局部详情或补充证据。
|
||||
- slave_to:要写入该图片 metadata 的归属页面 id。只有当当前图片明显是另一张输入图的局部/子界面时才填写那个宿主图的 id;如果不应修改现有 slave_to,必须填 null。Page 不得设置宿主。不能填写自身 id,也不能填写不在输入参考图中的 id。
|
||||
|
||||
所有字段都必须出现;nullable 字段使用 JSON null 表示“不要修改该字段”,不要省略字段。
|
||||
|
||||
每张参考图最多返回一条建议,且不得重复 ui_design_image_id。示例:
|
||||
[
|
||||
{
|
||||
"ui_design_image_id": "image-main",
|
||||
"name": "主游戏页面",
|
||||
"description": "带顶部资源栏和底部导航的完整游戏主页面",
|
||||
"role": "Page",
|
||||
"slave_to": null
|
||||
},
|
||||
{
|
||||
"ui_design_image_id": "image-inventory",
|
||||
"description": "从主页面进入的物品列表子界面",
|
||||
"role": "Section",
|
||||
"slave_to": "image-main"
|
||||
},
|
||||
]
|
||||
示例中的 image-main、image-inventory、image-detail 只是占位符;必须替换为本次输入中真实存在的 id。
|
||||
|
||||
以下UI设计图中的metadata部分信息已确定, 请补全未确定/为空的信息
|
||||
"#;
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)]
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))]
|
||||
pub struct UIDesignSuggestion {
|
||||
ui_design_image_id: UIDesignImageId,
|
||||
description: Option<String>,
|
||||
role: Option<UIDesignImageRole>,
|
||||
slave_to: Option<UIDesignImageId>,
|
||||
pub ui_design_image_id: UIDesignImageId,
|
||||
pub name: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub role: Option<UIDesignImageRole>,
|
||||
pub slave_to: Option<UIDesignImageId>,
|
||||
}
|
||||
|
||||
const MAX_REFERENCES: usize = 4;
|
||||
|
||||
fn image_data_url(path: &Path, bytes: &[u8]) -> Result<String, String> {
|
||||
let mime = match path.extension().and_then(|value| value.to_str()) {
|
||||
Some("png") => "image/png",
|
||||
Some("jpg") | Some("jpeg") => "image/jpeg",
|
||||
Some("webp") => "image/webp",
|
||||
_ => return Err(format!("不支持的界面图格式:{}", path.display())),
|
||||
};
|
||||
Ok(format!(
|
||||
"data:{mime};base64,{}",
|
||||
base64::engine::general_purpose::STANDARD.encode(bytes)
|
||||
))
|
||||
}
|
||||
|
||||
fn validate_suggestions(
|
||||
suggestions: &[UIDesignSuggestion],
|
||||
image_ids: &HashSet<UIDesignImageId>,
|
||||
) -> Result<(), String> {
|
||||
let mut seen = HashSet::new();
|
||||
for suggestion in suggestions {
|
||||
if !image_ids.contains(&suggestion.ui_design_image_id) {
|
||||
return Err("LLM 返回了未知界面图 ID".to_string());
|
||||
}
|
||||
if !seen.insert(suggestion.ui_design_image_id.clone()) {
|
||||
return Err("LLM 返回了重复界面图建议".to_string());
|
||||
}
|
||||
if suggestion.role == Some(UIDesignImageRole::Page) && suggestion.slave_to.is_some() {
|
||||
return Err("主页面不能设置归属页面".to_string());
|
||||
}
|
||||
if let Some(host) = &suggestion.slave_to {
|
||||
if host == &suggestion.ui_design_image_id || !image_ids.contains(host) {
|
||||
return Err("界面图归属页面无效".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn suggest_ui_design_semantic_impl(
|
||||
project_path: String,
|
||||
state: State,
|
||||
) -> Result<Vec<UIDesignSuggestion>, String> {
|
||||
if state.ui_design_images.is_empty() {
|
||||
return Err("请先导入界面图".to_string());
|
||||
}
|
||||
if state.ui_design_images.len() > MAX_REFERENCES {
|
||||
return Err("界面图最多 4 张".to_string());
|
||||
}
|
||||
let root = Path::new(project_path.trim());
|
||||
let mut parts = Vec::new();
|
||||
|
||||
let mut ids = HashSet::new();
|
||||
for (id, image) in &state.ui_design_images {
|
||||
ids.insert(id.clone());
|
||||
let absolute = crate::project::resolve_local_project_path(root, &image.path)?;
|
||||
let bytes = std::fs::read(&absolute).map_err(|error| format!("读取界面图失败:{error}"))?;
|
||||
parts.push(LlmMessageContentPart::InputText {
|
||||
text: format!(
|
||||
"REFERENCE id={} metadata={} pixel_size={:?}",
|
||||
id.as_str(),
|
||||
image.metadata,
|
||||
image.pixel_size
|
||||
),
|
||||
});
|
||||
parts.push(LlmMessageContentPart::InputImage {
|
||||
image_url: image_data_url(&absolute, &bytes)?,
|
||||
});
|
||||
}
|
||||
let client = build_game_creator_llm_client_from_config()?;
|
||||
let response = client
|
||||
.run(LlmRunRequest::new(vec![
|
||||
LlmMessage::system(SYSTEM_PROMPT),
|
||||
LlmMessage::user_multimodal(parts),
|
||||
]))
|
||||
.await
|
||||
.map_err(|error| format!("UI 参考图语义识别失败:{error}"))?;
|
||||
let suggestions = serde_json::from_str::<Vec<UIDesignSuggestion>>(response.text.trim())
|
||||
.map_err(|error| format!("LLM 返回的 UI 语义 JSON 无效:{error}"))?;
|
||||
validate_suggestions(&suggestions, &ids)?;
|
||||
// TODO: future single-page policy may reject multiple Page suggestions.
|
||||
Ok(suggestions)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
use crate::ui_editor::utils::UIDesignImageId;
|
||||
use nalgebra::Vector2;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt::{Display, Formatter, Write};
|
||||
use ts_rs::TS;
|
||||
use typed_floats::tf32::StrictlyPositiveFinite;
|
||||
use crate::ui_editor::utils::UIDesignImageId;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize, TS)]
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))]
|
||||
@@ -20,18 +21,26 @@ pub enum UIDesignImageRole {
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)]
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))]
|
||||
pub struct UIDesignImage {
|
||||
metadata: UIDesignImageMetadata,
|
||||
path: String,
|
||||
pub(crate) metadata: UIDesignImageMetadata,
|
||||
pub(crate) path: String,
|
||||
#[ts(as = "[f32; 2]")]
|
||||
pixel_size: Vector2<f32>,
|
||||
pub(crate) pixel_size: Vector2<f32>,
|
||||
#[ts(as = "f32")]
|
||||
pixels_per_unit: StrictlyPositiveFinite,
|
||||
pub(crate) pixels_per_unit: StrictlyPositiveFinite,
|
||||
}
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)]
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))]
|
||||
pub struct UIDesignImageMetadata {
|
||||
name: String,
|
||||
description: String,
|
||||
role: Option<UIDesignImageRole>,
|
||||
slave_to: Option<UIDesignImageId>,
|
||||
pub(crate) name: String,
|
||||
pub(crate) description: String,
|
||||
pub(crate) role: Option<UIDesignImageRole>,
|
||||
pub(crate) slave_to: Option<UIDesignImageId>,
|
||||
}
|
||||
impl Display for UIDesignImageMetadata {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&format!(
|
||||
"name: {}, description: {}, role: {:?}, slave_to: {:?}",
|
||||
self.name, self.description, self.role, self.slave_to
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,8 +17,8 @@ pub struct UITree {
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)]
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))]
|
||||
pub struct State {
|
||||
ui_trees: Vec<UITree>,
|
||||
ui_design_images: HashMap<UIDesignImageId, UIDesignImage>,
|
||||
sprite_assets: HashMap<SpriteAssetId, SpriteAsset>,
|
||||
font_assets: HashMap<FontAssetId, FontAsset>,
|
||||
pub(crate) ui_trees: Vec<UITree>,
|
||||
pub(crate) ui_design_images: HashMap<UIDesignImageId, UIDesignImage>,
|
||||
pub(crate) sprite_assets: HashMap<SpriteAssetId, SpriteAsset>,
|
||||
pub(crate) font_assets: HashMap<FontAssetId, FontAsset>,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
- 本文件采用 append-only:只在末尾追加新的共同原则,不改写或删除既有内容。
|
||||
- UI Editor 保持桌面端专用,当前不实现持久化。
|
||||
- 本阶段不新增 UI 测试,不修改或新增其它文档。
|
||||
- `demo/` 是设计师提供的旧 mock。只复用设计目的、页面布局和组件种类,不复用其数据结构或静态假数据实现。
|
||||
- 原始需求位于仓库根目录 `req_{n}.txt`;本文件记录已经确认的工程解释。两者冲突时先停止编码并向用户确认。
|
||||
- 正式状态是`State`
|
||||
- 核心布局结果 `State.ui_trees`。
|
||||
- Rust 是 UI Editor 领域类型的唯一源。并通过 `ts-rs` 生成
|
||||
`src/features/ui-editor/types/` 下的 TypeScript 文件。
|
||||
- React 只保存选择项、缩放、面板开关等临时 UI 状态。组件树、节点内容、审阅状态和人工锁定状态必须体现在同一个内存 `State` 中。
|
||||
|
||||
- 纯结构容器可使用空 `components`;视觉节点使用 `Image`;文本和动态数值使用 `Text`。动态数值的语义可写入节点描述,本期不引入独立 Number 组件。
|
||||
|
||||
- AI 识别由 Tauri/Rust 中的专用命令实现,入口放在
|
||||
`src-tauri/src/ui_editor/commands/mod.rs`。前端不得直接调用模型。
|
||||
- 复用项目现有 LLM 配置和 provider 能力;前端不得传递或持有 API Key。
|
||||
- 最多四张 UI 参考图必须作为真实多模态像素输入发送。Rust 读取图片并使用
|
||||
`data:<mime>;base64,...` 形式交给现有 LLM provider,不得只把文件路径、文件名或尺寸写进文字提示词。
|
||||
- base64 图片正文不得写入日志、错误详情、State、测试快照或其它持久记录。
|
||||
- 模型输出先进入命令内部识别 DTO,经结构校验和标准化后再构造正式 `UITree`、`Node`、`Component` 和 `Transform`;模型输出 DTO 不是公开 State 契约。
|
||||
- 模型使用的临时父子引用不得直接成为正式实体身份;正式 `NodeId` 由 Rust 校验和分配,并保证 State 内唯一。
|
||||
|
||||
- `container`、`button`、`tab`、`modal`、`image`、`text` 等名称只用于帮助理解 UI,不是正式数据类型,也不规定节点与 `Component` 的映射。正式能力只由当前 Rust 类型表达。
|
||||
- 本期只保留当前正式渲染组件 `Component::Image` 和 `Component::Text`。按钮、页签、弹窗、头像、进度条等更多组件类型以后再设计,不在本阶段预建枚举或 variant。
|
||||
- 节点数量、待审阅数量及其它概览信息只通过递归查询和过滤 `State.ui_trees` 得出,不向 `State` 添加重复统计或阶段确认字段。
|
||||
- 当前范围不实现UI交互关系、圆形识别框、复制、粘贴或手工新增节点,也不为这些概念预建状态。
|
||||
- 各 AI 阶段使用独立的输入/输出契约;后续阶段显式消费前一阶段结果,不从隐含的临时上下文推断前置事实。
|
||||
- AI 阶段的结果通过命令返回并更新同一个内存 `State`;阶段内部 DTO 只用于当前调用,不成为额外持久状态或跨阶段隐式参数。
|
||||
- 阶段命令可以只返回该阶段的 DTO;调用方负责将 DTO 显式合并到同一个内存 `State`。仅用于调试的验证入口与正式工作流分离。
|
||||
@@ -58,7 +58,8 @@ export async function prepareDesignImageBatch(
|
||||
);
|
||||
const image: UIDesignImage = {
|
||||
metadata: {
|
||||
name: basename(asset.localPath),
|
||||
name: '',
|
||||
description: '',
|
||||
role: null,
|
||||
slave_to: null,
|
||||
},
|
||||
@@ -86,7 +87,7 @@ export async function prepareSpriteAssetBatch(
|
||||
);
|
||||
const resource: SpriteAsset = {
|
||||
asset_id: asset.id,
|
||||
metadata: { name: basename(asset.localPath), asset_type: '' },
|
||||
metadata: { name: '', asset_type: '' },
|
||||
path: asset.localPath,
|
||||
pixel_size: pixelSize,
|
||||
pixels_per_unit: 1,
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
import type { UIDesignImageId } from "./UIDesignImageId";
|
||||
import type { UIDesignImageRole } from "./UIDesignImageRole";
|
||||
|
||||
export type UIDesignImageMetadata = { name: string, role: UIDesignImageRole | null, slave_to: UIDesignImageId | null, };
|
||||
export type UIDesignImageMetadata = { name: string, description: string, role: UIDesignImageRole | null, slave_to: UIDesignImageId | null, };
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
// This file is generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { UIDesignImageId } from "./UIDesignImageId";
|
||||
import type { UIDesignImageRole } from "./UIDesignImageRole";
|
||||
|
||||
export type UIDesignSuggestion = { ui_design_image_id: UIDesignImageId, name: string | null, description: string | null, role: UIDesignImageRole | null, slave_to: UIDesignImageId | null, };
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { State } from './types/State';
|
||||
import type { UIDesignSuggestion } from './types/UIDesignSuggestion';
|
||||
|
||||
/**
|
||||
* Applies semantic suggestions conservatively: null means "do not modify",
|
||||
* an already curated value wins, and suggestions for resources that disappeared
|
||||
* from the current State are ignored.
|
||||
*/
|
||||
export function applyUiDesignSuggestions(
|
||||
state: State,
|
||||
suggestions: readonly UIDesignSuggestion[],
|
||||
): State {
|
||||
const next = structuredClone(state);
|
||||
|
||||
for (const suggestion of suggestions) {
|
||||
const image = next.ui_design_images[suggestion.ui_design_image_id];
|
||||
if (!image) continue;
|
||||
|
||||
if (image.metadata.name.trim().length === 0 && suggestion.name?.trim()) {
|
||||
image.metadata.name = suggestion.name.trim();
|
||||
}
|
||||
if (image.metadata.role === null && suggestion.role !== null) {
|
||||
image.metadata.role = suggestion.role;
|
||||
}
|
||||
if (image.metadata.slave_to === null && suggestion.slave_to !== null) {
|
||||
image.metadata.slave_to = suggestion.slave_to;
|
||||
}
|
||||
if (
|
||||
image.metadata.description.trim().length === 0 &&
|
||||
suggestion.description?.trim()
|
||||
) {
|
||||
image.metadata.description = suggestion.description.trim();
|
||||
}
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
@@ -23,7 +23,8 @@ export type UiEditorOperationFailureReason =
|
||||
| 'duplicate'
|
||||
| 'limit'
|
||||
| 'missing'
|
||||
| 'invalid';
|
||||
| 'invalid'
|
||||
| `invalid:${string}`;
|
||||
|
||||
export type UiEditorOperationResult<T = undefined> =
|
||||
| { ok: true; value: T }
|
||||
@@ -101,30 +102,36 @@ export function spriteAssetRemovalImpact(
|
||||
};
|
||||
}
|
||||
|
||||
function validImageResource(image: UIDesignImage) {
|
||||
return (
|
||||
image.path.trim().length > 0 &&
|
||||
image.metadata.name.trim().length > 0 &&
|
||||
image.pixel_size.every(
|
||||
function imageResourceValidationError(image: UIDesignImage) {
|
||||
if (image.path.trim().length === 0) return '缺少图片路径';
|
||||
if (
|
||||
!image.pixel_size.every(
|
||||
(value) => Number.isFinite(value) && value > 0,
|
||||
) &&
|
||||
Number.isFinite(image.pixels_per_unit) &&
|
||||
image.pixels_per_unit > 0
|
||||
);
|
||||
)
|
||||
) {
|
||||
return `图片尺寸无效:${JSON.stringify(image.pixel_size)}`;
|
||||
}
|
||||
if (!Number.isFinite(image.pixels_per_unit) || image.pixels_per_unit <= 0) {
|
||||
return `像素单位无效:${image.pixels_per_unit}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function validSpriteResource(sprite: SpriteAsset) {
|
||||
return (
|
||||
sprite.asset_id.trim().length > 0 &&
|
||||
sprite.path.trim().length > 0 &&
|
||||
sprite.metadata.name.trim().length > 0 &&
|
||||
sprite.pixel_size.every(
|
||||
function spriteResourceValidationError(sprite: SpriteAsset) {
|
||||
if (sprite.asset_id.trim().length === 0) return '缺少素材 ID';
|
||||
if (sprite.path.trim().length === 0) return '缺少素材路径';
|
||||
if (
|
||||
!sprite.pixel_size.every(
|
||||
(value) => Number.isFinite(value) && value > 0,
|
||||
) &&
|
||||
Number.isFinite(sprite.pixels_per_unit) &&
|
||||
sprite.pixels_per_unit > 0 &&
|
||||
validateSpriteBorder(sprite.pixel_size, sprite.border).ok
|
||||
);
|
||||
)
|
||||
) {
|
||||
return `素材尺寸无效:${JSON.stringify(sprite.pixel_size)}`;
|
||||
}
|
||||
if (!Number.isFinite(sprite.pixels_per_unit) || sprite.pixels_per_unit <= 0) {
|
||||
return `像素单位无效:${sprite.pixels_per_unit}`;
|
||||
}
|
||||
const border = validateSpriteBorder(sprite.pixel_size, sprite.border);
|
||||
return border.ok ? null : border.message;
|
||||
}
|
||||
|
||||
export function useUiEditorState(
|
||||
@@ -184,6 +191,22 @@ export function useUiEditorState(
|
||||
[commit, guard],
|
||||
);
|
||||
|
||||
const setImageDescription = useCallback(
|
||||
(id: UIDesignImageId, description: string): UiEditorOperationResult => {
|
||||
const blocked = guard();
|
||||
if (blocked) return blocked;
|
||||
const current = stateRef.current;
|
||||
if (!(id in current.ui_design_images)) {
|
||||
return { ok: false, reason: 'missing' };
|
||||
}
|
||||
const next = cloneState(current);
|
||||
next.ui_design_images[id]!.metadata.description = description;
|
||||
commit(next);
|
||||
return { ok: true, value: undefined };
|
||||
},
|
||||
[commit, guard],
|
||||
);
|
||||
|
||||
const setImageRole = useCallback(
|
||||
(
|
||||
id: UIDesignImageId,
|
||||
@@ -240,8 +263,14 @@ export function useUiEditorState(
|
||||
if (Object.keys(current.ui_design_images).length + entries.length > 4) {
|
||||
return { ok: false, reason: 'limit' };
|
||||
}
|
||||
if (entries.some((entry) => !validImageResource(entry.image))) {
|
||||
return { ok: false, reason: 'invalid' };
|
||||
const invalidImage = entries
|
||||
.map((entry) => ({ entry, error: imageResourceValidationError(entry.image) }))
|
||||
.find((item) => item.error);
|
||||
if (invalidImage?.error) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: `invalid:${invalidImage.entry.id}:${invalidImage.error}`,
|
||||
};
|
||||
}
|
||||
const next = cloneState(current);
|
||||
for (const entry of entries) {
|
||||
@@ -265,8 +294,14 @@ export function useUiEditorState(
|
||||
) {
|
||||
return { ok: false, reason: 'duplicate' };
|
||||
}
|
||||
if (assets.some((asset) => !validSpriteResource(asset))) {
|
||||
return { ok: false, reason: 'invalid' };
|
||||
const invalidSprite = assets
|
||||
.map((asset) => ({ asset, error: spriteResourceValidationError(asset) }))
|
||||
.find((item) => item.error);
|
||||
if (invalidSprite?.error) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: `invalid:${invalidSprite.asset.asset_id}:${invalidSprite.error}`,
|
||||
};
|
||||
}
|
||||
const next = cloneState(current);
|
||||
for (const asset of assets) {
|
||||
@@ -396,12 +431,17 @@ export function useUiEditorState(
|
||||
return { ok: true, value: undefined };
|
||||
}, [commit, guard]);
|
||||
|
||||
const replaceState = useCallback((nextState: State) => {
|
||||
commit(cloneState(nextState));
|
||||
}, [commit]);
|
||||
|
||||
return {
|
||||
state,
|
||||
|
||||
isLocked,
|
||||
runWithStateLocked,
|
||||
setImageName,
|
||||
setImageDescription,
|
||||
setImageRole,
|
||||
setImageSlaveTo,
|
||||
addDesignImages,
|
||||
@@ -412,5 +452,6 @@ export function useUiEditorState(
|
||||
removeDesignImage,
|
||||
removeSpriteAsset,
|
||||
clearState,
|
||||
replaceState,
|
||||
};
|
||||
}
|
||||
|
||||
+11
@@ -85,6 +85,17 @@ export function InspectorSidebar({
|
||||
onChange={(event) => controller.setImageName(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-[11px] font-semibold text-(--platform-text-soft)">
|
||||
描述
|
||||
<textarea
|
||||
className="mt-1 min-h-20 w-full resize-y rounded-lg border border-(--platform-subpanel-border) bg-white/65 px-2 py-2 text-xs leading-5 text-(--platform-text-strong)"
|
||||
value={activeImage.metadata.description}
|
||||
placeholder="补充这张界面图的语义描述"
|
||||
onChange={(event) =>
|
||||
controller.setImageDescription(event.target.value)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<Metric label="宽度" value={`${activeImage.pixel_size[0]} px`} />
|
||||
<Metric label="高度" value={`${activeImage.pixel_size[1]} px`} />
|
||||
|
||||
@@ -24,6 +24,22 @@ export default function UiEditorPage({
|
||||
<InspectorSidebar controller={controller} />
|
||||
</div>
|
||||
<EditorDialogs controller={controller} />
|
||||
<div className="fixed bottom-4 right-4 z-30 flex max-w-xs flex-col items-end gap-2">
|
||||
{controller.suggestionStatus ? (
|
||||
<p className="rounded-lg border border-(--platform-subpanel-border) bg-(--platform-subpanel-fill) px-3 py-2 text-xs shadow-lg">
|
||||
{controller.suggestionStatus}
|
||||
</p>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full bg-orange-500 px-4 py-3 text-sm font-semibold text-white shadow-lg transition hover:bg-orange-600 disabled:cursor-wait disabled:opacity-60"
|
||||
onClick={() => void controller.suggestUiDesignSemantics()}
|
||||
disabled={controller.isSuggesting || controller.editor.isLocked}
|
||||
title="调试:识别参考图语义"
|
||||
>
|
||||
{controller.isSuggesting ? '识别中…' : '识别参考图语义'}
|
||||
</button>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -48,6 +48,8 @@ export function uiEditorOperationError(reason: string) {
|
||||
if (reason === 'duplicate') return '所选资源已经存在,本批次未加入编辑器。';
|
||||
if (reason === 'limit') return '界面图最多 4 张,本批次未加入编辑器。';
|
||||
if (reason === 'locked') return '当前任务正在运行,暂时不能修改 State。';
|
||||
if (reason === 'invalid') return '资源数据无效,本批次未加入编辑器。';
|
||||
if (reason.startsWith('invalid:')) {
|
||||
return `资源数据无效,本批次未加入编辑器。详情:${reason.slice('invalid:'.length)}`;
|
||||
}
|
||||
return '目标资源不存在。';
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
import type { ImportedAsset } from '../../components/AssetImporter';
|
||||
import {
|
||||
@@ -16,6 +17,8 @@ import type { SpriteAssetId } from '../../features/ui-editor/types/SpriteAssetId
|
||||
import type { SpriteBorder } from '../../features/ui-editor/types/SpriteBorder';
|
||||
import type { UIDesignImageId } from '../../features/ui-editor/types/UIDesignImageId';
|
||||
import type { UIDesignImageRole } from '../../features/ui-editor/types/UIDesignImageRole';
|
||||
import type { UIDesignSuggestion } from '../../features/ui-editor/types/UIDesignSuggestion';
|
||||
import { applyUiDesignSuggestions } from '../../features/ui-editor/uiDesignSuggestions';
|
||||
import {
|
||||
EMPTY_UI_EDITOR_STATE,
|
||||
useUiEditorState,
|
||||
@@ -44,6 +47,8 @@ export function useUiEditorPage(projectPath: string) {
|
||||
const [clearOpen, setClearOpen] = useState(false);
|
||||
const [pendingRemoval, setPendingRemoval] =
|
||||
useState<PendingResourceRemoval | null>(null);
|
||||
const [isSuggesting, setIsSuggesting] = useState(false);
|
||||
const [suggestionStatus, setSuggestionStatus] = useState<string | null>(null);
|
||||
|
||||
const images = editor.state.ui_design_images;
|
||||
const sprites = editor.state.sprite_assets;
|
||||
@@ -217,6 +222,12 @@ export function useUiEditorPage(projectPath: string) {
|
||||
setIssues(null);
|
||||
}
|
||||
|
||||
function setImageDescription(description: string) {
|
||||
if (!activeImageId) return;
|
||||
editor.setImageDescription(activeImageId, description);
|
||||
setIssues(null);
|
||||
}
|
||||
|
||||
function setImageRole(role: UIDesignImageRole | null) {
|
||||
if (!activeImageId) return;
|
||||
editor.setImageRole(activeImageId, role);
|
||||
@@ -246,6 +257,26 @@ export function useUiEditorPage(projectPath: string) {
|
||||
editor.setSpriteBorder(selectedSpriteId, border);
|
||||
}
|
||||
|
||||
async function suggestUiDesignSemantics() {
|
||||
if (isSuggesting) return;
|
||||
setSuggestionStatus(null);
|
||||
setIsSuggesting(true);
|
||||
try {
|
||||
await editor.runWithStateLocked(async (snapshot) => {
|
||||
const suggestions = await invoke<UIDesignSuggestion[]>(
|
||||
'suggest_ui_design_semantic',
|
||||
{ projectPath, state: snapshot },
|
||||
);
|
||||
editor.replaceState(applyUiDesignSuggestions(snapshot, suggestions));
|
||||
setSuggestionStatus(`已应用 ${suggestions.length} 条参考图语义建议。`);
|
||||
});
|
||||
} catch (cause) {
|
||||
setSuggestionStatus(cause instanceof Error ? cause.message : String(cause));
|
||||
} finally {
|
||||
setIsSuggesting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
projectPath,
|
||||
editor,
|
||||
@@ -282,11 +313,15 @@ export function useUiEditorPage(projectPath: string) {
|
||||
closeClearDialog: () => setClearOpen(false),
|
||||
clearState,
|
||||
setImageName,
|
||||
setImageDescription,
|
||||
setImageRole,
|
||||
setImageSlaveTo,
|
||||
setSpriteName,
|
||||
setSpriteAssetType,
|
||||
setSpriteBorder,
|
||||
isSuggesting,
|
||||
suggestionStatus,
|
||||
suggestUiDesignSemantics,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user