实现 UI 编辑器参考图语义建议

新增 Rust 参考图语义建议命令与严格 DTO 校验

支持名称、描述、角色和归属页面建议的前端合并

允许界面图与独立素材名称为空并增强导入错误详情

在 Inspector 暴露界面图描述编辑并保留调试入口
This commit is contained in:
2026-08-13 15:44:10 +08:00
parent 1be413a296
commit 09eb9ee202
15 changed files with 382 additions and 47 deletions
@@ -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;
@@ -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,
};
}
@@ -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,
};
}