Compare commits

...

78 Commits

Author SHA1 Message Date
k88936 26c6bdbbd5 同步文档:界面图参考语义建议退役
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
- UI 编辑器代码地图更新 resource/commands 职责、关键命令列表、两步工作流与类型文件数量

- UI 工作流资源桥接文档改为普通桥接从 structure-recognition 开始

- UI 编辑器工作流完成通知弹窗文档收敛为两个动作并删除分析参考图文案

- UI 编辑会话模块边界删除 suggestion seam 描述

- App 实施计划追加 2026-09-23 退役条目覆盖旧 slave_to 口径

- decision-log 记录退役决策、合并优先级恒 0 与无迁移后果及验证结果
2026-09-23 13:30:13 +08:00
k88936 81dc7b0cd6 前端移除分析参考图步骤与界面图元数据编辑
- model.ts 收敛为“识别界面结构 / 自动切分素材”两步并删除界面角色标签表

- 删除 suggestion 异步操作、结果通知分支、uiDesignSuggestions 与其 ts-rs 生成类型

- InputSidebar、InspectorSidebar、PreviewWorkspace 的界面图显示名改用 path basename,Inspector 只保留尺寸与资源 ID

- 删除因步骤退役而不可达的 ImportOverview,ToolNavigation 改为两格,删除“清空界面归属”提示

- requisites 删除角色/归属前置校验与参考图分析结果校验,importAdapter 不再写入界面图元数据

- 测试夹具去掉界面图元数据,删除界面角色与归属用例,历史用例改用 sprite 名称断言
2026-09-23 13:30:04 +08:00
k88936 e18a398cdf 退役界面图参考语义建议的 Rust 链路
- 删除 ui_editor/commands/ui_design_suggestion.rs 与 main.rs 的 suggest_ui_design_semantic 命令注册

- 收敛 UIDesignImage 为 path/pixel_size/pixels_per_unit,删除 UIDesignImageRole 与 UIDesignImageMetadata

- 结构识别改为每张界面图各自一棵树,删除 recognition_root_image_ids 与 slave_image_ids 及 ROOT/SLAVE 上下文标注

- 多树合并删除 slave_to 祖先链优先级,输入树优先级恒置 0 并留 TODO

- 持久化校验删除 slave_to 引用与环校验,html 片段注释不再携带界面图 name/description

- 页面级工作流构造页面设计图时不再写入界面图元数据
2026-09-23 13:29:39 +08:00
k88936 d8c19d9187 新增 UI 编辑器代码地图与模块职责文档
- 定义 `Rust` 权威层、`React` 会话层与视图层的职责边界
- 描述数据流、关键模块设计与核心命令
- 补充扩展指引与关联文档链接
- 更新 `docs/README.md` 引用路径
2026-09-23 11:46:31 +08:00
k88936 d5e06de33e 固化键盘菜单键不被右键手势拦截的口径
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m58s
Project CI / Backend tests (pull_request) Failing after 12s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 3m57s
Project CI / Frontend tests (pull_request) Successful in 3m6s
Project CI / Repository checks (pull_request) Failing after 11s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m41s
Project CI / Native shell tests (pull_request) Successful in 6m13s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 9m32s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 10m50s
- 实测 Chromium 151 下键盘菜单键触发的是 button: -1,previewRightPanGesture 注释写明只认按钮 2 的原因
- previewRightPanGesture.test.ts 增加 button: -1 不拦截的判据用例
- previewRightPanDrag.test.tsx 增加键盘菜单键仍走原有节点菜单路径的集成用例
- decision-log 条目补上该实测结论与新增用例的验证口径

验证:npx vitest run previewRightPanDrag.test.tsx previewRightPanGesture.test.ts(13 passed)
2026-09-22 15:35:51 +08:00
k88936 0ab04d0f64 预览画布接入右键拖拽平移
- PreviewWorkspace 右键按下记录起手并捕获指针,越过共用阈值后按起点全量 delta 平移视口,抬起时由 resolvePreviewRightPanRelease 裁决平移还是节点菜单
- 捕获阶段拦截按钮 2 的 contextmenu,拦截窗口保留到下一次指针按下之前,兼容"按下触发"与"抬起后触发"两类平台时序
- 右键抬起用 [data-node-id] 加树容器新增的 data-tree-id 命中节点,沿用"右键即选中该节点"语义;未平移但抬起在预览外时不弹菜单,空白网格右键静默
- 视口接线共享 isPanning,让空格、中键、右键三个平移绑定共用 grabbing 光标;onPointerCancel 拆成不进菜单的独立处理
- 新增 tests/previewRightPanDrag.test.tsx:右键拖拽只平移不弹菜单、阈值内仍弹菜单、空白右键不改选择、预览外抬起不弹、非右键 contextmenu 仍即时、中键平移回归、右键平移后左键拖动仍可用

验证:npx vitest run 定向 7 个文件 59 passed、apps/ai-game-creator-shell npx tsc --noEmit、改动文件 eslint、npm run check:encoding、git diff --check
2026-09-22 15:32:19 +08:00
k88936 f525b6dc70 新增预览右键平移手势状态机
- 新增 previewRightPanGesture.ts:按下起手、按共用阈值判定是否进入平移、按起点全量 delta 复用 moveViewportFromPan 算视口
- 抬起裁决统一收口到 resolvePreviewRightPanRelease:平移过就只回视口,未平移且在预览内才按节点菜单处理
- shouldInterceptRightContextMenu 记录"按钮 2 且处于右键按下序列"的拦截窗口,兼容按下触发与抬起后触发的两类平台时序
- 新增 tests/previewRightPanGesture.test.ts 覆盖阈值边界、越阈值后不回弹、全量 delta、预览外抬起与拦截判据

验证:npx vitest run previewRightPanGesture.test.ts(5 passed)
2026-09-22 15:27:30 +08:00
k88936 96e7ba7808 预览拖动阈值抽成公共常量模块
- 新增 previewDragThreshold.ts,导出 DRAG_THRESHOLD_SCREEN_PX 与屏幕坐标阈值判定 passedDragThresholdScreen
- useNodeTransformInteraction 删除本地阈值常量,改为复用该模块,左键拖动/缩放行为不变

验证:npx vitest run useNodeTransformInteraction.test.tsx previewWorkspaceZoom.test.tsx(18 passed)
2026-09-22 15:26:11 +08:00
k88936 65e06c4808 记录预览画布右键拖拽平移的交互约定
- decision-log 新增 2026-09-22 条目:右键拖拽平移的判定链、菜单改到抬起弹出、按下即拦截 contextmenu 的实测依据与代价
- 【UI编辑器】拖动变换提交边界补「视口平移不写 State、不进历史」以及右键平移与节点菜单按同一拖动阈值互斥的约定
- 同一文档的实现边界补拖动阈值落点 previewDragThreshold.ts 与右键裁决落点 previewRightPanGesture.ts
2026-09-22 15:25:32 +08:00
k88936 4cd5fb8d72 Merge remote-tracking branch 'origin/master' into feat/ui-editor-v3
# Conflicts:
#	apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationQueue.ts
#	apps/ai-game-creator-shell/src/features/resource-canvas/useResourceCanvasGenerationPlaceholders.ts
#	apps/ai-game-creator-shell/tests/resourceCanvasGenerationFloatingPanel.test.tsx
#	docs/project-memory/shared-memory/decision-log.md
2026-09-22 14:08:41 +08:00
k88936 47c07f9d12 迁移节点变换交互测试接口
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 4m8s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 4m25s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 4m24s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 5m13s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m34s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m39s
Project CI / Frontend tests (pull_request) Successful in 5m56s
Project CI / AI game creator shell web tests (pull_request) Successful in 8m34s
Project CI / Repository checks (pull_request) Successful in 15m18s
Project CI / Native shell tests (pull_request) Successful in 22m28s
Project CI / Backend tests (pull_request) Successful in 27m38s
将旧单树测试夹具改为 trees 与 logicalSizes 参数

为拖拽和缩放入口补充所属 treeId 并更新树移除语义
2026-09-18 22:33:21 +08:00
k88936 0904184412 校正预览上下文菜单测试
覆盖上下文菜单回调的可选树标识参数

区分编辑器树目标与最终预览的无树标识回调
2026-09-18 22:18:58 +08:00
k88936 aaa2eb482a 更新最终预览框线测试语义
无框线场景不再传入选中节点以匹配选中态高亮规则

保留显式 showFrame 时的选中框与控制点断言
2026-09-18 22:08:53 +08:00
k88936 1679e72deb 修正删除快捷键页面测试
从仍挂载的窗口目标触发全局删除快捷键

按全部同名节点消失断言删除结果
2026-09-18 22:05:16 +08:00
k88936 4844543061 补齐检查提示动画测试名称
让动画 mock 匹配仅重启提示动画的现役筛选逻辑

保留非提示动画不被重启的生产行为
2026-09-18 22:00:58 +08:00
k88936 99132c9fa7 补齐 UI 状态持久化测试偏移字段
让当前 schema 夹具继续验证原有业务约束
2026-09-18 21:41:55 +08:00
k88936 944d2aa3db 更新 UI 编辑器返回交互测试
按现役浮动关闭按钮的可访问名称查询
2026-09-18 21:14:57 +08:00
k88936 7c16a35fda 兼容缺少偏移量的预览运行数据
校验 offset 前安全处理旧状态和测试夹具
2026-09-18 20:14:24 +08:00
k88936 e4369cd82e Merge remote-tracking branch 'origin/master' into feat/ui-editor-v3
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Failing after 5m0s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m54s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Failing after 7m15s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Failing after 6m32s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 6m56s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m21s
Project CI / Frontend tests (pull_request) Failing after 4m23s
Project CI / Repository checks (pull_request) Failing after 5m22s
Project CI / Native shell tests (pull_request) Successful in 9m10s
Project CI / Backend tests (pull_request) Successful in 9m41s
Project CI / AI game creator shell web tests (pull_request) Failing after 5m32s
# Conflicts:
#	apps/ai-game-creator-shell/tests/uiEditorPage.test.ts
#	docs/project-memory/shared-memory/decision-log.md
2026-09-18 18:44:11 +08:00
k88936 54b9bd4354 同步节点偏移量 TypeScript 绑定
更新 ts-rs 生成的 NodeOffset 类型文件
2026-09-18 17:30:53 +08:00
k88936 365e530b5f 修正测试偏移类型导入
从公开 offset 模块导入 NodeOffset
2026-09-18 17:23:06 +08:00
k88936 d46cdfb282 抽取保存结果弹窗操作底栏
统一关闭与重试按钮的布局和样式
2026-09-18 17:17:45 +08:00
k88936 2142027f85 简化保存结果弹窗内容分支
用显式早返回替换嵌套三元渲染
2026-09-18 17:15:50 +08:00
k88936 8fdf39f37e 清理预览初始适配无效依赖
移除仅通过 ref 读取的画布尺寸依赖
2026-09-18 17:14:16 +08:00
k88936 70c65c37d5 保证拖拽释放提交最新偏移
从指针释放坐标直接计算树位置
2026-09-18 17:12:30 +08:00
k88936 e72637789c 校验预览树偏移坐标
仅将有限偏移量纳入画布布局
2026-09-18 17:11:23 +08:00
k88936 0a102ea1c3 合并预览节点类型导入
复用单一 Node 类型导入声明
2026-09-18 17:10:04 +08:00
k88936 1ab7fa9991 校验预览画布像素比例
避免无效 pixels_per_unit 产生无限尺寸
2026-09-18 17:09:17 +08:00
k88936 6d02a0c29f 移除无读取的代码生成错误状态
删除失效的 generateError 状态及写入路径
2026-09-18 17:04:10 +08:00
k88936 a6c3db60fb 收窄编辑器操作生命周期接口
移除未被调用的 setRunning 与 reset 暴露
2026-09-18 16:57:42 +08:00
k88936 c921ae3b16 简化编辑器概览分支渲染
用显式条件替换嵌套三元表达式
2026-09-18 16:52:37 +08:00
k88936 31c084fce6 保留保存失败的底层错误信息
将实际异常传递到保存状态和重试提示
2026-09-18 16:48:53 +08:00
k88936 1e7a3f17ff 避免返回确认与失败弹窗重叠
返回路径保存失败时沿用确认弹窗错误状态
2026-09-18 16:45:43 +08:00
k88936 3ce2977e32 固定节点偏移量 TypeScript 元组导出
为二维数组字段补充 ts-rs 元组覆盖
2026-09-18 16:43:26 +08:00
k88936 f4e9414d63 限定状态提示动画重启动范围
仅重启动 UI 编辑器关注动画而不影响过渡效果
2026-09-18 16:41:12 +08:00
k88936 2d8b51885a 优化切分概览卡片布局
将窄侧栏概览调整为两列网格
2026-09-18 16:39:39 +08:00
k88936 91f534ed66 优化识别概览卡片布局
将窄侧栏概览调整为两列网格
2026-09-18 16:38:25 +08:00
k88936 f455d3edb2 清理未使用的界面图校验码
移除不会被发出的 missing-image 类型
2026-09-18 16:37:48 +08:00
k88936 bec225436f 简化状态节点轮询索引逻辑
用显式分支替换嵌套三元表达式
2026-09-18 16:35:25 +08:00
k88936 ce309a3b28 忽略未定义的节点元数据补丁
保留部分更新对未提供字段的原有语义
2026-09-18 16:33:37 +08:00
k88936 abd95a6231 消除复制路径未处理拒绝
复制失败仅保留界面错误状态
2026-09-18 16:31:04 +08:00
k88936 08c3b89b67 修复跨树节点选择同步界面图
选择节点时同步激活所属设计图
2026-09-18 16:28:49 +08:00
k88936 3371337346 修复树节点回调未使用参数
为跨树选择回调参数添加下划线前缀
2026-09-18 16:25:54 +08:00
k88936 899a591cda 区分语义建议工具调用缺失错误
恢复缺失工具调用的独立日志阶段和提示
2026-09-18 16:23:27 +08:00
k88936 0988c4907a 区分识别工具调用缺失错误
恢复识别工具缺失的独立日志阶段和提示
2026-09-18 16:20:49 +08:00
k88936 d370648867 区分合并工具调用缺失错误
恢复缺失工具调用的独立日志阶段和提示
2026-09-18 16:13:58 +08:00
k88936 4f4fb0873c 修复切分命令测试节点构造
补齐 NodeOffset 默认值以恢复 Rust 测试构建
2026-09-18 16:09:03 +08:00
k88936 6b8a106414 修复合并命令测试节点构造
补齐 NodeOffset 默认值以恢复 Rust 测试构建
2026-09-18 16:08:53 +08:00
k88936 55a8513de4 更新UI编辑器深模块架构文档
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Failing after 4m16s
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Failing after 4m23s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Failing after 4m24s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Failing after 4m26s
Project CI / Backend tests (pull_request) Failing after 13s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m52s
Project CI / Repository checks (pull_request) Failing after 11s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 3m28s
Project CI / Frontend tests (pull_request) Failing after 3m30s
Project CI / AI game creator shell web tests (pull_request) Failing after 4m33s
Project CI / Native shell tests (pull_request) Successful in 9m17s
记录状态迁移、几何、不变量与操作生命周期 seam

同步结构化 LLM 适配器边界与验证结果
2026-09-18 10:24:03 +08:00
k88936 d986dbaeb0 收敛UI编辑器结构化LLM动作适配器
统一必需 tool-call 定位与有界 JSON 参数解析

让语义建议、结构识别、组件绑定和多树合并复用稳定机械

保留各操作的 prompt、schema、领域校验与 materializer
2026-09-18 10:23:35 +08:00
k88936 16c6b9198e 深化UI编辑器前端状态与几何模块
新增 React-free 语义状态迁移 seam

收敛 State 级节点几何与保存前不变量校验

统一四类异步编辑器操作生命周期

补充状态迁移和不变量测试
2026-09-18 10:23:04 +08:00
k88936 4df4708ba1 Merge remote-tracking branch 'origin/master' into feat/ui-editor-v3
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Failing after 3m0s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Failing after 3m4s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Failing after 3m7s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Failing after 3m9s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m41s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m32s
Project CI / Frontend tests (pull_request) Failing after 4m47s
Project CI / Repository checks (pull_request) Failing after 4m39s
Project CI / Native shell tests (pull_request) Successful in 7m14s
Project CI / Backend tests (pull_request) Successful in 8m12s
Project CI / AI game creator shell web tests (pull_request) Failing after 2m54s
# Conflicts:
#	docs/project-memory/shared-memory/pitfalls.md
2026-09-16 14:57:26 +08:00
k88936 1e992bcdf8 扩大未知 JavaScript 异常上报原则
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Has been cancelled
明确所有未解决和非契约化 JS 异常必须继续抛出

说明用户 fallback 不得替代全局 error report 链路
2026-09-14 19:47:08 +08:00
k88936 8368aa262c 补充剪贴板异常上报约定
记录 UI fallback 后仍需抛出原始原生错误

明确未知异常必须进入 error report 链路
2026-09-14 19:43:08 +08:00
k88936 e9ddbf16da 重构预览网格步长计算
提取预览网格自适应步长到独立模块

补充缩放网格计算与背景样式测试

同步预览画布缩放交互文档
2026-09-14 19:40:13 +08:00
k88936 22ac1f4c0b 修复 UI 编辑器剪贴板写入权限
为主窗口补充 clipboard-manager 写文本权限

保留手动复制提示并继续抛出原始 Tauri 剪贴板错误
2026-09-14 19:39:34 +08:00
k88936 9910a0eec0 优化 UI 编辑器复制反馈布局
将复制失败提示与操作按钮分离排列
2026-09-14 19:06:15 +08:00
k88936 70981b9ca9 完善 UI 编辑器结果弹窗覆盖
增加普通保存成功与保存后生成失败的场景断言

验证路径弹窗和失败重试按钮的可见行为
2026-09-14 19:01:44 +08:00
k88936 8a0d5600b3 拆分 UI 编辑器路径复制按钮
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Failing after 2m27s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Failing after 2m23s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Failing after 2m5s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Failing after 2m8s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m57s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m15s
Project CI / Frontend tests (pull_request) Failing after 3m22s
Project CI / Repository checks (pull_request) Successful in 3m38s
Project CI / Native shell tests (pull_request) Successful in 6m42s
Project CI / AI game creator shell web tests (pull_request) Failing after 3m12s
Project CI / Backend tests (pull_request) Successful in 8m24s
将剪贴板状态与写入逻辑提取为独立小组件

保持保存结果弹窗只负责结果布局与动作编排
2026-09-14 18:59:51 +08:00
k88936 184d88dbb8 修复多树预览受左侧选中图片限制
移除 activeImageId 对预览拖动和缩放手势的门控

按树 wrapper 提供几何上下文并允许任意树操作

保留 activeImageId 仅用于左侧图片与图片级检查
2026-09-14 18:59:51 +08:00
k88936 791794c0a6 记录 UI 编辑器保存结果弹窗约定
补充保存与代码生成结果弹窗及相对路径复制行为

记录复制失败、重试和保存并返回边界
2026-09-14 18:59:51 +08:00
k88936 9cfb47d945 补充 UI 编辑器保存结果测试
覆盖保存和生成成功弹窗与项目相对路径复制

覆盖保存失败、冲突和剪贴板失败反馈
2026-09-14 18:59:51 +08:00
k88936 27859d3e19 接入 UI 编辑器保存结果弹窗
让普通保存和保存并生成流程返回结构化结果

将成功与失败反馈接入独立弹窗并保留保存并返回直返行为
2026-09-14 18:59:51 +08:00
k88936 43f3780a38 新增 UI 编辑器保存结果弹窗
新增保存成功、代码生成成功和失败状态弹窗

提供项目相对路径复制按钮及复制失败提示
2026-09-14 18:59:51 +08:00
k88936 a1f9149c27 UI编辑器移除预览模式残留类型
完成三开关替代 render mode 的类型清理
2026-09-14 18:59:51 +08:00
k88936 f0bba18841 UI编辑器收口多树上下文菜单目标
预览菜单携带所属树 ID

避免非当前图片树的结构操作写错目标
2026-09-14 18:59:51 +08:00
k88936 f679cfa179 UI编辑器完善多树创建与文档契约
统一识别和合并结果的 createTree 流程

按选中节点所属树驱动 Inspector 编辑

补充多树预览与树级偏移权威文档
2026-09-14 18:59:50 +08:00
k88936 f68ffcfecd UI编辑器补充合并树偏移构造
为合并产出的节点补齐 NodeOffset
2026-09-14 18:59:50 +08:00
k88936 ec94a4fe00 UI编辑器重构多树预览与独立开关
预览画布同时渲染全部界面树并支持树级拖动

拆分显示框线、原图和组件开关

预览节点选择与左侧当前图片解耦
2026-09-14 18:59:50 +08:00
k88936 46f8592375 UI编辑器补充树级偏移模型
新增 Node offset min/max 数据结构

统一新树默认横向布局入口

增加树级偏移状态更新操作与实施计划
2026-09-14 18:59:50 +08:00
k88936 cdf2882302 补齐自动切分概览字体属性
向 SeparationOverview 传入 fonts 数据

修复 UI 编辑器 TS2741 类型错误
2026-09-14 18:59:50 +08:00
k88936 d156113e71 修复 UI 编辑器待办循环高亮动画
修复受控树选中回调清除 Inspector 高亮的问题

循环游标同时使用界面树与节点 ID

补充多节点循环和动画重启回归测试
2026-09-14 18:59:50 +08:00
k88936 a8f2ac17be 注释掉 InspectorSidebar 中的 LayoutEditor 组件以暂时禁用布局编辑功能 2026-09-14 18:59:50 +08:00
k88936 43ac9a5761 补充 UI 编辑器交互回归测试
从实际节点事件目标验证删除快捷键

验证 portal 右键菜单启用后会执行操作并关闭
2026-09-14 18:59:50 +08:00
k88936 e992e0b35c 压缩 UI 编辑器概览布局
将识别概览指标改为四列横向排列

将素材切分概览改为四列网格以减少纵向高度
2026-09-14 18:59:50 +08:00
k88936 e389b54a3a 修复 UI 预览画布交互
为无限视口添加跟随平移和缩放的点阵背景

恢复画布快捷缩放与初始适配比例控制

保留节点平移选择并屏蔽浏览器原生拖拽选择
2026-09-14 18:59:50 +08:00
k88936 84c780a6f8 重构 UI 编辑器全屏外壳
通过 portal 将资源编辑器挂载到 body 并覆盖标题栏以下区域

移除外层 FocusTrap,保留内部确认对话框的模态行为

统一左右浮动面板的顶部与底部留白
2026-09-14 18:59:50 +08:00
k88936 03f900fbe5 布局重构:
- 预览改为全区域背景层。
  - 左右面板、顶部流程条改为覆盖层,可遮挡预览。
  - 左右面板开合状态仅保存在当前 React 会话。
  - 增加左右面板“收起/展开”入口。
  - 编辑器关闭入口改为单独的 ×,继续通过 onBack 注入关闭逻辑。
  - 保留全局顶部栏与现有快捷键、业务状态、保存流程。
  - 底层页面仍由现有父级路由控制,不新增 URL 状态。
2026-09-14 18:59:50 +08:00
74 changed files with 2907 additions and 1947 deletions
@@ -1,11 +1,12 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "main",
"description": "AI 游戏创作主窗口允许读系统剪贴板图片,用于粘贴素材附件;允许弹出原生打开/保存对话框用于素材上传与导出。",
"description": "AI 游戏创作主窗口允许读系统剪贴板,用于粘贴素材附件和复制生成文件路径;允许弹出原生打开/保存对话框用于素材上传与导出。",
"windows": ["client"],
"permissions": [
"clipboard-manager:allow-read-image",
"clipboard-manager:allow-read-text",
"clipboard-manager:allow-write-text",
"core:image:allow-rgba",
"core:image:allow-size",
"core:resources:allow-close",
@@ -197,14 +197,6 @@ use swarm_cli::*;
use template_library::*;
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::UIDesignSuggestionTreeNode>, String> {
ui_editor::commands::suggest_ui_design_semantic_impl(project_path, state).await
}
#[tauri::command]
async fn recognize_ui(
project_path: String,
@@ -2665,7 +2657,6 @@ fn main() {
prepare_ui_editor_project_fonts,
read_ui_editor_font_bytes,
check_ui_editor_font_glyph_coverage,
suggest_ui_design_semantic,
recognize_ui,
separate_ui,
inspect_separation_recovery,
@@ -2,7 +2,7 @@ use crate::config::build_game_creator_llm_client_from_llm_config;
use crate::config::load_game_creator_app_config;
use crate::ui_editor::commands::utils::{
parse_limited_llm_tool_arguments, read_ui_reference_image_data_url, request_ui_editor_llm,
strict_json_schema,
required_tool_call_arguments, strict_json_schema,
};
use crate::ui_editor::component::text::FontSource;
use crate::ui_editor::component::{Component, NodeComponent};
@@ -403,12 +403,8 @@ pub(crate) async fn bind_components_impl_with_provider(
.await
}
.map_err(|error| format!("组件绑定失败:{error}"))?;
let call = response
.tool_calls
.iter()
.find(|call| call.name == "bind_ui_components")
.ok_or_else(|| "LLM 未返回 bind_ui_components 工具调用".to_string())?;
let parsed = parse_binding_response(&call.arguments, editable_nodes.len())?;
let arguments = required_tool_call_arguments(&response, "bind_ui_components")?;
let parsed = parse_binding_response(arguments, editable_nodes.len())?;
let known_sprite_ids = state.sprite_assets.keys().cloned().collect::<HashSet<_>>();
let known_font_ids = state.font_assets.keys().cloned().collect::<HashSet<_>>();
let result = validate_and_materialize(
@@ -1,7 +1,8 @@
use crate::config::build_game_creator_llm_client_from_llm_config;
use crate::config::load_game_creator_app_config;
use crate::ui_editor::commands::utils::{
parse_limited_llm_tool_arguments, request_ui_editor_llm, strict_json_schema,
parse_limited_llm_tool_arguments, request_ui_editor_llm, required_tool_call_arguments,
strict_json_schema,
};
use crate::ui_editor::state::{State, UITree};
use platform_llm::{LlmFunctionTool, LlmMessage, LlmRunRequest, LlmToolChoice};
@@ -113,65 +114,13 @@ mod llm_contract {
}
}
mod priority {
use crate::ui_editor::resource::ui_design_image::UIDesignImage;
use crate::ui_editor::state::State;
use crate::ui_editor::utils::UIDesignImageId;
use std::collections::{HashMap, HashSet};
fn ancestor_chain(
image_id: &UIDesignImageId,
images: &HashMap<UIDesignImageId, UIDesignImage>,
) -> Result<Vec<UIDesignImageId>, String> {
let mut chain = Vec::new();
let mut seen = HashSet::new();
let mut current = image_id.clone();
loop {
if !seen.insert(current.clone()) {
return Err(format!(
"界面图 slave_to 关系存在循环:{}",
current.as_str()
));
}
chain.push(current.clone());
let image = images
.get(&current)
.ok_or_else(|| format!("界面图 slave_to 引用了缺失界面图:{}", current.as_str()))?;
match &image.metadata.slave_to {
Some(parent) => current = parent.clone(),
None => return Ok(chain),
}
}
}
pub(super) fn for_state(state: &State) -> Result<Vec<u32>, String> {
let source_ids = state
.ui_trees
.iter()
.map(|tree| tree.src_ui_design.clone())
.collect::<Vec<_>>();
let chains = source_ids
.iter()
.map(|source_id| ancestor_chain(source_id, &state.ui_design_images))
.collect::<Result<Vec<_>, _>>()?;
Ok(source_ids
.iter()
.map(|candidate| {
chains
.iter()
.filter(|chain| chain.contains(candidate))
.count() as u32
})
.collect())
}
}
mod materialize {
use super::llm_contract::Node;
use crate::ui_editor::layout::children_display_mode::ChildrenDisplayMode;
use crate::ui_editor::layout::node::{
Node as LayoutNode, NodeMetadata, NodeSource, StageStatus,
};
use crate::ui_editor::layout::offset::NodeOffset;
use crate::ui_editor::layout::transform::Transform;
use crate::ui_editor::state::{State, UITree};
use crate::ui_editor::utils::{random_node_id, NodeId, UIDesignImageId};
@@ -215,6 +164,7 @@ mod materialize {
Ok(())
}
// TODO(ui-merge): 优先级恒 0 时该函数固定取 merged_from 的首个成员;优先级来源待重新设计。
fn highest_priority_member_index(members: &[BuiltNode]) -> Result<usize, String> {
if members.is_empty() {
return Err("MergedNode.merged_from 不能为空".to_string());
@@ -302,6 +252,7 @@ mod materialize {
component: None,
children_display_mode: ChildrenDisplayMode::Exclusive,
children: members.into_iter().map(|member| member.node).collect(),
offset: NodeOffset::default(),
},
priority,
src_ui_design,
@@ -409,10 +360,8 @@ pub(crate) async fn merge_ui_impl_with_provider(
app_log!("ui_merge.error stage=validate_input error={error}");
error
})?;
let priorities = priority::for_state(&state).map_err(|error| {
app_log!("ui_merge.error stage=build_priority error={error}");
error
})?;
// TODO(ui-merge): 输入树优先级来源待重新设计,当前所有输入树等权(恒 0)。
let priorities = vec![0u32; state.ui_trees.len()];
let trees = llm_contract::input_trees(&state, &priorities).map_err(|error| {
app_log!("ui_merge.error stage=build_input error={error}");
error
@@ -485,15 +434,11 @@ pub(crate) async fn merge_ui_impl_with_provider(
app_log!("ui_merge.error stage=llm_request error={error}");
format!("UI 树合并失败:{error}")
})?;
let call = response
.tool_calls
.iter()
.find(|call| call.name == MERGE_TOOL_NAME)
.ok_or_else(|| {
app_log!("ui_merge.error stage=parse_tool_call reason=missing_tool_call");
format!("LLM 未返回 {MERGE_TOOL_NAME} 工具调用")
})?;
let arguments = parse_limited_llm_tool_arguments(&call.arguments).map_err(|error| {
let arguments = required_tool_call_arguments(&response, MERGE_TOOL_NAME).map_err(|error| {
app_log!("ui_merge.error stage=parse_tool_call reason=missing_tool_call error={error}");
format!("LLM 未返回 {MERGE_TOOL_NAME} 工具调用")
})?;
let arguments = parse_limited_llm_tool_arguments(arguments).map_err(|error| {
app_log!("ui_merge.error stage=parse_arguments error={error}");
format!("UI 合并工具参数无效:{error}")
})?;
@@ -527,6 +472,7 @@ mod tests {
use crate::ui_editor::layout::children_display_mode::ChildrenDisplayMode;
use crate::ui_editor::layout::control_layout::ControlLayout;
use crate::ui_editor::layout::node::{Node, NodeMetadata, NodeSource, StageStatus};
use crate::ui_editor::layout::offset::NodeOffset;
use crate::ui_editor::layout::transform::Transform;
use crate::ui_editor::state::{State, UITree};
use crate::ui_editor::utils::{NodeId, UIDesignImageId};
@@ -548,6 +494,7 @@ mod tests {
component: None,
children_display_mode: ChildrenDisplayMode::Stack,
children,
offset: NodeOffset::default(),
}
}
@@ -2,7 +2,6 @@ pub mod binding;
pub mod merge;
pub mod recognition;
pub mod separation;
pub mod ui_design_suggestion;
pub mod utils;
pub use binding::BindingDTO;
@@ -13,5 +12,3 @@ pub use recognition::RecognitionDTO;
pub(crate) use recognition::{recognize_ui_impl, recognize_ui_impl_with_provider};
pub(crate) use separation::separate_ui_impl;
pub use separation::{SeparationDTO, SeparationRecoveryDTO};
pub(crate) use ui_design_suggestion::suggest_ui_design_semantic_impl;
pub use ui_design_suggestion::UIDesignSuggestionTreeNode;
@@ -2,17 +2,18 @@ use crate::config::build_game_creator_llm_client_from_llm_config;
use crate::config::load_game_creator_app_config;
use crate::ui_editor::commands::utils::{
parse_limited_llm_tool_arguments, read_ui_reference_image_data_url, request_ui_editor_llm,
strict_json_schema,
required_tool_call_arguments, strict_json_schema,
};
use crate::ui_editor::component::{Component, NodeComponent};
use crate::ui_editor::layout::children_display_mode::ChildrenDisplayMode;
use crate::ui_editor::layout::control_layout::ControlLayout;
use crate::ui_editor::layout::dimension::UIRect;
use crate::ui_editor::layout::node::{Node as LayoutNode, NodeMetadata, NodeSource, StageStatus};
use crate::ui_editor::layout::offset::NodeOffset;
use crate::ui_editor::layout::transform::Transform;
use crate::ui_editor::resource::ui_design_image::UIDesignImage;
use crate::ui_editor::state::{State, UITree};
use crate::ui_editor::utils::{random_node_id, NodeId, UIDesignImageId};
use crate::ui_editor::utils::{random_node_id, UIDesignImageId};
use nalgebra::{Point2, Vector2};
use platform_llm::{
LlmFunctionTool, LlmMessage, LlmMessageContentPart, LlmRunRequest, LlmToolChoice,
@@ -31,7 +32,7 @@ const SYSTEM_PROMPT: &str = r#"
任务:
同时分析同一 UI 系统的全部参考图,建立UI树
用户会给你一些UI截图(它们从属于同一个UI系统)和对应的元数据, 请用给定的工具描述UI结构
用户会给你一些UI截图(它们从属于同一个UI系统), 请用给定的工具描述UI结构
识别规则:
* 只识别 UI元素. 要区分动态内容, 不要白费力气识别应该由程序生成/绘制的内容.(此类内容应该用一个整体节点+自然语言描述) 除此之外必须完整包含所有元素,结构.
@@ -322,6 +323,7 @@ fn convert_node(
component: source.component.clone().into_option(),
children_display_mode: ChildrenDisplayMode::Stack,
children,
offset: NodeOffset::default(),
})
}
@@ -384,12 +386,6 @@ mod tests {
fn test_image() -> UIDesignImage {
UIDesignImage {
metadata: crate::ui_editor::resource::ui_design_image::UIDesignImageMetadata {
name: "测试图".to_string(),
description: String::new(),
role: None,
slave_to: None,
},
path: "test.png".to_string(),
pixel_size: Vector2::new(1000.0, 500.0),
pixels_per_unit: typed_floats::tf32::StrictlyPositiveFinite::new(2.0)
@@ -546,7 +542,7 @@ mod tests {
#[test]
fn conversion_stays_in_the_tree_image_coordinate_system() {
let image_id = UIDesignImageId::new("slave").expect("valid image id");
let image_id = UIDesignImageId::new("second").expect("valid image id");
let image = test_image();
let root_rect = UIRect::new(Point2::origin(), image_layout_size(&image).unwrap());
let converted =
@@ -589,56 +585,29 @@ mod tests {
#[test]
fn tree_validation_requires_exactly_one_tree_per_context_image() {
let page = UIDesignImageId::new("page").expect("valid image id");
let slave = UIDesignImageId::new("slave").expect("valid image id");
let second = UIDesignImageId::new("second").expect("valid image id");
let tree = |id: UIDesignImageId| RecognitionTree {
src_ui_design_image_id: id,
root: test_node(),
};
assert!(validate_tree_image_ids(
&[tree(page.clone()), tree(slave.clone())],
&[page.clone(), slave.clone()],
&[tree(page.clone()), tree(second.clone())],
&[page.clone(), second.clone()],
)
.is_ok());
assert!(
validate_tree_image_ids(&[tree(page.clone())], &[page.clone(), slave.clone()]).is_err()
);
assert!(
validate_tree_image_ids(&[tree(page.clone()), tree(page.clone())], &[page, slave],)
validate_tree_image_ids(&[tree(page.clone())], &[page.clone(), second.clone()])
.is_err()
);
assert!(validate_tree_image_ids(
&[tree(page.clone()), tree(page.clone())],
&[page, second],
)
.is_err());
}
}
fn recognition_root_image_ids(state: &State) -> Vec<UIDesignImageId> {
state
.ui_design_images
.iter()
.filter_map(|(id, image)| {
let is_page = image.metadata.role
== Some(crate::ui_editor::resource::ui_design_image::UIDesignImageRole::Page);
let is_direct_slave_of_page = image.metadata.slave_to.as_ref().is_some_and(|parent| {
state
.ui_design_images
.get(parent)
.and_then(|parent_image| parent_image.metadata.role)
== Some(crate::ui_editor::resource::ui_design_image::UIDesignImageRole::Page)
});
(is_page || !is_direct_slave_of_page).then(|| id.clone())
})
.collect()
}
fn slave_image_ids(state: &State, root_id: &UIDesignImageId) -> Vec<UIDesignImageId> {
state
.ui_design_images
.iter()
.filter_map(|(id, image)| {
(image.metadata.slave_to.as_ref() == Some(root_id)).then(|| id.clone())
})
.collect()
}
pub(crate) async fn recognize_ui_impl_with_provider(
project_path: String,
state: State,
@@ -655,11 +624,7 @@ pub(crate) async fn recognize_ui_impl_with_provider(
);
return Err("界面图最多 4 张".to_string());
}
let root_ids = recognition_root_image_ids(&state);
if root_ids.is_empty() {
app_log!("ui_recognition.error stage=validate reason=no_root_image");
return Err("至少需要一张可作为识别上下文根的界面图".to_string());
}
let root_ids = state.ui_design_images.keys().cloned().collect::<Vec<_>>();
let (llm, client) = if provider_identity.is_none() {
let llm = load_game_creator_app_config()
.map_err(|error| {
@@ -683,12 +648,9 @@ pub(crate) async fn recognize_ui_impl_with_provider(
let root = Path::new(project_path.trim());
let mut ui_trees = Vec::with_capacity(root_ids.len());
for root_id in root_ids {
let mut context_ids = vec![root_id.clone()];
context_ids.extend(slave_image_ids(&state, &root_id));
// Page 仍可把直接 slave 参考图放在同一识别上下文;没有 Page
// 归属关系的其它界面图各自作为独立上下文根。
let context_ids = vec![root_id.clone()];
let mut parts = Vec::with_capacity(context_ids.len() * 2);
for (index, context_id) in context_ids.iter().enumerate() {
for context_id in context_ids.iter() {
let image = state
.ui_design_images
.get(context_id)
@@ -714,8 +676,7 @@ pub(crate) async fn recognize_ui_impl_with_provider(
})?;
parts.push(LlmMessageContentPart::InputText {
text: format!(
"{} id={} pixel_size={:?}",
if index == 0 { "ROOT" } else { "SLAVE" },
"ROOT id={} pixel_size={:?}",
context_id.as_str(),
image.pixel_size
),
@@ -768,21 +729,18 @@ pub(crate) async fn recognize_ui_impl_with_provider(
!response.text.trim().is_empty(),
response.tool_calls.len()
);
let call = response
.tool_calls
.iter()
.find(|call| call.name == "recognize_ui_structure")
.ok_or_else(|| {
let arguments = required_tool_call_arguments(&response, "recognize_ui_structure")
.map_err(|error| {
app_log!(
"ui_recognition.error stage=parse_tool_call root={} reason=missing_tool_call",
"ui_recognition.error stage=parse_tool_call reason=missing_tool_call root={} error={error}",
root_id.as_str()
);
format!(
"根界面图 {}LLM 未返回 recognize_ui_structure 工具调用",
"LLM 未返回 recognize_ui_structure 工具调用(根界面图 {}",
root_id.as_str()
)
})?;
let arguments = parse_limited_llm_tool_arguments(&call.arguments).map_err(|error| {
let arguments = parse_limited_llm_tool_arguments(arguments).map_err(|error| {
app_log!(
"ui_recognition.error stage=parse_arguments root={} error={error}",
root_id.as_str()
@@ -858,6 +816,7 @@ pub(crate) async fn recognize_ui_impl_with_provider(
component: recognition_root.component.into_option(),
children_display_mode: ChildrenDisplayMode::Stack,
children,
offset: NodeOffset::default(),
};
// Tree identity and root identity are assigned by Rust, never chosen by the model.
ui_trees.push(UITree {
@@ -22,6 +22,7 @@ mod tests {
use crate::ui_editor::layout::control_layout::ControlLayout;
use crate::ui_editor::layout::node::Node;
use crate::ui_editor::layout::node::{NodeMetadata, NodeSource, StageStatus};
use crate::ui_editor::layout::offset::NodeOffset;
use crate::ui_editor::resource::ui_design_image::UIDesignImage;
use crate::ui_editor::state::{State, UITree};
use crate::ui_editor::utils::{NodeId, UIDesignImageId};
@@ -46,6 +47,7 @@ mod tests {
component,
children_display_mode: ChildrenDisplayMode::Stack,
children,
offset: NodeOffset::default(),
}
}
fn state(root: Node) -> State {
@@ -58,12 +60,6 @@ mod tests {
ui_design_images: HashMap::from([(
image_id,
UIDesignImage {
metadata: crate::ui_editor::resource::ui_design_image::UIDesignImageMetadata {
name: "page".to_string(),
description: String::new(),
role: None,
slave_to: None,
},
path: "page.png".to_string(),
pixel_size: Vector2::new(100.0, 100.0),
pixels_per_unit: StrictlyPositiveFinite::new(1.0).unwrap(),
@@ -1,350 +0,0 @@
use crate::config::build_game_creator_llm_client_from_llm_config;
use crate::config::load_game_creator_app_config;
use crate::ui_editor::commands::utils::{
parse_limited_llm_tool_arguments, read_ui_reference_image_data_url, request_ui_editor_llm,
strict_json_schema,
};
use crate::ui_editor::resource::ui_design_image::UIDesignImageRole;
use crate::ui_editor::state::State;
use crate::ui_editor::utils::UIDesignImageId;
use platform_llm::{
LlmFunctionTool, LlmMessage, LlmMessageContentPart, LlmRunRequest, LlmToolChoice,
};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::path::Path;
use ts_rs::TS;
const SYSTEM_PROMPT: &str = r#"
请识别这些 UI 参考图的界面语义,并调用 suggest_ui_design_semantics 工具返回结果。
不要在工具调用外输出 JSON、Markdown、代码围栏、注释、额外字段或解释文字。
字段含义:
- id:必填,必须逐字匹配当前输入参考图的 id。它标识“这条建议属于哪张图”,不是新 ID,不能为 null。
- name:要写入该图片 metadata 的简短、可读名称;
- description:要写入该图片 metadata 的简短语义描述,例如“带底部导航的主游戏页面”。
- role:要写入该图片 metadata 的界面角色。
Page 表示完整主页面,
Section 表示同一主页面中的子界面或页签,
Modal/Drawer/Popover 表示浮层或局部覆盖界面,
State 表示同一界面的状态变体,
Scrolled 表示滚动或分页后的内容,
Detail 表示局部详情或补充证据。
- children:该节点直接包含的子界面图。
- 根节点必须是 Page,子节点不能是 Page
所有字段都必须出现,禁止省略字段、使用 null 或返回空字符串。每张参考图最多出现一次;id 必须逐字匹配输入图片 id。
以下 UI 设计图中的 metadata 部分信息已确定,请根据已有信息补全语义.
"#;
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS, JsonSchema)]
#[schemars(deny_unknown_fields)]
#[serde(deny_unknown_fields)]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))]
pub struct UIDesignSuggestionTreeNode {
pub id: UIDesignImageId,
pub name: String,
pub description: String,
pub role: UIDesignImageRole,
pub children: Vec<UIDesignSuggestionTreeNode>,
}
#[derive(Clone, Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
#[serde(deny_unknown_fields)]
struct UIDesignSuggestion {
ui_designs: Vec<UIDesignSuggestionTreeNode>,
}
fn ui_design_suggestion_json_schema() -> Result<serde_json::Value, String> {
strict_json_schema::<UIDesignSuggestion>()
}
const MAX_REFERENCES: usize = 4;
const MAX_SUGGESTION_TREE_DEPTH: usize = 4;
fn validate_suggestion_response_shape(value: &serde_json::Value) -> Result<(), String> {
let roots = value
.get("ui_designs")
.and_then(serde_json::Value::as_array)
.ok_or_else(|| "UI 语义建议工具参数缺少 ui_designs 数组".to_string())?;
let mut stack = roots.iter().map(|node| (node, 1usize)).collect::<Vec<_>>();
let mut node_count = 0usize;
while let Some((node, depth)) = stack.pop() {
if depth > MAX_SUGGESTION_TREE_DEPTH {
return Err(format!(
"UI 语义建议树最大深度不能超过 {MAX_SUGGESTION_TREE_DEPTH}"
));
}
node_count += 1;
if node_count > MAX_REFERENCES {
return Err(format!("UI 语义建议最多包含 {MAX_REFERENCES} 个节点"));
}
let children = node
.get("children")
.and_then(serde_json::Value::as_array)
.ok_or_else(|| "UI 语义建议节点缺少 children 数组".to_string())?;
stack.extend(children.iter().map(|child| (child, depth + 1)));
}
Ok(())
}
fn validate_suggestions(
suggestions: &[UIDesignSuggestionTreeNode],
image_ids: &HashSet<UIDesignImageId>,
) -> Result<(), String> {
let mut seen = HashSet::new();
fn visit(
nodes: &[UIDesignSuggestionTreeNode],
is_root: bool,
image_ids: &HashSet<UIDesignImageId>,
seen: &mut HashSet<UIDesignImageId>,
) -> Result<(), String> {
for node in nodes {
if !image_ids.contains(&node.id) {
return Err("LLM 返回了未知界面图 ID".to_string());
}
if !seen.insert(node.id.clone()) {
return Err("LLM 返回了重复界面图建议".to_string());
}
if node.name.trim().is_empty() || node.description.trim().is_empty() {
return Err("界面图语义字段不能是空字符串".to_string());
}
if is_root {
if node.role != UIDesignImageRole::Page {
return Err("界面图树根节点必须是 Page".to_string());
}
} else if node.role == UIDesignImageRole::Page {
return Err("Page 不能作为其它界面图的子节点".to_string());
}
visit(&node.children, false, image_ids, seen)?;
}
Ok(())
}
visit(suggestions, true, image_ids, &mut seen)?;
if seen != *image_ids {
return Err("LLM 未为每张界面图返回语义建议".to_string());
}
Ok(())
}
#[tauri::command]
pub(crate) async fn suggest_ui_design_semantic_impl(
project_path: String,
state: State,
) -> Result<Vec<UIDesignSuggestionTreeNode>, String> {
if state.ui_design_images.is_empty() {
app_log!("ui_design_suggestion.error stage=validate reason=no_images");
return Err("请先导入界面图".to_string());
}
if state.ui_design_images.len() > MAX_REFERENCES {
app_log!(
"ui_design_suggestion.error stage=validate reason=too_many_images count={}",
state.ui_design_images.len()
);
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).map_err(|error| {
app_log!(
"ui_design_suggestion.error stage=resolve_image id={} error={error}",
id.as_str()
);
error
})?;
let image_url = read_ui_reference_image_data_url(absolute)
.await
.map_err(|error| {
app_log!(
"ui_design_suggestion.error stage=read_image id={} error={error}",
id.as_str()
);
error
})?;
parts.push(LlmMessageContentPart::InputText {
text: format!("REFERENCE id:{} metadata:{}", id.as_str(), image.metadata,),
});
parts.push(LlmMessageContentPart::InputImage { image_url });
}
let llm = load_game_creator_app_config()
.map_err(|error| {
eprintln!("ui_design_suggestion.error stage=build_client error={error}");
error
})?
.llm;
let client = build_game_creator_llm_client_from_llm_config(&llm, "llm").map_err(|error| {
app_log!("ui_design_suggestion.error stage=build_client error={error}");
error
})?;
let schema = ui_design_suggestion_json_schema().map_err(|error| {
app_log!("ui_design_suggestion.error stage=build_schema error={error}");
error
})?;
let tool = LlmFunctionTool::new(
"suggest_ui_design_semantics",
"为所有 UI 参考图补全界面语义 metadata",
schema,
)
.with_strict(true);
let response = request_ui_editor_llm(
&client,
&llm,
LlmRunRequest::new(vec![
LlmMessage::system(SYSTEM_PROMPT),
LlmMessage::user_multimodal(parts),
])
.with_function_tools(vec![tool])
.with_tool_choice(LlmToolChoice::Required),
)
.await
.map_err(|error| {
app_log!("ui_design_suggestion.error stage=llm_request error={error}");
format!("UI 参考图语义识别失败:{error}")
})?;
app_log!(
"ui_design_suggestion.llm_output text_present={} tool_call_count={}",
!response.text.trim().is_empty(),
response.tool_calls.len()
);
let call = response
.tool_calls
.iter()
.find(|call| call.name == "suggest_ui_design_semantics")
.ok_or_else(|| {
app_log!("ui_design_suggestion.error stage=parse_tool_call reason=missing_tool_call");
"LLM 响应无效(详情:未返回 suggest_ui_design_semantics 工具调用)".to_string()
})?;
let arguments = parse_limited_llm_tool_arguments(&call.arguments).map_err(|error| {
app_log!("ui_design_suggestion.error stage=parse_arguments error={error}");
format!("LLM 响应无效(详情:UI 语义建议工具参数无效:{error}")
})?;
validate_suggestion_response_shape(&arguments).map_err(|error| {
app_log!("ui_design_suggestion.error stage=validate_arguments error={error}");
format!("LLM 响应无效(详情:{error}")
})?;
let suggestions = serde_json::from_value::<UIDesignSuggestion>(arguments)
.map_err(|error| {
app_log!("ui_design_suggestion.error stage=parse_arguments error={error}");
format!("LLM 响应无效(详情:UI 语义建议工具参数无效:{error}")
})?
.ui_designs;
validate_suggestions(&suggestions, &ids).map_err(|error| {
app_log!("ui_design_suggestion.error stage=validate_result error={error}");
format!("LLM 响应无效(详情:{error}")
})?;
Ok(suggestions)
}
#[cfg(test)]
mod tests {
use super::*;
fn id(value: &str) -> UIDesignImageId {
UIDesignImageId::new(value).expect("valid image id")
}
fn node(
value: &str,
role: UIDesignImageRole,
children: Vec<UIDesignSuggestionTreeNode>,
) -> UIDesignSuggestionTreeNode {
UIDesignSuggestionTreeNode {
id: id(value),
name: value.to_string(),
description: format!("{value} description"),
role,
children,
}
}
fn image_ids(values: &[&str]) -> HashSet<UIDesignImageId> {
values.iter().map(|value| id(value)).collect()
}
#[test]
fn validates_page_tree() {
let suggestions = vec![node(
"page",
UIDesignImageRole::Page,
vec![node("section", UIDesignImageRole::Section, Vec::new())],
)];
assert!(validate_suggestions(&suggestions, &image_ids(&["page", "section"])).is_ok());
}
#[test]
fn suggestion_shape_rejects_more_than_four_nodes_or_four_levels() {
let oversized = (0..=MAX_REFERENCES)
.map(|_| serde_json::json!({"children": []}))
.collect::<Vec<_>>();
assert!(validate_suggestion_response_shape(&serde_json::json!({
"ui_designs": oversized
}))
.is_err());
let mut nested = serde_json::json!({"children": []});
for _ in 0..MAX_SUGGESTION_TREE_DEPTH {
nested = serde_json::json!({"children": [nested]});
}
assert!(validate_suggestion_response_shape(&serde_json::json!({
"ui_designs": [nested]
}))
.is_err());
}
#[test]
fn rejects_duplicate_and_unknown_ids() {
let duplicate = vec![
node(
"page",
UIDesignImageRole::Page,
vec![node("section", UIDesignImageRole::Section, Vec::new())],
),
node("page", UIDesignImageRole::Page, Vec::new()),
];
assert!(validate_suggestions(&duplicate, &image_ids(&["page", "section"])).is_err());
let unknown = vec![node("missing", UIDesignImageRole::Page, Vec::new())];
assert!(validate_suggestions(&unknown, &image_ids(&["page"])).is_err());
}
#[test]
fn rejects_invalid_root_roles_and_nested_pages() {
let non_page_root = vec![node("section", UIDesignImageRole::Section, Vec::new())];
assert!(validate_suggestions(&non_page_root, &image_ids(&["section"])).is_err());
let nested_page = vec![node(
"page",
UIDesignImageRole::Page,
vec![node("nested", UIDesignImageRole::Page, Vec::new())],
)];
assert!(validate_suggestions(&nested_page, &image_ids(&["page", "nested"])).is_err());
}
#[test]
fn strict_schema_requires_tree_fields() {
let schema = ui_design_suggestion_json_schema().expect("schema");
assert_eq!(schema["required"], serde_json::json!(["ui_designs"]));
let node_schema = &schema["properties"]["ui_designs"]["items"];
let required = node_schema["required"]
.as_array()
.expect("node required")
.iter()
.filter_map(serde_json::Value::as_str)
.collect::<HashSet<_>>();
assert_eq!(
required,
["id", "name", "description", "role", "children"]
.into_iter()
.collect()
);
assert_eq!(node_schema["additionalProperties"], false);
}
}
@@ -95,6 +95,29 @@ pub(crate) fn parse_limited_llm_tool_arguments(
serde_json::from_str(arguments).map_err(|error| format!("LLM 工具参数不是有效 JSON{error}"))
}
/// Stable structured-action adapter: locate the required tool call and parse its
/// bounded JSON arguments. Prompt, schema and materialization stay in each
/// operation-specific command.
pub(crate) fn required_tool_arguments(
response: &LlmRunResponse,
tool_name: &str,
) -> Result<serde_json::Value, String> {
let arguments = required_tool_call_arguments(response, tool_name)?;
parse_limited_llm_tool_arguments(arguments)
}
pub(crate) fn required_tool_call_arguments<'a>(
response: &'a LlmRunResponse,
tool_name: &str,
) -> Result<&'a str, String> {
response
.tool_calls
.iter()
.find(|call| call.name == tool_name)
.map(|call| call.arguments.as_str())
.ok_or_else(|| format!("LLM 未返回 {tool_name} 工具调用"))
}
pub(crate) async fn read_ui_reference_image_data_url(path: PathBuf) -> Result<String, String> {
tokio::task::spawn_blocking(move || read_ui_reference_image_data_url_blocking(&path))
.await
@@ -84,8 +84,6 @@ pub(crate) fn render_ui_design_state_js(
"genarrative-ui-tree",
json!({
"srcUiDesign": tree.src_ui_design.as_str(),
"name": image.metadata.name,
"description": image.metadata.description,
}),
)
.into_string();
@@ -2,4 +2,5 @@ pub mod children_display_mode;
pub mod control_layout;
pub mod dimension;
pub mod node;
pub mod offset;
pub mod transform;
@@ -1,6 +1,7 @@
use crate::ui_editor::component::Component;
use crate::ui_editor::layout::children_display_mode::ChildrenDisplayMode;
use crate::ui_editor::layout::control_layout::ControlLayout;
use crate::ui_editor::layout::offset::NodeOffset;
use crate::ui_editor::utils::NodeId;
use serde::{Deserialize, Serialize};
use ts_rs::TS;
@@ -14,6 +15,7 @@ pub struct Node {
pub component: Option<Component>,
pub children_display_mode: ChildrenDisplayMode,
pub children: Vec<Node>,
pub offset: NodeOffset,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)]
@@ -0,0 +1,20 @@
use serde::{Deserialize, Serialize};
use ts_rs::TS;
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))]
pub struct NodeOffset {
#[ts(as = "[f32; 2]")]
pub min: [f32; 2],
#[ts(as = "[f32; 2]")]
pub max: [f32; 2],
}
impl Default for NodeOffset {
fn default() -> Self {
Self {
min: [0.0, 0.0],
max: [0.0, 0.0],
}
}
}
@@ -2,9 +2,7 @@ use crate::ui_editor::component::text::FontSource;
use crate::ui_editor::component::Component;
use crate::ui_editor::html_renderer::render_ui_design_state_js;
use crate::ui_editor::layout::node::Node;
use crate::ui_editor::resource::ui_design_image::{
UIDesignImage, UIDesignImageMetadata, UIDesignImageRole,
};
use crate::ui_editor::resource::ui_design_image::UIDesignImage;
use crate::ui_editor::state::State;
use crate::ui_editor::utils::UIDesignImageId;
use crate::*;
@@ -139,12 +137,6 @@ pub(crate) fn initialize_ui_design_state_with_source_image_at(
document.state.ui_design_images.insert(
source_image_id,
UIDesignImage {
metadata: UIDesignImageMetadata {
name: "游戏界面原型".to_string(),
description: "由画布 UI 原型桥接载入".to_string(),
role: Some(UIDesignImageRole::Page),
slave_to: None,
},
path: source_image_path,
pixel_size: Vector2::new(pixel_size.0 as f32, pixel_size.1 as f32),
pixels_per_unit,
@@ -614,16 +606,7 @@ fn validate_state(state: &State) -> Result<(), String> {
{
return Err("界面图像素尺寸必须为正有限数值".to_string());
}
if image
.metadata
.slave_to
.as_ref()
.is_some_and(|owner| !state.ui_design_images.contains_key(owner))
{
return Err("界面图 slaveTo 引用了不存在的界面图".to_string());
}
}
validate_slave_to_acyclic(state)?;
for (id, sprite) in &state.sprite_assets {
validate_id(id.as_str(), "独立素材 ID")?;
if sprite.asset_id != *id {
@@ -649,23 +632,6 @@ fn validate_state(state: &State) -> Result<(), String> {
Ok(())
}
fn validate_slave_to_acyclic(state: &State) -> Result<(), String> {
for start in state.ui_design_images.keys() {
let mut current = Some(start);
let mut visited = HashSet::new();
while let Some(id) = current {
if !visited.insert(id) {
return Err("界面图 slaveTo 不能形成循环".to_string());
}
current = state
.ui_design_images
.get(id)
.and_then(|image| image.metadata.slave_to.as_ref());
}
}
Ok(())
}
fn validate_node(
node: &Node,
state: &State,
@@ -1050,12 +1016,6 @@ mod tests {
"ui_trees": [],
"ui_design_images": {
"page": {
"metadata": {
"name": "主界面",
"description": "",
"role": "Page",
"slave_to": null
},
"path": path,
"pixel_size": [1280.0, 720.0],
"pixels_per_unit": 1.0
@@ -1128,18 +1088,14 @@ mod tests {
}
},
"children_display_mode": "Stack",
"children": []
}]
"children": [],
"offset": { "min": [0.0, 0.0], "max": [0.0, 0.0] }
}],
"offset": { "min": [0.0, 0.0], "max": [0.0, 0.0] }
}
}],
"ui_design_images": {
"page": {
"metadata": {
"name": "主界面",
"description": "",
"role": "Page",
"slave_to": null
},
"path": "assets/page.png",
"pixel_size": [1280.0, 720.0],
"pixels_per_unit": 1.0
@@ -1311,17 +1267,12 @@ mod tests {
},
"component": null,
"children_display_mode": "Stack",
"children": []
"children": [],
"offset": { "min": [0.0, 0.0], "max": [0.0, 0.0] }
}
}],
"ui_design_images": {
"page": {
"metadata": {
"name": "主界面",
"description": "",
"role": "Page",
"slave_to": null
},
"path": "missing.png",
"pixel_size": [1280.0, 720.0],
"pixels_per_unit": 1.0
@@ -1338,45 +1289,6 @@ mod tests {
);
}
#[test]
fn rejects_cyclic_slave_to_relationships() {
let state: State = serde_json::from_value(serde_json::json!({
"ui_trees": [],
"ui_design_images": {
"page": {
"metadata": {
"name": "主界面",
"description": "",
"role": "Page",
"slave_to": "section"
},
"path": "page.png",
"pixel_size": [1280.0, 720.0],
"pixels_per_unit": 1.0
},
"section": {
"metadata": {
"name": "子页面",
"description": "",
"role": "Section",
"slave_to": "page"
},
"path": "section.png",
"pixel_size": [1280.0, 720.0],
"pixels_per_unit": 1.0
}
},
"sprite_assets": {},
"font_assets": {}
}))
.expect("deserialize cyclic state");
assert_eq!(
validate_state(&state),
Err("界面图 slaveTo 不能形成循环".to_string())
);
}
#[test]
fn recovers_last_valid_state_when_primary_is_corrupt() {
let (directory, asset_id) = fixture();
@@ -1545,17 +1457,12 @@ mod tests {
}
},
"children_display_mode": "Stack",
"children": []
"children": [],
"offset": { "min": [0.0, 0.0], "max": [0.0, 0.0] }
}
}],
"ui_design_images": {
"page": {
"metadata": {
"name": "主界面",
"description": "",
"role": "Page",
"slave_to": null
},
"path": "assets/page.png",
"pixel_size": [1280.0, 720.0],
"pixels_per_unit": 1.0
@@ -1,48 +1,14 @@
use crate::ui_editor::utils::UIDesignImageId;
use nalgebra::Vector2;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::fmt::{Display, Formatter};
use ts_rs::TS;
use typed_floats::tf32::StrictlyPositiveFinite;
#[derive(Clone, Copy, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize, TS)]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))]
pub enum UIDesignImageRole {
Page,
Section,
Modal,
Drawer,
Popover,
State,
Scrolled,
Detail,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))]
pub struct UIDesignImage {
pub(crate) metadata: UIDesignImageMetadata,
pub(crate) path: String,
#[ts(as = "[f32; 2]")]
pub(crate) pixel_size: Vector2<f32>,
#[ts(as = "f32")]
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 {
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 {
write!(
f,
"name: {}, description: {}, role: {:?}, slave_to: {:?}",
self.name, self.description, self.role, self.slave_to
)
}
}
@@ -11,9 +11,7 @@ use crate::ui_editor::persistence::{
};
use crate::ui_editor::resource::font::FontAsset;
use crate::ui_editor::resource::sprite::{SpriteAsset, SpriteAssetMetadata, SpriteBorder};
use crate::ui_editor::resource::ui_design_image::{
UIDesignImage, UIDesignImageMetadata, UIDesignImageRole,
};
use crate::ui_editor::resource::ui_design_image::UIDesignImage;
use crate::ui_editor::utils::{SpriteAssetId, UIDesignImageId};
use crate::*;
use image::GenericImageView as _;
@@ -801,7 +799,7 @@ fn ensure_page_ui_resource(
})?;
let image_id = UIDesignImageId::new(page.page_id.clone())
.map_err(|error| format!("页面 image ID 无效:{error}"))?;
let expected_image = workflow_design_image(root, page, design_asset)?;
let expected_image = workflow_design_image(root, design_asset)?;
let mut state = snapshot.state;
match state.ui_design_images.get(&image_id) {
Some(current) if current != &expected_image => {
@@ -1235,7 +1233,6 @@ fn workflow_stage_rank(kind: &str) -> Option<u8> {
fn workflow_design_image(
root: &Path,
page: &UiWorkflowPageInput,
design_asset: &GameCreationAppAssetManifestEntry,
) -> Result<UIDesignImage, String> {
let absolute = resolve_local_project_path(root, &design_asset.local_path)?;
@@ -1248,12 +1245,6 @@ fn workflow_design_image(
return Err("页面设计图尺寸无效".to_string());
}
Ok(UIDesignImage {
metadata: UIDesignImageMetadata {
name: page.title.trim().to_string(),
description: page.description.trim().to_string(),
role: Some(UIDesignImageRole::Page),
slave_to: None,
},
path: design_asset.local_path.clone(),
pixel_size: Vector2::new(width as f32, height as f32),
pixels_per_unit: StrictlyPositiveFinite::new(1.0)
@@ -66,12 +66,6 @@ export async function prepareDesignImageBatch(
scopeId,
);
const image: UIDesignImage = {
metadata: {
name: '',
description: '',
role: null,
slave_to: null,
},
path: asset.localPath,
pixel_size: pixelSize,
pixels_per_unit: 1,
@@ -1,6 +1,8 @@
import type { Node } from './types/Node';
import type { NodeId } from './types/NodeId';
import type { State } from './types/State';
import type { Transform } from './types/Transform';
import type { UIDesignImageId } from './types/UIDesignImageId';
export type ResizeAxis = 'horizontal' | 'vertical';
@@ -17,6 +19,32 @@ export type NodePageContext = {
parentRect: PageRect;
};
/** State-level geometry seam shared by preview, inspector and transitions. */
export function findStateNodePageContext(
state: State,
treeId: UIDesignImageId,
nodeId: NodeId,
): NodePageContext | null {
const tree = state.ui_trees.find(
(candidate) => candidate.src_ui_design === treeId,
);
const image = state.ui_design_images[treeId];
if (
!tree ||
!image ||
!Number.isFinite(image.pixels_per_unit) ||
image.pixels_per_unit <= 0
) {
return null;
}
const size: [number, number] = [
image.pixel_size[0] / image.pixels_per_unit,
image.pixel_size[1] / image.pixels_per_unit,
];
if (!size.every((value) => Number.isFinite(value) && value > 0)) return null;
return findNodePageContext(tree.root, nodeId, pageRectFromSize(size));
}
const MIN_NODE_SIZE = 1;
export function pageRectFromSize(size: [number, number]): PageRect {
@@ -38,37 +38,6 @@ export function validateComponentRecognitionPrerequisites(
if (ids.length > 4) {
issues.push({ code: 'design-image-limit', message: '界面图最多 4 张' });
}
for (const id of ids) {
const image = state.ui_design_images[id]!;
const { role, slave_to: slaveTo } = image.metadata;
if (role === 'Page' && slaveTo !== null) {
issues.push({
code: 'page-has-slave-to',
message: '主页面不能设置归属页面',
resourceId: id,
});
} else if (role !== null && role !== 'Page') {
if (slaveTo === null) {
issues.push({
code: 'missing-slave-to',
message: '该界面角色需要选择归属主页面',
resourceId: id,
});
} else if (slaveTo === id) {
issues.push({
code: 'self-slave-to',
message: '界面不能归属于自身',
resourceId: id,
});
} else if (state.ui_design_images[slaveTo]?.metadata.role !== 'Page') {
issues.push({
code: 'invalid-slave-to',
message: '归属页面必须是有效主页面',
resourceId: id,
});
}
}
}
return issues;
}
@@ -131,24 +100,6 @@ function visitNodes(
}
// 结果检查与进入步骤的前置条件保持分离;调用方只把结果作为非阻塞提示。
export function validateReferenceAnalysisResult(
state: State,
): UiEditorPrerequisiteIssue[] {
const images = Object.values(state.ui_design_images);
if (images.length === 0) {
return [{ code: 'missing-analysis-input', message: '尚未导入参考图' }];
}
if (images.every((image) => image.metadata.role === null)) {
return [
{
code: 'missing-reference-analysis',
message: '参考图尚未设置界面用途',
},
];
}
return [];
}
export function validateStructureRecognitionResult(
state: State,
): UiEditorPrerequisiteIssue[] {
@@ -1,4 +1,5 @@
import type { Node as UiNode } from './types/Node';
import type { NodeId } from './types/NodeId';
import type { NodeMetadata } from './types/NodeMetadata';
import type { StageStatus } from './types/StageStatus';
import type { UIDesignImageId } from './types/UIDesignImageId';
@@ -14,6 +15,11 @@ export type UiTreeNodeTarget = {
node: UiNode;
};
export type UiTreeNodeCursor = {
treeId: UIDesignImageId;
nodeId: NodeId;
};
export type StageStatusOverview = {
total: number;
needsAttention: number;
@@ -79,22 +85,28 @@ export function getStageStatusTargets(
export function getNextUiTreeNodeTarget(
targets: UiTreeNodeTarget[],
previousNodeId: string | null,
previous: string | UiTreeNodeCursor | null,
): UiTreeNodeTarget | null {
if (targets.length === 0) return null;
const previousIndex = targets.findIndex(
({ node }) => node.id === previousNodeId,
);
let previousIndex = -1;
if (typeof previous === 'string') {
previousIndex = targets.findIndex(({ node }) => node.id === previous);
} else if (previous) {
previousIndex = targets.findIndex(
({ treeId, node }) =>
treeId === previous.treeId && node.id === previous.nodeId,
);
}
return targets[(previousIndex + 1) % targets.length] ?? null;
}
export function getNextMatchingUiTreeNodeTarget(
uiTrees: UITree[],
previousNodeId: string | null,
previous: string | UiTreeNodeCursor | null,
matches: (target: UiTreeNodeTarget) => boolean,
): UiTreeNodeTarget | null {
return getNextUiTreeNodeTarget(
collectUiTreeNodeTargets(uiTrees).filter(matches),
previousNodeId,
previous,
);
}
@@ -0,0 +1,53 @@
import type { Node } from './types/Node';
import type { State } from './types/State';
export type UiDesignInvariantIssue = {
code: 'duplicate-node' | 'invalid-image' | 'missing-tree-image';
message: string;
};
/** Frontend display/save projection of persistable State invariants.
* Rust remains authoritative; this seam only prevents obviously invalid saves
* and gives the view a stable, user-visible failure message.
*/
export function validateUiDesignState(state: State): UiDesignInvariantIssue[] {
const issues: UiDesignInvariantIssue[] = [];
const nodeIds = new Set<string>();
for (const [id, image] of Object.entries(state.ui_design_images)) {
if (
image.path.trim() === '' ||
!image.pixel_size.every((value) => Number.isFinite(value) && value > 0) ||
!Number.isFinite(image.pixels_per_unit) ||
image.pixels_per_unit <= 0
) {
issues.push({
code: 'invalid-image',
message: `界面图 ${id} 的尺寸或路径无效`,
});
}
}
const visit = (node: Node) => {
if (nodeIds.has(node.id)) {
issues.push({
code: 'duplicate-node',
message: `节点 ID 重复:${node.id}`,
});
}
nodeIds.add(node.id);
for (const child of node.children) visit(child);
};
for (const tree of state.ui_trees) {
if (!state.ui_design_images[tree.src_ui_design]) {
issues.push({
code: 'missing-tree-image',
message: `UI 树引用了缺失界面图:${tree.src_ui_design}`,
});
}
visit(tree.root);
}
return issues;
}
export function firstUiDesignInvariantMessage(state: State): string | null {
return validateUiDesignState(state)[0]?.message ?? null;
}
@@ -0,0 +1,125 @@
import type { Component } from './types/Component';
import type { Node } from './types/Node';
import type { NodeId } from './types/NodeId';
import type { NodeMetadata } from './types/NodeMetadata';
import type { State } from './types/State';
import type { UIDesignImageId } from './types/UIDesignImageId';
export type StateTransitionFailure =
| 'missing'
| 'invalid'
| `invalid:${string}`;
export type StateTransitionResult =
| { ok: true; state: State }
| { ok: false; reason: StateTransitionFailure };
export type UiEditorCommand =
| {
type: 'set-tree-offset';
treeId: UIDesignImageId;
min: [number, number];
}
| {
type: 'set-node-metadata';
treeId: UIDesignImageId;
nodeId: NodeId;
patch: Partial<
Pick<
NodeMetadata,
| 'name'
| 'description'
| 'layout_status'
| 'component_status'
| 'allow_llm_edit_layout'
| 'allow_llm_edit_component'
>
>;
}
| {
type: 'set-node-component';
treeId: UIDesignImageId;
nodeId: NodeId;
component: Component | null;
};
function cloneState(state: State): State {
return structuredClone(state);
}
function findNode(node: Node, id: NodeId): Node | null {
if (node.id === id) return node;
for (const child of node.children) {
const found = findNode(child, id);
if (found) return found;
}
return null;
}
function imageLogicalSize(
state: State,
treeId: UIDesignImageId,
): [number, number] | null {
const image = state.ui_design_images[treeId];
if (
!image ||
!Number.isFinite(image.pixels_per_unit) ||
image.pixels_per_unit <= 0
) {
return null;
}
const size: [number, number] = [
image.pixel_size[0] / image.pixels_per_unit,
image.pixel_size[1] / image.pixels_per_unit,
];
return size.every((value) => Number.isFinite(value) && value > 0)
? size
: null;
}
/**
* React-free semantic State transition seam. The hook is an adapter that adds
* locking/history; callers provide a command and receive a complete next State
* or a typed failure, never a partially-mutated tree.
*/
export function applyUiEditorCommand(
current: State,
command: UiEditorCommand,
): StateTransitionResult {
const next = cloneState(current);
const tree = next.ui_trees.find(
(candidate) => candidate.src_ui_design === command.treeId,
);
if (!tree) return { ok: false, reason: 'missing' };
if (command.type === 'set-tree-offset') {
if (!command.min.every(Number.isFinite))
return { ok: false, reason: 'invalid' };
const size = imageLogicalSize(next, command.treeId);
if (!size) return { ok: false, reason: 'invalid' };
tree.root.offset = {
min: [...command.min],
max: [command.min[0] + size[0], command.min[1] + size[1]],
};
return { ok: true, state: next };
}
const node = findNode(tree.root, command.nodeId);
if (!node) return { ok: false, reason: 'missing' };
if (command.type === 'set-node-component') {
node.component = structuredClone(command.component);
node.metadata.component_status = 'NoProblem';
return { ok: true, state: next };
}
if (command.patch.component_status !== undefined && node.component === null) {
const status = command.patch.component_status;
if (status !== 'NoProblem') return { ok: false, reason: 'invalid' };
}
const patch = Object.fromEntries(
Object.entries(command.patch).filter(([, value]) => value !== undefined),
) as Partial<NodeMetadata>;
node.metadata = { ...node.metadata, ...structuredClone(patch) };
return { ok: true, state: next };
}
@@ -4,5 +4,6 @@ import type { Component } from "./Component";
import type { ControlLayout } from "./ControlLayout";
import type { NodeId } from "./NodeId";
import type { NodeMetadata } from "./NodeMetadata";
import type { NodeOffset } from "./NodeOffset";
export type Node = { id: NodeId, layout: ControlLayout, metadata: NodeMetadata, component: Component | null, children_display_mode: ChildrenDisplayMode, children: Array<Node>, };
export type Node = { id: NodeId, layout: ControlLayout, metadata: NodeMetadata, component: Component | null, children_display_mode: ChildrenDisplayMode, children: Array<Node>, offset: NodeOffset };
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type NodeOffset = { min: [number, number], max: [number, number], };
@@ -1,4 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { UIDesignImageMetadata } from "./UIDesignImageMetadata";
export type UIDesignImage = { metadata: UIDesignImageMetadata, path: string, pixel_size: [number, number], pixels_per_unit: number, };
export type UIDesignImage = { path: string, pixel_size: [number, number], pixels_per_unit: number, };

Some files were not shown because too many files have changed in this diff Show More