Compare commits

..

47 Commits

Author SHA1 Message Date
k88936 d558cfe42d 修复预览画布快捷键测试的平台兼容
根据 navigator.platform 发送 Ctrl 或 Cmd 修饰键。

避免 Apple CI 下快捷键被正确忽略导致断言读取初始缩放值。
2026-09-08 16:00:45 +08:00
k88936 3e6fbaed9a 修复预览画布缩放百分比编辑
Project CI / Repository checks (pull_request) Successful in 2m45s
Project CI / Frontend tests (pull_request) Successful in 2m50s
Project CI / Backend tests (pull_request) Successful in 6m40s
Project CI / Native shell tests (pull_request) Successful in 17m32s
将百分比按钮替换为可编辑数字输入框

失焦时限制缩放范围并同步画布视口

新增百分比输入组件级回归测试并更新交互文档
2026-09-08 11:17:12 +08:00
k88936 3fa19600f2 Merge remote-tracking branch 'origin/fix/ui-editor-minor-adjusts' into fix/ui-editor-minor-adjusts
Project CI / Repository checks (pull_request) Successful in 2m21s
Project CI / Frontend tests (pull_request) Successful in 3m0s
Project CI / Backend tests (pull_request) Successful in 6m58s
Project CI / Native shell tests (pull_request) Successful in 17m24s
2026-09-07 18:49:04 +08:00
k88936 1a03bfebf6 修复UI编辑器通知弹窗文档末尾空行
删除文档末尾多余空行,保留标准EOF换行
2026-09-07 18:26:47 +08:00
k88936 777f928830 Merge branch 'master' into fix/ui-editor-minor-adjusts
Project CI / Frontend tests (pull_request) Successful in 2m38s
Project CI / Repository checks (pull_request) Failing after 2m13s
Project CI / Backend tests (pull_request) Successful in 6m34s
Project CI / Native shell tests (pull_request) Successful in 19m41s
2026-09-07 17:26:21 +08:00
suzmii e15e3b455f 修复AGC大上下文请求体限制 (#295)
Project CI / Repository checks (push) Successful in 3m12s
Project CI / Frontend tests (push) Successful in 3m21s
Project CI / Backend tests (push) Successful in 7m42s
Project CI / Native shell tests (push) Successful in 20m14s
Project CI / Frontend tests (pull_request) Successful in 50m43s
Project CI / Repository checks (pull_request) Successful in 20m21s
Project CI / Backend tests (pull_request) Successful in 6m18s
Project CI / Native shell tests (pull_request) Successful in 18m19s
## 背景与问题

AGC(AI 游戏创作智能体 App)Direct Codex 模式在携带图片工具结果等大上下文调用 LLM 时,`/api/llm/responses` 与 `/api/llm/chat/completions` 会命中 Axum 默认 **2 MiB** 请求体上限,直接被 413 拒绝,大上下文任务无法执行;同时 Codex app-server 的 failed turn 未把上游 413 映射为稳定错误分类,前端只能显示笼统的“其他错误”,无法引导用户处理。

## 改动内容

**1. api-server(server-rs)**
- 新增常量 `LLM_REQUEST_MAX_BODY_BYTES = 32 MiB`,作为两个 LLM 代理路由的正式请求体上限;
- 两个路由显式配置 `DefaultBodyLimit::max(32 MiB)`,避免 Axum 默认 2 MiB 提前拒绝;handler 内保留超限检查,超过 32 MiB 仍返回 `413 PAYLOAD_TOO_LARGE`;
- 补充回归测试:>2 MiB 大上下文请求不再命中默认限制;超过 32 MiB 仍返回 413。

**2. AGC 壳(apps/ai-game-creator-shell)**
- `codex_app_server` 错误映射:上游/连接层 HTTP 413、`PAYLOAD_TOO_LARGE`、`provider request too large` 等统一映射为稳定分类 `codex-app-server-error:request-too-large`,不再落入 `other` 或误判为权限/安全策略错误;
- 前端新增用户可见文案“模型请求体过大,请减少参考图或上下文后重试”,并补充单测断言。

**3. 文档**
- 【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md 补充“2026-09-06 AGC LLM 代理请求体合同”:明确 32 MiB 上限、必须显式配置 `DefaultBodyLimit`、413 映射规则与用户文案。

## 验证

- `cargo test -p api-server llm_routes_accept_large_context_bodies_beyond_axum_default` 通过
- `cargo test -p api-server llm_responses_rejects_bodies_above_explicit_limit` 通过
- `cargo test ... ai-game-creator-shell ... codex_app_server_failed_turn_maps_request_too_large_details` 通过
- `npm run test -- apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts`(27 通过)

全量 workspace 测试与 CI 门禁待推送后由 CI 覆盖。

Reviewed-on: #295
Reviewed-by: 段舒康 <kdletters@qq.com>
Co-authored-by: 董羽秦 <suzmii@qq.com>
Co-committed-by: 董羽秦 <suzmii@qq.com>
2026-09-07 17:05:42 +08:00
k88936 a08b224590 Merge branch 'master' into fix/ui-editor-minor-adjusts
Project CI / Repository checks (pull_request) Failing after 2m16s
Project CI / Backend tests (pull_request) Successful in 7m7s
Project CI / Frontend tests (pull_request) Successful in 2m56s
Project CI / Native shell tests (pull_request) Successful in 20m43s
2026-09-07 15:44:17 +08:00
k88936 964290c641 优化错误报告 (#286)
Project CI / Native shell tests (push) Successful in 21m3s
Project CI / Repository checks (push) Successful in 2m37s
Project CI / Frontend tests (push) Successful in 2m41s
Project CI / Backend tests (push) Successful in 7m2s
样式

before:
![shotmd-1788605870.jpg](/attachments/0470de00-944f-466e-a339-cf9e6cc0b780)
![shotmd-1788605755.jpg](/attachments/3e7be68e-31c5-41ba-9f8b-6dc98ec08868)

after:
![shotmd-1788607435.jpg](/attachments/165c878e-5c1c-4517-b7d5-74ce97a1a99b)
![shotmd-1788608364.jpg](/attachments/4dd20baf-879c-4aa8-ab51-bcc588d48dbc)

逻辑:
每次打开报告modal会再次查询队列中的错误
<video src="attachments/6d9d9bd7-5b7b-4a05-a7c3-ca64b6fb77f3" title="2026-09-05 19-46-02.mp4" controls></video>

---------

Co-authored-by: 段舒康 <kdletters@qq.com>
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/286
Reviewed-by: 段舒康 <kdletters@qq.com>
Co-authored-by: 王德宇 <kvtodev@outlook.com>
Co-committed-by: 王德宇 <kvtodev@outlook.com>
2026-09-07 15:17:21 +08:00
k88936 3f8b438fdc Merge branch 'master' into fix/ui-editor-minor-adjusts
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
2026-09-07 13:11:54 +08:00
k88936 584b0f95cb Merge remote-tracking branch 'origin/fix/ui-editor-minor-adjusts' into fix/ui-editor-minor-adjusts
Project CI / Repository checks (pull_request) Failing after 9s
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
2026-09-07 12:49:13 +08:00
k88936 933ddca561 Merge branch 'master' into fix/ui-editor-minor-adjusts 2026-09-07 11:12:56 +08:00
k88936 6271026e2d Merge branch 'master' into fix/ui-editor-minor-adjusts
Project CI / Repository checks (pull_request) Failing after 2m38s
Project CI / Frontend tests (pull_request) Successful in 3m8s
Project CI / Backend tests (pull_request) Successful in 6m35s
Project CI / Native shell tests (pull_request) Successful in 21m42s
2026-09-05 19:55:27 +08:00
k88936 29b0b944bd 修复缩放控件焦点拦截节点删除
Project CI / Repository checks (pull_request) Failing after 15s
Project CI / Backend tests (pull_request) Failing after 20s
Project CI / Frontend tests (pull_request) Successful in 3m37s
Project CI / Native shell tests (pull_request) Successful in 21m7s
允许缩放滑杆和缩放按钮焦点下继续执行 Delete/Backspace。

修正 UI 树宽度测量重复节点文本并保持预览点击选择语义。

补充缩放焦点快捷键回归测试与交互文档。
2026-09-05 19:54:06 +08:00
k88936 df72dd4bed 接入预览画布加减快捷缩放
复用工具栏中心缩放逻辑并统一加减倍率常量
为预览画布增加悬停与键盘焦点作用域
保留既有适配画布和实际尺寸快捷键行为
2026-09-05 19:49:27 +08:00
k88936 af8e1df139 封装预览画布快捷缩放判定
识别跨平台加减、适配画布与实际尺寸快捷键
限制快捷键作用域并放行可编辑和可操作目标
增加按键变体、重复输入和默认行为回归测试
2026-09-05 19:33:10 +08:00
k88936 c50960f4c4 明确预览画布快捷缩放约定
区分滑杆选择范围与快捷加减的核心视口边界
约定跨平台按键、作用域、事件拦截与既有重置行为
补充组件级回归测试和定向验证标准
2026-09-05 19:25:33 +08:00
k88936 9deae852ec 补充 UI 树布局约定
记录节点操作列与树横向滚动规则
说明深层节点名称不得截断
2026-09-05 18:46:58 +08:00
k88936 39736fb54f 优化 UI 树节点操作布局
统一节点操作列宽度并对齐可见性与删除按钮
为树内容增加横向滚动和动态宽度测量
组件数量改用实心橙色拼图图标徽标
2026-09-05 18:46:37 +08:00
k88936 98c5768ebf 修正缩放滑杆百分比映射
Project CI / Repository checks (pull_request) Failing after 2m35s
Project CI / Frontend tests (pull_request) Failing after 2m9s
Project CI / Native shell tests (pull_request) Failing after 4m57s
Project CI / Backend tests (pull_request) Successful in 6m33s
使用显示百分比同步滑块位置
2026-09-05 14:42:37 +08:00
k88936 9d53c44241 优化预览画布缩放滑杆
新增桌面端可拖动缩放滑杆

保留按钮与视口中心缩放行为
2026-09-05 14:42:02 +08:00
k88936 dd6bd0cb7b 补充预览画布缩放滑杆交互文档
新增桌面端缩放滑杆交互约定

明确范围、键盘操作与验收标准
2026-09-05 14:40:57 +08:00
k88936 48b78c1b33 Merge remote-tracking branch 'origin/fix/ui-editor-minor-adjusts' into fix/ui-editor-minor-adjusts 2026-09-05 13:59:09 +08:00
k88936 d8d4501665 补充弹窗快捷键规范
明确对话框打开时暂停编辑器级撤销重做
2026-09-05 13:54:28 +08:00
k88936 5b75792d6a 修复弹窗内编辑器快捷键误触发
抽取UI编辑器键盘快捷键处理模块

阻止对话框内撤销重做并保留普通按钮快捷键行为
2026-09-05 13:54:26 +08:00
k88936 84222dd44e Merge branch 'master' into fix/ui-editor-minor-adjusts
Project CI / Native shell tests (pull_request) Failing after 4m11s
Project CI / Frontend tests (pull_request) Failing after 30m6s
Project CI / Repository checks (pull_request) Failing after 30m26s
Project CI / Backend tests (pull_request) Successful in 35m27s
2026-09-05 13:50:25 +08:00
k88936 897683e63f 补充UI树删除计数测试
覆盖嵌套节点定位与后代节点递归计数
2026-09-05 13:17:51 +08:00
k88936 8242885c79 新增UI树节点快捷删除
在可删除节点行提供桌面端垃圾桶按钮

复用右键删除入口并对后代节点显示确认弹窗
2026-09-05 13:17:49 +08:00
k88936 b4ba318849 补充UI树快捷删除方案
记录桌面端快捷删除按钮与右键删除复用规则

明确后代节点确认提示、页面根禁删和底层命令边界
2026-09-05 13:01:35 +08:00
k88936 6f29c60d63 统一节点选区的严格空值判断
Project CI / Repository checks (pull_request) Failing after 12s
Project CI / Backend tests (pull_request) Failing after 19s
Project CI / Frontend tests (pull_request) Successful in 2m57s
Project CI / Native shell tests (pull_request) Successful in 18m0s
将 selectedNodeId 的宽松不等比较改为 !== null
2026-09-04 18:07:08 +08:00
k88936 6f0bae7724 让工作流步骤标题映射显式穷举
使用覆盖全部 UiEditorStepId 的 switch 映射步骤标题

增加 never 穷举检查以便新增步骤触发类型错误
2026-09-04 18:06:47 +08:00
k88936 efd67911c9 提取工作流完成状态上报逻辑
统一成功与失败分支的提示拼接、状态更新和终态通知

保持三个工作流步骤现有消息与状态行为不变
2026-09-04 18:06:03 +08:00
k88936 f029992083 修复弹窗内删除快捷键误删节点
排除 role=dialog 和 aria-modal 弹窗内的 Delete/Backspace 快捷键

补充弹窗焦点下节点不应被删除的回归测试

同步更新 UI 编辑器撤销重做规范
2026-09-04 18:04:16 +08:00
k88936 fe813802a5 简化工作流检查提示拼接
Project CI / Repository checks (pull_request) Failing after 12s
Project CI / Backend tests (pull_request) Failing after 10s
Project CI / Frontend tests (pull_request) Successful in 3m26s
Project CI / Native shell tests (pull_request) Successful in 20m41s
移除请检查文案探测逻辑
终态文案统一直接追加检查提示
2026-09-04 16:09:19 +08:00
k88936 7f3cf3b64b 调整工作流通知测试位置
将通知辅助测试纳入AGC测试目录
保持提示文案和步骤映射覆盖
2026-09-04 15:34:18 +08:00
k88936 e6b2539dfe 增加工作流通知文案测试
覆盖请检查提示去重
覆盖三项步骤标题映射
2026-09-04 15:32:48 +08:00
k88936 620c2c5015 接入UI工作流终态通知
为三项工作流动作增加成功失败通知
扩展结果数量与请检查提示
在编辑器页面挂载阻塞通知弹窗
2026-09-04 15:31:54 +08:00
k88936 e7345508df 提取UI工作流通知文案工具
拆分通知类型与文案辅助函数
修复组件快速刷新导出约束
2026-09-04 15:31:35 +08:00
k88936 fc37bfbceb 补充UI工作流完成通知设计
新增工作流终态通知弹窗交互约定
明确三步骤反馈文案与验收边界
2026-09-04 15:26:21 +08:00
k88936 20a065e540 同步 UI 编辑器快捷键规范
Project CI / Repository checks (pull_request) Failing after 16s
Project CI / Backend tests (pull_request) Failing after 15s
Project CI / Frontend tests (pull_request) Successful in 2m47s
Project CI / Native shell tests (pull_request) Successful in 19m22s
记录 macOS Backspace 删除节点快捷键

明确按钮链接焦点下撤销重做与删除快捷键的差异
2026-09-04 13:00:04 +08:00
k88936 fb058adb17 明确删除后的节点选区判断
仅在存在选中节点时查询删除节点集合

避免使用空字符串作为节点 ID 查询值
2026-09-04 12:56:44 +08:00
k88936 4886dffda2 稳定 UI 编辑器删除回调引用
使用 useCallback 保持 deleteNode 在输入未变化时的函数引用稳定

减少全局键盘监听器不必要的解绑与重新注册
2026-09-04 12:56:13 +08:00
k88936 d456b97eaf 恢复工具栏聚焦时撤销快捷键
将删除快捷键的交互控件过滤与撤销重做的可编辑目标过滤分离

避免按钮和链接聚焦时意外屏蔽 Ctrl/Cmd+Z/Y
2026-09-04 12:55:00 +08:00
k88936 21aa894871 修复 UI 编辑器 macOS 删除快捷键
支持 macOS 标准 Delete 键上报的 Backspace 事件

保留原有修饰键、重复事件和交互控件过滤
2026-09-04 12:54:12 +08:00
k88936 2d884f1bc5 补充 Delete 快捷键页面验收测试
Project CI / Repository checks (pull_request) Failing after 15s
Project CI / Backend tests (pull_request) Failing after 15s
Project CI / Frontend tests (pull_request) Successful in 2m50s
Project CI / Native shell tests (pull_request) Successful in 18m50s
验证页面选中节点可通过 Delete 删除

覆盖真实树面板选择与删除后的渲染结果
2026-09-04 11:37:00 +08:00
k88936 5898d38083 支持 UI 编辑器 Delete 删除选中节点
复用现有 deleteNode 命令处理 Delete 快捷键

过滤交互控件、修饰键和重复按键事件

清理删除子树后的无效节点选区并补充页面测试
2026-09-04 11:35:58 +08:00
k88936 a2876eaf65 补充 UI 编辑器 Delete 快捷键规范
明确 Delete 快捷键触发范围与交互控件过滤

规定复用 deleteNode、子树删除、撤销与脏状态语义

补充删除后无效选区清理和验收标准
2026-09-04 11:28:56 +08:00
k88936 754964c57e 完善组件建议,增加容器背景与节点大小一致的实现
Project CI / Repository checks (pull_request) Failing after 12s
Project CI / Backend tests (pull_request) Failing after 11s
Project CI / Frontend tests (pull_request) Successful in 2m59s
Project CI / Native shell tests (pull_request) Failing after 11m40s
2026-09-04 11:03:09 +08:00
48 changed files with 1991 additions and 462 deletions
+1 -2
View File
@@ -1,7 +1,7 @@
{
"name": "@genarrative/ai-game-creator-shell",
"private": true,
"version": "0.1.19",
"version": "0.1.12",
"type": "module",
"scripts": {
"dev": "node scripts/start-tauri-dev.mjs",
@@ -9,7 +9,6 @@
"dev-stack": "node scripts/start-dev-stack.mjs",
"build": "node scripts/build-release.mjs",
"release:upload": "node scripts/release-upload.mjs",
"bump-version": "node scripts/bump-version.mjs",
"skill-pack:check": "node scripts/check-skill-pack.mjs",
"skill-pack:sync": "node scripts/check-skill-pack.mjs --write",
"skill-pack:test": "node --test scripts/check-skill-pack.test.mjs",
@@ -5,7 +5,6 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
const appRoot = fileURLToPath(new URL('..', import.meta.url));
const repoRoot = path.resolve(appRoot, '../..');
const defaultReleaseTarget = 'x86_64-pc-windows-msvc';
const releaseTarget =
process.env.AGC_BUILD_TARGET?.trim() || defaultReleaseTarget;
@@ -50,23 +49,16 @@ function parseVersion(value, label) {
return value;
}
export function bumpVersion(current, target = 'patch') {
const [major, minor, patch] =
parseVersion(current, '当前版本').split('.').map(Number);
if (target === 'major') return `${major + 1}.0.0`;
if (target === 'minor') return `${major}.${minor + 1}.0`;
if (target === 'patch' || target == null) {
if (patch === Number.MAX_SAFE_INTEGER) {
throw new Error(`版本号 patch 已达到上限:${current}`);
}
return `${major}.${minor}.${patch + 1}`;
export function nextPatchVersion(localVersion, remoteVersion) {
const local = parseVersion(localVersion, '本地版本');
const remote =
remoteVersion == null ? null : parseVersion(remoteVersion, 'OSS版本');
const base = remote && compareVersions(remote, local) > 0 ? remote : local;
const [major, minor, patch] = base.split('.').map(Number);
if (patch === Number.MAX_SAFE_INTEGER) {
throw new Error(`版本号 patch 已达到上限:${base}`);
}
if (typeof target === 'string' && /^\d+\.\d+\.\d+$/u.test(target)) {
return parseVersion(target, '指定版本');
}
throw new Error(
`无法识别的版本目标:${String(target)}(支持 patch / minor / major / 明确的三段版本号)`,
);
return `${major}.${minor}.${patch + 1}`;
}
async function readRemoteVersion() {
@@ -96,52 +88,14 @@ function replaceVersionLine(source, version, pattern, label) {
return source.replace(pattern, `$1${version}$3`);
}
const versionFileSources = [
{
file: packageJsonPath,
pattern: /("version"\s*:\s*")([^"]+)(")/u,
label: 'package.json',
},
{
file: rootPackageLockPath,
pattern: /("apps\/ai-game-creator-shell"\s*:\s*\{\s*\n\s*"name"\s*:\s*"@genarrative\/ai-game-creator-shell"\s*,\s*\n\s*"version"\s*:\s*")([^"]+)(")/u,
label: 'package-lock.json',
},
{
file: tauriConfigPath,
pattern: /("version"\s*:\s*")([^"]+)(")/u,
label: 'tauri.conf.json',
},
{
file: cargoManifestPath,
pattern: /(^\[package\][\s\S]*?^version\s*=\s*")([^"]+)(")/mu,
label: 'Cargo.toml',
},
{
file: cargoLockPath,
pattern: /(^name\s*=\s*"genarrative-ai-game-creator-shell"\s*\nversion\s*=\s*")([^"]+)(")/mu,
label: 'Cargo.lock',
},
];
export async function prepareReleaseVersion() {
const localVersion = parseVersion(readPackageJson().version, '本地版本');
const remoteVersion = await readRemoteVersion();
const requestedVersion = process.env.AGC_RELEASE_VERSION?.trim();
const nextVersion = requestedVersion
? parseVersion(requestedVersion, '指定版本')
: nextPatchVersion(localVersion, remoteVersion);
export function readLocalVersion() {
return parseVersion(readPackageJson().version, '本地版本');
}
export function validateVersionConsistency(expectedVersion) {
for (const source of versionFileSources) {
const match = fs.readFileSync(source.file, 'utf8').match(source.pattern);
const actual = match ? match[2].trim() : '<未找到>';
if (actual !== expectedVersion) {
throw new Error(
`AGC 版本号不一致:${source.label} 应为 ${expectedVersion},实际 ${actual}`,
);
}
}
}
export function writeVersionFiles(version) {
const nextVersion = parseVersion(version, '目标版本');
const packageSource = fs.readFileSync(packageJsonPath, 'utf8');
fs.writeFileSync(
packageJsonPath,
@@ -197,47 +151,14 @@ export function writeVersionFiles(version) {
),
);
console.log(
requestedVersion
? `[ai-game-creator-shell] 使用指定版本 ${nextVersion}(本地 ${localVersion} / OSS ${remoteVersion ?? '不存在'}`
: `[ai-game-creator-shell] 版本 ${localVersion} / OSS ${remoteVersion ?? '不存在'} -> ${nextVersion}`,
);
return nextVersion;
}
export function assertVersionCommitted() {
const repoPaths = versionFileSources.map((source) =>
path.relative(repoRoot, source.file).replaceAll('\\', '/'),
);
const changed =
spawnSync(
'git',
['diff', '--quiet', 'HEAD', '--', ...repoPaths],
{ cwd: repoRoot },
).status === 1;
if (changed) {
throw new Error(
'版本文件相对 HEAD 存在未提交改动,请先提交后再发布:运行 npm --prefix apps/ai-game-creator-shell run bump-version -- --commit',
);
}
}
export function prepareReleaseVersion() {
const localVersion = readLocalVersion();
validateVersionConsistency(localVersion);
console.log(
`[ai-game-creator-shell] 使用仓库版本 ${localVersion}(不再自动递增或自由指定版本)`,
);
return localVersion;
}
export async function assertVersionNotBelowOss(version) {
const remoteVersion = await readRemoteVersion();
if (remoteVersion && compareVersions(version, remoteVersion) < 0) {
throw new Error(
`仓库版本 ${version} 低于线上 OSS 版本 ${remoteVersion}。请先运行 npm --prefix apps/ai-game-creator-shell run bump-version 提升版本后再发布。`,
);
}
console.log(
`[ai-game-creator-shell] 线上 OSS 版本 ${remoteVersion ?? '不存在'},发布版本 ${version}`,
);
}
export function runTauriBuild(args = []) {
const noBundle = args.includes('--no-bundle');
const hasTarget = args.includes('--target');
@@ -1,19 +1,16 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { test } from 'node:test';
import { fileURLToPath } from 'node:url';
import {
bumpVersion,
compareVersions,
createUpdateManifest,
nextPatchVersion,
selectReleaseArtifact,
} from './build-release.mjs';
test('selects an explicit release artifact when configured', () => {
const artifactPath = fileURLToPath(
new URL('../package.json', import.meta.url),
);
const artifactPath = new URL('../package.json', import.meta.url).pathname;
const previous = process.env.AGC_UPDATE_ARTIFACT;
process.env.AGC_UPDATE_ARTIFACT = artifactPath;
try {
@@ -33,7 +30,7 @@ test('does not select unsupported files', () => {
test('manifest contains version, download URL and integrity fields', () => {
const manifest = createUpdateManifest(
fileURLToPath(new URL('../package.json', import.meta.url)),
new URL('../package.json', import.meta.url).pathname,
);
assert.match(manifest.version, /^\d+\.\d+\.\d+$/u);
assert.match(
@@ -49,7 +46,7 @@ test('manifest preserves multiline release notes', () => {
process.env.AGC_UPDATE_RELEASE_NOTES = '第一行\n第二行\r\n第三行';
try {
const manifest = createUpdateManifest(
fileURLToPath(new URL('../package.json', import.meta.url)),
new URL('../package.json', import.meta.url).pathname,
);
assert.equal(manifest.releaseNotes, '第一行\n第二行\r\n第三行');
} finally {
@@ -58,14 +55,11 @@ test('manifest preserves multiline release notes', () => {
}
});
test('bump version follows the requested target', () => {
test('next release version follows the higher local or OSS version', () => {
assert.equal(compareVersions('0.1.15', '0.1.12'), 1);
assert.equal(compareVersions('0.1.12', '0.1.12'), 0);
assert.equal(bumpVersion('0.1.19', 'patch'), '0.1.20');
assert.equal(bumpVersion('0.1.19'), '0.1.20');
assert.equal(bumpVersion('0.1.19', 'minor'), '0.2.0');
assert.equal(bumpVersion('0.1.19', 'major'), '1.0.0');
assert.equal(bumpVersion('0.1.12', '0.1.25'), '0.1.25');
assert.equal(nextPatchVersion('0.1.12', '0.1.15'), '0.1.16');
assert.equal(nextPatchVersion('0.1.18', '0.1.15'), '0.1.19');
assert.equal(nextPatchVersion('0.1.12', null), '0.1.13');
});
test('release upload forces overwrite for versioned artifact and latest pointer', () => {
@@ -78,12 +72,3 @@ test('release upload forces overwrite for versioned artifact and latest pointer'
2,
);
});
test('release upload verifies the version is committed and not below OSS', () => {
const source = readFileSync(
new URL('./release-upload.mjs', import.meta.url),
'utf8',
);
assert.match(source, /assertVersionCommitted\(\)/u);
assert.match(source, /assertVersionNotBelowOss\(/u);
});
@@ -1,121 +0,0 @@
import { spawnSync } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const appRoot = fileURLToPath(new URL('..', import.meta.url));
const repoRoot = path.resolve(appRoot, '../..');
const {
bumpVersion,
readLocalVersion,
writeVersionFiles,
} = await import('./build-release.mjs');
const versionFiles = [
'apps/ai-game-creator-shell/package.json',
'package-lock.json',
'apps/ai-game-creator-shell/src-tauri/tauri.conf.json',
'apps/ai-game-creator-shell/src-tauri/Cargo.toml',
'apps/ai-game-creator-shell/src-tauri/Cargo.lock',
];
function parseArgs(argv) {
const args = argv.slice(2);
const explicitIndex = args.indexOf('--version');
const explicit = explicitIndex >= 0 ? args[explicitIndex + 1] : null;
const target =
explicit ??
args.find((arg) => arg === 'patch' || arg === 'minor' || arg === 'major') ??
'patch';
return { target, commit: args.includes('--commit') };
}
function runGit(args) {
const result = spawnSync('git', args, { cwd: repoRoot, stdio: 'inherit' });
if (result.error) throw result.error;
return result.status ?? 1;
}
function hasUncommittedVersionChanges() {
return (
spawnSync('git', ['diff', '--quiet', '--', ...versionFiles], {
cwd: repoRoot,
}).status === 1
);
}
function otherStagedFiles() {
const result = spawnSync(
'git',
['diff', '--cached', '--name-only'],
{ cwd: repoRoot, encoding: 'utf8' },
);
if (result.status !== 0) {
throw new Error('无法读取已暂存文件列表');
}
const versionSet = new Set(versionFiles);
return result.stdout
.split('\n')
.map((file) => file.trim())
.filter(Boolean)
.filter((file) => !versionSet.has(file));
}
const { target, commit } = parseArgs(process.argv);
// 提交前先确认没有其他已暂存改动,避免把无关改动一并提交;若存在则在改动任何文件前中止。
if (commit) {
const others = otherStagedFiles();
if (others.length > 0) {
console.error(
`[bump-version] 检测到其他已暂存改动,为避免误提交,请先单独处理后再运行 --commit:\n ${others.join('\n ')}`,
);
console.error(
'提示:用 git restore --staged <file> 取消暂存,或先提交这些改动。',
);
process.exit(1);
}
}
const current = readLocalVersion();
// 若版本文件已带着一次未提交的提升(例如先默认跑过一次),再 --commit 时不再重复递增,直接提交现有改动。
const next =
commit && hasUncommittedVersionChanges()
? current
: bumpVersion(current, target);
writeVersionFiles(next);
if (!commit) {
console.log(
`[bump-version] 版本 ${current} -> ${next}(已写入版本文件,未创建提交;如需提交加 --commit)`,
);
process.exit(0);
}
const addStatus = runGit(['add', ...versionFiles]);
if (addStatus !== 0) process.exit(addStatus);
const hasDiff =
spawnSync('git', ['diff', '--cached', '--quiet'], {
cwd: repoRoot,
}).status === 1;
if (!hasDiff) {
console.log(`[bump-version] 版本未变化(已是 ${current}),未创建提交`);
process.exit(0);
}
const commitStatus = runGit([
'commit',
'-m',
`提升 AGC 版本至 ${next}`,
'-m',
`- AGC 客户端版本提升至 ${next}`,
'-m',
'- 同步更新 package.json、根 package-lock.json、tauri.conf.json、Cargo.toml、Cargo.lock 中的 AGC 包条目',
]);
if (commitStatus !== 0) process.exit(commitStatus);
console.log(`[bump-version] 已提交版本 ${next}(本地提交,未推送)`);
@@ -9,13 +9,8 @@ if (!/^[a-z0-9][a-z0-9.-]{1,62}$/u.test(bucket) || /[\r\n\0]/u.test(endpoint)) {
}
process.env.AGC_UPDATE_OSS_BASE_URL ||= `https://${bucket}.${endpoint}/agc`;
const {
assertVersionCommitted,
assertVersionNotBelowOss,
generateUpdateManifest,
prepareReleaseVersion,
runTauriBuild,
} = await import('./build-release.mjs');
const { generateUpdateManifest, prepareReleaseVersion, runTauriBuild } =
await import('./build-release.mjs');
function runOssutil(args) {
const binary = process.env.OSSUTIL_BIN?.trim() || 'ossutil';
@@ -41,9 +36,7 @@ function runOssutil(args) {
if (result.status !== 0) process.exit(result.status ?? 1);
}
const releaseVersion = await prepareReleaseVersion();
assertVersionCommitted();
await assertVersionNotBelowOss(releaseVersion);
await prepareReleaseVersion();
runTauriBuild([]);
const { artifact, manifestPath, manifest } = generateUpdateManifest();
const artifactKey = `agc/${manifest.version}/${path.basename(artifact)}`;
+1 -1
View File
@@ -1703,7 +1703,7 @@ dependencies = [
[[package]]
name = "genarrative-ai-game-creator-shell"
version = "0.1.19"
version = "0.1.12"
dependencies = [
"agent-runtime-core",
"axum",
@@ -1,6 +1,6 @@
[package]
name = "genarrative-ai-game-creator-shell"
version = "0.1.19"
version = "0.1.12"
edition = "2021"
publish = false
@@ -266,6 +266,7 @@ fn game_creator_codex_app_server_connection_error(
) -> platform_llm::LlmError {
match game_creator_codex_app_server_error_http_status(info, field) {
Some(401 | 403) => game_creator_codex_app_server_error_kind("unauthorized"),
Some(413) => game_creator_codex_app_server_error_kind("request-too-large"),
Some(status_code) => platform_llm::LlmError::Upstream {
status_code,
message: "Codex app-server 连接上游失败".to_string(),
@@ -303,6 +304,30 @@ fn game_creator_codex_app_server_error_detail_indicates_auth_failure(
|| detail.contains("http 403")
}
fn game_creator_codex_app_server_error_detail_indicates_request_too_large(
error: &serde_json::Value,
) -> bool {
let Some(error) = error.as_object() else {
return false;
};
let detail = ["message", "additionalDetails", "code"]
.into_iter()
.filter_map(|field| error.get(field).and_then(serde_json::Value::as_str))
.collect::<Vec<_>>()
.join(" ")
.to_ascii_lowercase();
if detail.is_empty() {
return false;
}
detail.contains("413 payload too large")
|| detail.contains("http 413")
|| detail.contains("status 413")
|| detail.contains("payload_too_large")
|| detail.contains("payload too large")
|| detail.contains("request too large")
|| detail.contains("provider request too large")
}
fn game_creator_codex_app_server_error_detail_indicates_insufficient_mud_points(
error: &serde_json::Value,
) -> bool {
@@ -333,6 +358,9 @@ fn game_creator_codex_app_server_failed_turn_error(
message: "泥点余额不足".to_string(),
};
}
if game_creator_codex_app_server_error_detail_indicates_request_too_large(error) {
return game_creator_codex_app_server_error_kind("request-too-large");
}
if game_creator_codex_app_server_error_detail_indicates_auth_failure(error) {
return game_creator_codex_app_server_error_kind("unauthorized");
}
@@ -4787,6 +4815,12 @@ mod tests {
"codex-app-server-error:unauthorized".to_string(),
),
),
(
serde_json::json!({"httpConnectionFailed":{"httpStatusCode":413}}),
platform_llm::LlmError::InvalidRequest(
"codex-app-server-error:request-too-large".to_string(),
),
),
(
serde_json::json!({"httpConnectionFailed":{"httpStatusCode":429}}),
platform_llm::LlmError::Upstream {
@@ -4847,6 +4881,31 @@ mod tests {
}
}
#[test]
fn codex_app_server_failed_turn_maps_request_too_large_details() {
for detail in [
"HTTP 413 Payload Too Large",
"status 413",
"PAYLOAD_TOO_LARGE",
"provider request too large",
] {
let error = game_creator_codex_app_server_failed_turn_error(&serde_json::json!({
"status": "failed",
"error": {
"message": detail,
"additionalDetails": "private upstream diagnostics",
"codexErrorInfo": "other"
}
}));
assert_eq!(
error,
platform_llm::LlmError::InvalidRequest(
"codex-app-server-error:request-too-large".to_string(),
)
);
}
}
#[test]
fn codex_app_server_failed_turn_maps_insufficient_mud_points_to_stable_upstream_error() {
for detail in [
@@ -35,7 +35,9 @@ const SYSTEM_PROMPT: &str = r#"
* 对每个 Component,直接完整返回其全部参数.
* 有任何困难或者不确定把状态设为 NeedReview,说明中文原因。
* 纯结构节点可以返回空数组并标为 NoProblem。
* 容器背景等推荐使用Simple + preserve_aspect: false 实现与node大小一致
* 面向用户的 reason 使用中文。
"#;
#[derive(Clone, Debug, Deserialize, JsonSchema)]
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Genarrative AI Game Creator",
"version": "0.1.19",
"version": "0.1.12",
"identifier": "world.genarrative.ai-game-creator",
"build": {
"beforeDevCommand": "npm --prefix ../.. run agc:serve",
@@ -4,6 +4,7 @@ import {
ackClientErrorEventsWithRetry,
type ClientErrorEvent,
type DiagnosticLogFile,
getPendingClientErrorEvents,
getStableErrorReportSubmissionId,
readApplicationDiagnosticLogs,
submitErrorReportBatch,
@@ -12,10 +13,12 @@ import { ThemedModal } from '../modal/ThemedModal';
type ErrorReportDialogProps = {
open: boolean;
events: ClientErrorEvent[];
onClose: () => void;
fallbackEvents?: ClientErrorEvent[];
};
const emptyFallbackEvents: ClientErrorEvent[] = [];
function diagnosticLogLabel(
logsReady: boolean,
logsError: boolean,
@@ -28,9 +31,12 @@ function diagnosticLogLabel(
export function ErrorReportDialog({
open,
events,
onClose,
fallbackEvents = emptyFallbackEvents,
}: ErrorReportDialogProps) {
const [events, setEvents] = useState<ClientErrorEvent[]>([]);
const [eventsReady, setEventsReady] = useState(false);
const [eventsError, setEventsError] = useState(false);
const [selectedFingerprints, setSelectedFingerprints] = useState<Set<string>>(
new Set(),
);
@@ -45,29 +51,59 @@ export function ErrorReportDialog({
useEffect(() => {
if (!open) return;
setSelectedFingerprints(
new Set(events.slice(0, 1).map((event) => event.fingerprint)),
);
let disposed = false;
setEvents([]);
setEventsReady(false);
setEventsError(false);
setSelectedFingerprints(new Set());
setStatus('');
setLogsReady(false);
setLogsError(false);
void getPendingClientErrorEvents()
.then((nextEvents) => {
if (disposed) return;
setEvents(nextEvents);
setSelectedFingerprints(
new Set(nextEvents.map((event) => event.fingerprint)),
);
setEventsReady(true);
})
.catch(() => {
if (disposed) return;
if (fallbackEvents.length) {
setEvents(fallbackEvents);
setSelectedFingerprints(
new Set(fallbackEvents.map((event) => event.fingerprint)),
);
setEventsReady(true);
setStatus('最新错误读取失败,已使用打开通知时的快照');
return;
}
setEventsError(true);
setEventsReady(true);
});
void readApplicationDiagnosticLogs()
.then((nextLogs) => {
if (disposed) return;
setLogs(nextLogs);
setLogsReady(true);
})
.catch(() => {
if (disposed) return;
setLogs([]);
setLogsError(true);
setLogsReady(true);
});
}, [events, open]);
return () => {
disposed = true;
};
}, [fallbackEvents, open]);
const submit = useCallback(async () => {
const selectedEvents = events.filter((event) =>
selectedFingerprints.has(event.fingerprint),
);
if (!selectedEvents.length || busy || !logsReady) return;
if (!selectedEvents.length || busy || !eventsReady || !logsReady) return;
if (includeLogs && logsError) return;
setBusy(true);
setStatus('正在提交…');
@@ -92,6 +128,7 @@ export function ErrorReportDialog({
}, [
busy,
description,
eventsReady,
events,
includeLogs,
logs,
@@ -124,7 +161,11 @@ export function ErrorReportDialog({
<div className="my-5 grid gap-4">
<section>
<strong>{events.length}</strong>
{events.length ? (
{!eventsReady && <p></p>}
{eventsReady && eventsError && (
<p role="alert"></p>
)}
{eventsReady && !eventsError && events.length > 0 && (
<ul className="m-0 mt-2 grid list-none gap-2 p-0">
{events.map((event) => (
<li
@@ -154,7 +195,8 @@ export function ErrorReportDialog({
</li>
))}
</ul>
) : (
)}
{eventsReady && !eventsError && !events.length && (
<p></p>
)}
</section>
@@ -191,11 +233,12 @@ export function ErrorReportDialog({
</button>
<button
className="cursor-pointer rounded-[9px] border-0 bg-[var(--platform-button-primary-fill)] px-3.5 py-[9px] text-[var(--platform-button-primary-text)]"
className="cursor-pointer rounded-[9px] border border-(--platform-button-primary-border) bg-(image:--platform-button-primary-fill) px-3.5 py-[9px] text-(--platform-button-primary-text)"
type="button"
onClick={() => void submit()}
disabled={
busy ||
!eventsReady ||
!selectedFingerprints.size ||
!logsReady ||
(includeLogs && logsError)
@@ -12,7 +12,10 @@ export function ErrorReportNotice() {
const [notificationEvents, setNotificationEvents] = useState<
ClientErrorEvent[]
>([]);
const [reportEvents, setReportEvents] = useState<ClientErrorEvent[]>([]);
const [reportFallbackEvents, setReportFallbackEvents] = useState<
ClientErrorEvent[]
>([]);
const [reportOpen, setReportOpen] = useState(false);
const requestId = useRef(0);
const retryTimer = useRef<number | null>(null);
@@ -65,31 +68,37 @@ export function ErrorReportNotice() {
}, [loadEvents]);
const openReport = () => {
setReportEvents(notificationEvents);
setReportFallbackEvents(notificationEvents);
setReportOpen(true);
setNotificationEvents([]);
};
const closeReport = () => {
setReportOpen(false);
setReportFallbackEvents([]);
};
return (
<>
{notificationEvents.length ? (
<aside
className="fixed top-[calc(var(--window-chrome-height)+14px)] right-4 z-40 flex max-w-[min(420px,calc(100vw-2rem))] items-center gap-2.5 rounded-xl border border-[var(--platform-surface-border)] bg-[var(--platform-subpanel-fill)] px-3.5 py-3 text-[var(--platform-text-strong)] shadow-[0_12px_30px_rgb(36_20_12_/_18%)] max-[560px]:top-[calc(var(--window-chrome-height)+8px)] max-[560px]:right-2 max-[560px]:left-2 max-[560px]:max-w-none"
className="fixed top-[calc(var(--window-chrome-height)+12px)] right-4 z-40 flex max-w-[min(520px,calc(100vw-2rem))] items-center gap-4 rounded-xl border border-[#efc9ae] bg-[#fffaf5] px-3.5 py-3 shadow-[0_8px_24px_rgb(100_49_26_/_16%)]"
role="status"
aria-live="polite"
>
<span className="flex-[1_1_auto]">
{notificationEvents.length}
</span>
<div className="grid min-w-0 gap-[3px]">
<strong className="text-[13px] text-[#4a220f]"></strong>
</div>
<button
className="min-h-9 cursor-pointer rounded-lg border-0 bg-[var(--platform-button-primary-fill)] px-2.5 text-[var(--platform-button-primary-text)] last:w-9 last:bg-transparent last:px-0 last:text-xl last:text-[var(--platform-text-soft)]"
className="flex-[0_0_auto] cursor-pointer rounded-lg border-0 bg-[#c7653d] px-3 py-[7px] text-[12px] font-bold text-white disabled:cursor-wait disabled:opacity-[0.65]"
type="button"
onClick={openReport}
>
</button>
<button
className="min-h-9 cursor-pointer rounded-lg border-0 bg-[var(--platform-button-primary-fill)] px-2.5 text-[var(--platform-button-primary-text)] last:w-9 last:bg-transparent last:px-0 last:text-xl last:text-[var(--platform-text-soft)]"
type="button"
className="inline-flex h-7 w-7 flex-[0_0_auto] cursor-pointer items-center justify-center rounded-md border-0 bg-transparent p-0 text-[#8d6a58] hover:bg-[rgb(199_101_61_/_12%)] hover:text-[#4a220f] disabled:cursor-wait disabled:opacity-50"
onClick={() => {
const dismissed = notificationEvents;
setNotificationEvents([]);
@@ -102,9 +111,9 @@ export function ErrorReportNotice() {
</aside>
) : null}
<ErrorReportDialog
open={reportEvents.length > 0}
events={reportEvents}
onClose={() => setReportEvents([])}
open={reportOpen}
fallbackEvents={reportFallbackEvents}
onClose={closeReport}
/>
</>
);
@@ -1951,6 +1951,7 @@ export function projectRuntimeVisibleError(
'session-budget-exceeded': '本次会话预算已耗尽,请缩小任务范围或新建任务',
'usage-limit-exceeded': '智能创作用量已达上限,请检查账户额度后重试',
unauthorized: '智能服务鉴权失败,请重新登录后重试',
'request-too-large': '模型请求体过大,请减少参考图或上下文后重试',
'bad-request': '智能创作请求无效,请稍后重试',
'cyber-policy': '智能创作安全策略拒绝了本次请求,请调整任务内容',
'sandbox-error': '智能创作隔离环境启动失败,请重试或检查本机环境',
@@ -1972,6 +1973,7 @@ export function projectRuntimeVisibleError(
'session-budget-exceeded': '本次会话预算已耗尽,请缩小任务范围或新建任务',
'usage-limit-exceeded': '用量已达上限,请检查账户额度后重试',
unauthorized: '鉴权失败,请重新登录后重试',
'request-too-large': '模型请求体过大,请减少参考图或上下文后重试',
'bad-request': '请求无效,请稍后重试',
'cyber-policy': '安全策略拒绝了本次请求,请调整任务内容',
'sandbox-error': '工作区隔离启动失败,请检查项目目录后重试',
@@ -14,3 +14,20 @@ export function collectUiNodeIds(root: UiNode): Set<NodeId> {
visitUiNodes(root, (node) => ids.add(node.id));
return ids;
}
export function findUiNode(root: UiNode, nodeId: NodeId): UiNode | null {
if (root.id === nodeId) return root;
for (const child of root.children) {
const found = findUiNode(child, nodeId);
if (found) return found;
}
return null;
}
export function countUiNodeDescendants(root: UiNode): number {
let count = 0;
for (const child of root.children) {
count += 1 + countUiNodeDescendants(child);
}
return count;
}
@@ -0,0 +1,56 @@
import { ThemedModal } from '../../../components/modal/ThemedModal';
import type { NodeId } from '../../../features/ui-editor/types/NodeId';
export type UiNodeDeleteRequest = {
nodeId: NodeId;
nodeLabel: string;
descendantCount: number;
};
type UiNodeDeleteConfirmModalProps = {
request: UiNodeDeleteRequest | null;
onCancel: () => void;
onConfirm: (nodeId: NodeId) => void;
disabled?: boolean;
};
export function UiNodeDeleteConfirmModal({
request,
onCancel,
onConfirm,
disabled = false,
}: UiNodeDeleteConfirmModalProps) {
return (
<ThemedModal
open={request !== null}
onClose={onCancel}
ariaLabel="确认删除节点"
panelClassName="w-[420px] rounded-2xl p-5"
>
<h2 className="m-0 text-base font-semibold"></h2>
<p className="text-sm leading-6 text-(--platform-text-soft)">
{request?.nodeLabel ?? ''} {request?.descendantCount ?? 0}{' '}
</p>
<div className="mt-5 flex justify-end gap-2">
<button
type="button"
className="rounded-lg border border-(--platform-subpanel-border) px-3 py-2 text-xs"
onClick={onCancel}
>
</button>
<button
type="button"
className="rounded-lg bg-red-600 px-3 py-2 text-xs font-semibold text-white"
disabled={disabled}
onClick={() => {
if (request) onConfirm(request.nodeId);
}}
>
</button>
</div>
</ThemedModal>
);
}
@@ -1,7 +1,10 @@
import { ChevronDown, Eye, EyeOff } from 'lucide-react';
import { ChevronDown, Eye, EyeOff, Puzzle, Trash2 } from 'lucide-react';
import {
forwardRef,
type HTMLAttributes,
type MouseEvent as ReactMouseEvent,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
@@ -9,16 +12,25 @@ import {
adjustMoveIndex,
type MoveHandler,
type NodeRendererProps,
type RowRendererProps,
Tree,
type TreeApi,
} from 'react-arborist';
import {
countUiNodeDescendants,
findUiNode,
} from '../../../features/ui-editor/treeUtils';
import type { Node as UiNode } from '../../../features/ui-editor/types/Node';
import type { NodeId } from '../../../features/ui-editor/types/NodeId';
import type { UIDesignImageId } from '../../../features/ui-editor/types/UIDesignImageId';
import type { UiNodeMoveRequest } from '../../../features/ui-editor/types/UiNodeMoveRequest';
import type { UiEditorNodeFocusRequest } from '../model';
import { UiNodeContextMenu } from './UiNodeContextMenu';
import {
UiNodeDeleteConfirmModal,
type UiNodeDeleteRequest,
} from './UiNodeDeleteConfirmModal';
type UiTreePanelProps = {
root: UiNode | null;
@@ -36,6 +48,38 @@ type UiTreePanelProps = {
className?: string;
};
const TreeListOuter = forwardRef<
HTMLDivElement,
HTMLAttributes<HTMLDivElement>
>(function TreeListOuter({ style, ...props }, ref) {
return (
<div
{...props}
ref={ref}
style={{ ...style, overflowX: 'visible', overflowY: 'auto' }}
/>
);
});
function TreeRowContainer<T>({
node,
innerRef,
attrs,
children,
}: RowRendererProps<T>) {
return (
<div
{...attrs}
ref={innerRef}
style={{ ...attrs.style, minWidth: 0 }}
onFocus={(event) => event.stopPropagation()}
onClick={node.handleClick}
>
{children}
</div>
);
}
function TreeRow({
node,
style,
@@ -44,11 +88,17 @@ function TreeRow({
isNodeVisible,
onToggleNodeVisibility,
onOpenContextMenu,
onRequestDelete,
canDelete,
deleteDisabled,
}: NodeRendererProps<UiNode> & {
onSelectNode: (id: NodeId) => void;
isNodeVisible: (nodeId: NodeId) => boolean;
onToggleNodeVisibility: (id: NodeId) => void;
onOpenContextMenu: (event: ReactMouseEvent, id: NodeId) => void;
onRequestDelete: (id: NodeId) => void;
canDelete: boolean;
deleteDisabled: boolean;
}) {
const data = node.data;
const nodeLabel = data.metadata.name || '未命名节点';
@@ -56,8 +106,8 @@ function TreeRow({
return (
<div
ref={dragHandle}
style={style}
className={`flex min-w-0 items-center gap-1 rounded-lg px-2 text-xs text-(--platform-text-strong) ${
style={{ ...style, width: '100%', height: '100%', minWidth: 0 }}
className={`relative flex min-w-0 items-center gap-1 rounded-lg px-2 pr-[68px] text-xs text-(--platform-text-strong) ${
node.isSelected ? 'bg-orange-100 text-orange-900' : 'hover:bg-black/5'
} ${isVisible ? '' : 'opacity-60'}`}
onClick={() => {
@@ -88,23 +138,53 @@ function TreeRow({
/>
) : null}
</button>
<span className="min-w-0 flex-1 truncate">{nodeLabel}</span>
<span className="text-[10px] text-(--platform-text-soft)">
{data.components.length}
<span className="shrink-0 whitespace-nowrap" title={nodeLabel}>
{nodeLabel}
</span>
<button
type="button"
aria-label={`${isVisible ? '隐藏' : '显示'}节点“${nodeLabel}`}
title={`${isVisible ? '隐藏' : '显示'}节点“${nodeLabel}`}
className="grid size-6 shrink-0 place-items-center rounded hover:bg-black/5"
onClick={(event) => {
event.stopPropagation();
onToggleNodeVisibility(data.id);
}}
onPointerDown={(event) => event.stopPropagation()}
<span
className="inline-flex shrink-0 items-center gap-0.5 rounded-md bg-black/5 px-1 py-0.5 text-[10px] leading-none text-(--platform-text-soft)"
title={`${data.components.length} 个组件`}
aria-label={`${data.components.length} 个组件`}
>
{isVisible ? <Eye size={13} /> : <EyeOff size={13} />}
</button>
<Puzzle
size={11}
className="text-orange-600"
fill="currentColor"
strokeWidth={1.5}
aria-hidden="true"
/>
<span>{data.components.length}</span>
</span>
<span className="absolute inset-y-0 right-2 flex w-[52px] items-center justify-end gap-1 bg-inherit">
<button
type="button"
aria-label={`${isVisible ? '隐藏' : '显示'}节点“${nodeLabel}`}
title={`${isVisible ? '隐藏' : '显示'}节点“${nodeLabel}`}
className="grid size-6 shrink-0 place-items-center rounded hover:bg-black/5"
onClick={(event) => {
event.stopPropagation();
onToggleNodeVisibility(data.id);
}}
onPointerDown={(event) => event.stopPropagation()}
>
{isVisible ? <Eye size={13} /> : <EyeOff size={13} />}
</button>
<button
type="button"
aria-label={`删除节点“${nodeLabel}`}
title={canDelete ? `删除节点“${nodeLabel}` : '页面根节点不可删除'}
disabled={!canDelete || deleteDisabled}
aria-disabled={!canDelete || deleteDisabled}
className="grid size-6 shrink-0 place-items-center rounded text-red-700 hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-35"
onClick={(event) => {
event.stopPropagation();
onRequestDelete(data.id);
}}
onPointerDown={(event) => event.stopPropagation()}
>
<Trash2 size={13} />
</button>
</span>
</div>
);
}
@@ -127,11 +207,15 @@ export function UiTreePanel({
const treeApiRef = useRef<TreeApi<UiNode> | undefined>(undefined);
const treeContainerRef = useRef<HTMLDivElement>(null);
const [treeHeight, setTreeHeight] = useState(0);
const [treeWidth, setTreeWidth] = useState(0);
const [contextMenu, setContextMenu] = useState<{
nodeId: NodeId;
x: number;
y: number;
} | null>(null);
const [pendingDelete, setPendingDelete] = useState<
(UiNodeDeleteRequest & { treeId: UIDesignImageId }) | null
>(null);
useEffect(() => {
if (!focusRequest) return;
@@ -150,6 +234,41 @@ export function UiTreePanel({
return () => resizeObserver.disconnect();
}, []);
const treeRows = useMemo(() => {
if (!root) return [];
const rows: Array<{ node: UiNode; depth: number }> = [];
const visit = (node: UiNode, depth: number) => {
rows.push({ node, depth });
node.children.forEach((child) => visit(child, depth + 1));
};
visit(root, 0);
return rows;
}, [root]);
const treeContentWidth = useMemo(
() =>
treeRows.reduce((width, { node, depth }) => {
const labelWidth = Math.max(
56,
(node.metadata.name || '未命名节点').length * 8,
);
return Math.max(width, 16 + depth * 16 + 24 + labelWidth + 42 + 52);
}, 0),
[treeRows],
);
useEffect(() => {
const container = treeContainerRef.current;
if (!container) return;
const updateWidth = () => {
setTreeWidth(Math.max(treeContentWidth, container.clientWidth));
};
updateWidth();
const resizeObserver = new ResizeObserver(updateWidth);
resizeObserver.observe(container);
return () => resizeObserver.disconnect();
}, [treeContentWidth]);
const handleMove: MoveHandler<UiNode> = ({
dragIds,
parentId,
@@ -184,21 +303,55 @@ export function UiTreePanel({
const pageRootIds = new Set(root?.children.map((child) => child.id) ?? []);
const contextTreeId = contextMenu ? treeIdForNode(contextMenu.nodeId) : null;
const requestDelete = (nodeId: NodeId) => {
if (!root || isLocked || nodeId === root.id || pageRootIds.has(nodeId)) {
return;
}
const treeId = treeIdForNode(nodeId);
const node = findUiNode(root, nodeId);
if (!treeId || !node) return;
const descendantCount = countUiNodeDescendants(node);
if (descendantCount === 0) {
onDeleteNode(treeId, nodeId);
return;
}
setPendingDelete({
treeId,
nodeId,
nodeLabel: node.metadata.name || '未命名节点',
descendantCount,
});
};
const confirmDelete = (nodeId: NodeId) => {
if (!pendingDelete || isLocked || pendingDelete.nodeId !== nodeId) {
return;
}
const { treeId } = pendingDelete;
setPendingDelete(null);
onDeleteNode(treeId, nodeId);
};
return (
<div
className={`relative flex h-full min-h-0 flex-col ${className ?? ''}`}
onContextMenu={(event) => event.preventDefault()}
>
<div ref={treeContainerRef} className="min-h-0 flex-1">
<div
ref={treeContainerRef}
className="relative min-h-0 flex-1 overflow-x-auto overflow-y-hidden"
>
{root && treeHeight > 0 ? (
<Tree<UiNode>
ref={treeApiRef}
data={[root]}
width="100%"
width={treeWidth > 0 ? treeWidth : '100%'}
outerElementType={TreeListOuter}
height={treeHeight}
rowHeight={34}
indent={16}
openByDefault
renderRow={TreeRowContainer}
selection={selectedNodeId ?? undefined}
onSelect={(nodes) => {
const selected = nodes[0]?.data;
@@ -224,6 +377,12 @@ export function UiTreePanel({
}}
isNodeVisible={isNodePreviewVisible}
onToggleNodeVisibility={onToggleNodeVisibility}
onRequestDelete={requestDelete}
canDelete={
props.node.data.id !== root.id &&
!pageRootIds.has(props.node.data.id)
}
deleteDisabled={isLocked}
onOpenContextMenu={(event, nodeId) =>
nodeId === root.id
? undefined
@@ -252,9 +411,15 @@ export function UiTreePanel({
onClose={() => setContextMenu(null)}
onInsertChild={(nodeId) => onInsertNode(contextTreeId, nodeId)}
onInsertSibling={(nodeId) => onInsertNodeAfter(contextTreeId, nodeId)}
onDelete={(nodeId) => onDeleteNode(contextTreeId, nodeId)}
onDelete={requestDelete}
/>
) : null}
<UiNodeDeleteConfirmModal
request={pendingDelete}
onCancel={() => setPendingDelete(null)}
onConfirm={confirmDelete}
disabled={isLocked}
/>
</div>
);
}
@@ -0,0 +1,40 @@
import { ThemedModal } from '../../../components/modal/ThemedModal';
import type { WorkflowCompletionNotice } from './workflowCompletionNotice';
import { workflowStepLabel } from './workflowCompletionNotice';
export function WorkflowCompletionModal({
notice,
onClose,
}: {
notice: WorkflowCompletionNotice | null;
onClose: () => void;
}) {
if (!notice) return null;
const stepLabel = workflowStepLabel(notice.step);
const outcomeLabel = notice.outcome === 'success' ? '完成' : '失败';
return (
<ThemedModal
open
onClose={onClose}
ariaLabel={`${stepLabel}${outcomeLabel}`}
panelClassName="w-[min(440px,calc(100vw-2rem))] rounded-2xl p-5"
>
<h2 className="m-0 text-base font-semibold">
{stepLabel}
{outcomeLabel}
</h2>
<p className="mt-3 text-sm leading-6 text-(--platform-text-soft)">
{notice.message}
</p>
<div className="mt-5 flex justify-end">
<button
type="button"
className="rounded-lg border border-(--platform-subpanel-border) px-3 py-2 text-xs"
onClick={onClose}
>
</button>
</div>
</ThemedModal>
);
}
@@ -1,4 +1,6 @@
import {
CANVAS_ZOOM_IN_FACTOR,
CANVAS_ZOOM_OUT_FACTOR,
type CanvasViewport,
createPanDragState,
type DragState,
@@ -28,8 +30,14 @@ import type { Node } from '../../../../features/ui-editor/types/Node';
import type { NodeId } from '../../../../features/ui-editor/types/NodeId';
import type { UiEditorCanvasProjection } from '../../useUiEditorPage';
import { UiNodeContextMenu } from '../UiNodeContextMenu';
import {
handlePreviewZoomKeyDown,
isPreviewZoomInteractiveTarget,
previewZoomUsesMetaModifier,
} from './previewZoomKeyboard';
import { type UiEditorRenderMode, UiTreeRenderer } from './UiTreeRenderer';
import { useNodeTransformInteraction } from './useNodeTransformInteraction';
import { ZoomPercentageInput } from './ZoomPercentageInput';
export function PreviewWorkspace({
canvas,
@@ -41,6 +49,8 @@ export function PreviewWorkspace({
const viewportRef = useRef<CanvasViewport>({ x: 0, y: 0, scale: 0.5 });
const handledFocusRequestIdRef = useRef<number | null>(null);
const panRef = useRef<Extract<DragState, { kind: 'pan' }> | null>(null);
const previewFocusedRef = useRef(false);
const previewHoveredRef = useRef(false);
const [viewport, setViewportState] = useState<CanvasViewport>(
viewportRef.current,
);
@@ -150,6 +160,34 @@ export function PreviewWorkspace({
);
}, [logicalSize, setViewport]);
const scaleViewportFromCenter = useCallback(
(nextScale: number) => {
const element = viewportElementRef.current;
const width = element?.clientWidth || canvasSize.width;
const height = element?.clientHeight || canvasSize.height;
setViewport(
scaleViewportFromScreenPoint({
viewport: viewportRef.current,
nextScale,
screenPoint: { x: width / 2, y: height / 2 },
}),
);
},
[canvasSize.height, canvasSize.width, setViewport],
);
const resetToActualSize = useCallback(() => {
scaleViewportFromCenter(1);
}, [scaleViewportFromCenter]);
const zoomIn = useCallback(() => {
scaleViewportFromCenter(viewportRef.current.scale * CANVAS_ZOOM_IN_FACTOR);
}, [scaleViewportFromCenter]);
const zoomOut = useCallback(() => {
scaleViewportFromCenter(viewportRef.current.scale * CANVAS_ZOOM_OUT_FACTOR);
}, [scaleViewportFromCenter]);
useEffect(() => {
const element = viewportElementRef.current;
if (!element) return;
@@ -224,6 +262,9 @@ export function PreviewWorkspace({
),
);
};
const usesMetaModifier = previewZoomUsesMetaModifier(
window.navigator.platform,
);
const onKeyDown = (event: KeyboardEvent) => {
if (
event.code === 'Space' &&
@@ -232,23 +273,21 @@ export function PreviewWorkspace({
) {
setSpaceHeld(true);
}
if (!event.ctrlKey && !event.metaKey) return;
if (event.key === '0') {
event.preventDefault();
fitToCanvas();
} else if (event.key === '1' && logicalSize) {
event.preventDefault();
const element = viewportElementRef.current;
const width = element?.clientWidth ?? 900;
const height = element?.clientHeight ?? 640;
setViewport(
scaleViewportFromScreenPoint({
viewport: viewportRef.current,
nextScale: 1,
screenPoint: { x: width / 2, y: height / 2 },
}),
);
}
handlePreviewZoomKeyDown(
event,
{
hasZoomableViewport: logicalSize !== null,
isFocused: previewFocusedRef.current,
isHovered: previewHoveredRef.current,
usesMetaModifier,
},
{
fit: fitToCanvas,
resetToActualSize,
zoomIn,
zoomOut,
},
);
};
const onKeyUp = (event: KeyboardEvent) => {
if (event.code === 'Space') setSpaceHeld(false);
@@ -262,9 +301,12 @@ export function PreviewWorkspace({
window.removeEventListener('keyup', onKeyUp);
window.removeEventListener('blur', onWindowBlur);
};
}, [fitToCanvas, logicalSize, setViewport]);
}, [fitToCanvas, logicalSize, resetToActualSize, zoomIn, zoomOut]);
const handlePointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
if (event.button === 0 && !isPreviewZoomInteractiveTarget(event.target)) {
event.currentTarget.focus({ preventScroll: true });
}
if (event.button === 1 || (event.button === 0 && spaceHeld)) {
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
@@ -371,7 +413,27 @@ export function PreviewWorkspace({
</div>
<SharedCanvasViewport
ref={viewportElementRef}
className="size-full"
className="size-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-orange-400"
role="region"
aria-label="UI 预览画布"
tabIndex={0}
onPointerEnter={() => {
previewHoveredRef.current = true;
}}
onPointerLeave={() => {
previewHoveredRef.current = false;
}}
onFocus={() => {
previewFocusedRef.current = true;
}}
onBlur={(event) => {
if (
!(event.relatedTarget instanceof Node) ||
!event.currentTarget.contains(event.relatedTarget)
) {
previewFocusedRef.current = false;
}
}}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
@@ -448,21 +510,20 @@ export function PreviewWorkspace({
<ZoomControls
viewport={viewport}
onFit={fitToCanvas}
onScaleFromCenter={(nextScale) => {
const element = viewportElementRef.current;
const width = element?.clientWidth ?? canvasSize.width;
const height = element?.clientHeight ?? canvasSize.height;
setViewport(
scaleViewportFromScreenPoint({
viewport: viewportRef.current,
nextScale,
screenPoint: { x: width / 2, y: height / 2 },
}),
);
}}
onScaleFromCenter={scaleViewportFromCenter}
>
{(actions) => (
<div className="absolute bottom-3 right-3 z-10 flex items-center gap-1 rounded-lg border border-(--platform-subpanel-border) bg-white/90 p-1 shadow-lg">
<div
className="absolute bottom-3 right-3 z-10 flex items-center gap-1 rounded-lg border border-(--platform-subpanel-border) bg-white/90 p-1 shadow-lg"
onMouseDown={(event) => {
if (
event.target instanceof Element &&
event.target.closest('button')
) {
event.preventDefault();
}
}}
>
<button
type="button"
className="grid size-8 place-items-center rounded hover:bg-black/5"
@@ -471,13 +532,33 @@ export function PreviewWorkspace({
>
<Minus size={14} />
</button>
<input
type="range"
min={25}
max={200}
step={5}
value={Math.round(
Number.parseInt(actions.displayPercent, 10),
)}
aria-label="画布缩放"
className="h-1 w-28 accent-orange-500"
onChange={(event) =>
actions.zoomToDisplayScale(Number(event.target.value) / 100)
}
/>
<ZoomPercentageInput
displayPercent={actions.displayPercent}
onCommit={(percent) =>
actions.zoomToDisplayScale(percent / 100)
}
/>
<button
type="button"
className="min-w-14 rounded px-2 py-1 text-[10px] tabular-nums hover:bg-black/5"
aria-label={`适配画布,当前 ${actions.displayPercent}`}
className="rounded px-2 py-1 text-[10px] hover:bg-black/5"
aria-label="适配画布"
onClick={actions.fit}
>
{actions.displayPercent}
</button>
<button
type="button"
@@ -90,7 +90,7 @@ function RenderNode({
selectedNodeId,
resources,
onSelectNode,
consumeNodeClick = () => false,
consumeNodeClick,
onNodeContextMenu,
onNodePointerDown,
onNodePointerMove,
@@ -107,6 +107,8 @@ function RenderNode({
isRoot?: boolean;
parentContainer?: UiNode['layout']['container'];
}) {
const hasClickGestureConsumer = consumeNodeClick !== undefined;
const consumeClick = consumeNodeClick ?? (() => false);
const activePreviewTransforms = previewTransforms ?? EMPTY_PREVIEW_TRANSFORMS;
if (hiddenNodeIds.has(node.id)) return null;
const previewTransform = activePreviewTransforms.get(node.id);
@@ -158,11 +160,12 @@ function RenderNode({
hasDirectPointerGesture
? (event) => {
event.stopPropagation();
consumeNodeClick();
if (consumeClick()) return;
if (hasClickGestureConsumer) onSelectNode(node.id);
}
: (event) => {
event.stopPropagation();
if (consumeNodeClick()) return;
if (consumeClick()) return;
onSelectNode(node.id);
}
}
@@ -0,0 +1,79 @@
import { useEffect, useRef, useState } from 'react';
const MIN_ZOOM_PERCENT = 25;
const MAX_ZOOM_PERCENT = 200;
function displayPercentToValue(displayPercent: string) {
return displayPercent.replace('%', '');
}
export function ZoomPercentageInput({
displayPercent,
onCommit,
}: {
displayPercent: string;
onCommit: (percent: number) => void;
}) {
const [draft, setDraft] = useState(() =>
displayPercentToValue(displayPercent),
);
const draftRef = useRef(draft);
const isEditingRef = useRef(false);
useEffect(() => {
if (!isEditingRef.current) {
setDraft(displayPercentToValue(displayPercent));
draftRef.current = displayPercentToValue(displayPercent);
}
}, [displayPercent]);
const commit = () => {
const currentPercent = Number.parseFloat(
displayPercentToValue(displayPercent),
);
const parsed = Number.parseFloat(draftRef.current);
const nextPercent = Number.isFinite(parsed)
? Math.min(MAX_ZOOM_PERCENT, Math.max(MIN_ZOOM_PERCENT, parsed))
: currentPercent;
setDraft(String(nextPercent));
draftRef.current = String(nextPercent);
isEditingRef.current = false;
if (nextPercent !== currentPercent) {
onCommit(nextPercent);
}
};
return (
<label className="flex min-w-14 items-center justify-center rounded px-1 py-1 hover:bg-black/5">
<input
type="number"
min={MIN_ZOOM_PERCENT}
max={MAX_ZOOM_PERCENT}
step={5}
inputMode="numeric"
value={draft}
aria-label={`画布缩放,当前 ${displayPercent}`}
className="w-10 bg-transparent text-center text-[10px] tabular-nums outline-none"
onFocus={() => {
isEditingRef.current = true;
const nextDraft = displayPercentToValue(displayPercent);
setDraft(nextDraft);
draftRef.current = nextDraft;
}}
onChange={(event) => {
draftRef.current = event.target.value;
setDraft(event.target.value);
}}
onBlur={commit}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.currentTarget.blur();
}
}}
/>
<span aria-hidden="true" className="text-[10px] tabular-nums">
%
</span>
</label>
);
}
@@ -0,0 +1,100 @@
export type PreviewZoomShortcut =
| 'fit'
| 'actual-size'
| 'zoom-in'
| 'zoom-out';
export type PreviewZoomKeyboardContext = {
hasZoomableViewport: boolean;
isFocused: boolean;
isHovered: boolean;
usesMetaModifier: boolean;
};
export type PreviewZoomKeyboardActions = {
fit: () => void;
resetToActualSize: () => void;
zoomIn: () => void;
zoomOut: () => void;
};
const INTERACTIVE_TARGET_SELECTOR = [
'button',
'a[href]',
'input',
'textarea',
'select',
'summary',
'[contenteditable]:not([contenteditable="false"])',
'[role="button"]',
'[role="link"]',
'[role="textbox"]',
'[role="combobox"]',
'[role="slider"]',
'[role="spinbutton"]',
'[role="checkbox"]',
'[role="radio"]',
'[role="switch"]',
'[role="tab"]',
'[role="menuitem"]',
'[role="option"]',
'[role="dialog"]',
'[aria-modal="true"]',
].join(', ');
export function previewZoomUsesMetaModifier(platform: string) {
return /Mac|iPhone|iPad|iPod/i.test(platform);
}
export function isPreviewZoomInteractiveTarget(target: EventTarget | null) {
const element = target instanceof Element ? target : null;
return Boolean(element?.closest(INTERACTIVE_TARGET_SELECTOR));
}
function hasPlatformModifier(event: KeyboardEvent, usesMetaModifier: boolean) {
return usesMetaModifier
? event.metaKey && !event.ctrlKey
: event.ctrlKey && !event.metaKey;
}
export function resolvePreviewZoomShortcut(
event: KeyboardEvent,
): PreviewZoomShortcut | null {
if (event.altKey) return null;
if (event.key === '0') return 'fit';
if (event.key === '1') return 'actual-size';
if (event.code === 'NumpadAdd' || event.key === '+' || event.key === '=') {
return 'zoom-in';
}
if (event.code === 'NumpadSubtract' || event.key === '-') {
return 'zoom-out';
}
return null;
}
export function handlePreviewZoomKeyDown(
event: KeyboardEvent,
context: PreviewZoomKeyboardContext,
actions: PreviewZoomKeyboardActions,
) {
if (
event.defaultPrevented ||
!context.hasZoomableViewport ||
(!context.isHovered && !context.isFocused) ||
!hasPlatformModifier(event, context.usesMetaModifier) ||
isPreviewZoomInteractiveTarget(event.target)
) {
return false;
}
const shortcut = resolvePreviewZoomShortcut(event);
if (!shortcut) return false;
event.preventDefault();
event.stopPropagation();
if (shortcut === 'fit') actions.fit();
else if (shortcut === 'actual-size') actions.resetToActualSize();
else if (shortcut === 'zoom-in') actions.zoomIn();
else actions.zoomOut();
return true;
}
@@ -0,0 +1,26 @@
import type { UiEditorStepId } from '../model';
export type WorkflowCompletionNotice = {
step: UiEditorStepId;
outcome: 'success' | 'failure';
message: string;
};
export function appendWorkflowCheckPrompt(message: string): string {
const trimmed = message.trim();
if (!trimmed) return '请检查。';
return `${trimmed.replace(/[!?]+$/u, '')}`;
}
export function workflowStepLabel(step: UiEditorStepId): string {
switch (step) {
case 'reference-analysis':
return '分析参考图';
case 'structure-recognition':
return '识别界面结构';
case 'visual-binding':
return '绑定视觉素材';
}
const exhaustiveCheck: never = step;
return exhaustiveCheck;
}
@@ -16,7 +16,9 @@ import { PreviewWorkspace } from './components/preview/PreviewWorkspace';
import { RecognitionOverview } from './components/RecognitionOverview';
import { ToolNavigation } from './components/ToolNavigation';
import { WorkflowActionCard } from './components/WorkflowActionCard';
import { WorkflowCompletionModal } from './components/WorkflowCompletionModal';
import { UI_EDITOR_STEPS, type UiEditorStepId } from './model';
import { handleUiEditorKeyDown } from './uiEditorKeyboardShortcuts';
import {
type UiEditorWorkflowProjection,
useUiEditorSession,
@@ -79,41 +81,26 @@ export default function UiEditorPage({
session.save.isLocked;
const historyUndo = session.history.undo;
const historyRedo = session.history.redo;
const selectedNodeId = session.input.selectedNodeId;
const activeImageId = session.input.activeImageId;
const deleteNode = session.input.deleteNode;
useEffect(() => {
if (session.save.isDirty) setGenerateSuccess(null);
}, [session.save.isDirty]);
useEffect(() => {
const isEditableTarget = (target: EventTarget | null) => {
const element = target instanceof HTMLElement ? target : null;
return Boolean(
element?.isContentEditable ||
element?.closest('input, textarea, select, [contenteditable="true"]'),
);
};
const onKeyDown = (event: KeyboardEvent) => {
if (
event.repeat ||
event.defaultPrevented ||
isEditableTarget(event.target) ||
(!event.ctrlKey && !event.metaKey)
) {
return;
}
const isUndo = event.key.toLowerCase() === 'z' && !event.shiftKey;
const isRedo =
(event.key.toLowerCase() === 'z' && event.shiftKey) ||
(event.ctrlKey && event.key.toLowerCase() === 'y');
if (isUndo && historyUndo()) {
event.preventDefault();
} else if (isRedo && historyRedo()) {
event.preventDefault();
}
};
const onKeyDown = (event: KeyboardEvent) =>
handleUiEditorKeyDown(event, {
selectedNodeId,
activeImageId,
deleteNode,
historyUndo,
historyRedo,
});
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [historyRedo, historyUndo]);
}, [activeImageId, deleteNode, historyRedo, historyUndo, selectedNodeId]);
async function save(afterReturn = false) {
if (await session.save.save()) {
@@ -298,6 +285,10 @@ export default function UiEditorPage({
</div>
</div>
<EditorDialogs dialogs={session.dialogs} />
<WorkflowCompletionModal
notice={session.workflow.completionNotice}
onClose={session.workflow.dismissCompletionNotice}
/>
<ThemedModal
open={returnConfirmOpen}
onClose={() => setReturnConfirmOpen(false)}
@@ -0,0 +1,82 @@
import type { NodeId } from '../../features/ui-editor/types/NodeId';
import type { UIDesignImageId } from '../../features/ui-editor/types/UIDesignImageId';
type DeleteResult = { ok: boolean } | undefined;
export type UiEditorKeyboardActions = {
selectedNodeId: NodeId | null;
activeImageId: UIDesignImageId | null;
deleteNode: (nodeId: NodeId, treeId: UIDesignImageId) => DeleteResult;
historyUndo: () => boolean;
historyRedo: () => boolean;
};
function isEditableTarget(target: EventTarget | null) {
const element = target instanceof HTMLElement ? target : null;
return Boolean(
element?.isContentEditable ||
element?.closest('input, textarea, select, [contenteditable="true"]'),
);
}
function isInteractiveTarget(target: EventTarget | null) {
const element = target instanceof Element ? target : null;
return Boolean(
(target instanceof HTMLElement && target.isContentEditable) ||
element?.closest(
'button, a, input, textarea, select, [contenteditable="true"], [role="button"], [role="dialog"], [aria-modal="true"]',
),
);
}
function isModalTarget(target: EventTarget | null) {
const element = target instanceof Element ? target : null;
return Boolean(element?.closest('[role="dialog"], [aria-modal="true"]'));
}
export function handleUiEditorKeyDown(
event: KeyboardEvent,
actions: UiEditorKeyboardActions,
) {
if (isModalTarget(event.target)) return;
if (
(event.key === 'Delete' || event.key === 'Backspace') &&
!event.repeat &&
!event.defaultPrevented &&
!event.ctrlKey &&
!event.metaKey &&
!event.altKey &&
!event.shiftKey &&
!isInteractiveTarget(event.target)
) {
if (actions.selectedNodeId && actions.activeImageId) {
const result = actions.deleteNode(
actions.selectedNodeId,
actions.activeImageId,
);
if (result?.ok) {
event.preventDefault();
event.stopPropagation();
return;
}
}
}
if (
event.repeat ||
event.defaultPrevented ||
isEditableTarget(event.target) ||
(!event.ctrlKey && !event.metaKey)
) {
return;
}
const isUndo = event.key.toLowerCase() === 'z' && !event.shiftKey;
const isRedo =
(event.key.toLowerCase() === 'z' && event.shiftKey) ||
(event.ctrlKey && event.key.toLowerCase() === 'y');
if (isUndo && actions.historyUndo()) {
event.preventDefault();
} else if (isRedo && actions.historyRedo()) {
event.preventDefault();
}
}
@@ -10,7 +10,10 @@ import {
} from '../../features/ui-editor/importAdapter';
import { applyMergeResult } from '../../features/ui-editor/merge';
import { applyRecognitionResult } from '../../features/ui-editor/recognition';
import type { StageStatusField } from '../../features/ui-editor/stageStatusOverview';
import {
getStageStatusOverview,
type StageStatusField,
} from '../../features/ui-editor/stageStatusOverview';
import { collectUiNodeIds } from '../../features/ui-editor/treeUtils';
import type { BindingDTO } from '../../features/ui-editor/types/BindingDTO';
import type { ChildrenDisplayMode } from '../../features/ui-editor/types/ChildrenDisplayMode';
@@ -54,6 +57,10 @@ import {
prerequisiteIssuesForStep,
type UiEditorPrerequisiteIssue,
} from './components/WorkflowChecks';
import {
appendWorkflowCheckPrompt,
type WorkflowCompletionNotice,
} from './components/workflowCompletionNotice';
import {
type PendingResourceRemoval,
removalHasDownstreamReferences,
@@ -175,6 +182,8 @@ export function useUiEditorSession(
initialFurthestStepIndex = 0,
) {
const editor = useUiEditorState(EMPTY_UI_EDITOR_STATE);
const editorDeleteNode = editor.deleteNode;
const editorUiTrees = editor.state.ui_trees;
const replaceEditorState = editor.replaceState;
const [isLoading, setIsLoading] = useState(Boolean(resourceId));
const [loadError, setLoadError] = useState<string | null>(null);
@@ -246,6 +255,19 @@ export function useUiEditorSession(
const [hasSuggested, setHasSuggested] = useState(false);
const [hasRecognized, setHasRecognized] = useState(false);
const [hasBound, setHasBound] = useState(false);
const [completionNotice, setCompletionNotice] =
useState<WorkflowCompletionNotice | null>(null);
function reportWorkflowCompletion(
step: UiEditorStepId,
outcome: WorkflowCompletionNotice['outcome'],
rawMessage: string,
setStatus: (message: string) => void,
) {
const message = appendWorkflowCheckPrompt(rawMessage);
setStatus(message);
setCompletionNotice({ step, outcome, message });
}
useEffect(() => {
setActiveStep(initialStep);
@@ -437,6 +459,9 @@ export function useUiEditorSession(
);
return next.size === current.size ? current : next;
});
setSelectedNodeId((current) =>
current && !validNodeIds.has(current) ? null : current,
);
}, [editor.state.ui_trees]);
const isNodePreviewVisible = useCallback(
@@ -879,16 +904,26 @@ export function useUiEditorSession(
return result;
}
function deleteNode(nodeId: NodeId, treeId = activeImageId) {
if (!treeId) return;
const result = editor.deleteNode(treeId, nodeId);
if (!result.ok) {
setStatus('无法删除该节点。');
const deleteNode = useCallback(
(nodeId: NodeId, treeId = activeImageId) => {
if (!treeId) return;
const tree = editorUiTrees.find(
(candidate) => candidate.src_ui_design === treeId,
);
const location = tree ? findUiNodeLocation(tree.root, nodeId) : null;
const deletedNodeIds = location ? collectUiNodeIds(location.node) : null;
const result = editorDeleteNode(treeId, nodeId);
if (!result.ok) {
setStatus('无法删除该节点。');
return result;
}
if (selectedNodeId !== null && deletedNodeIds?.has(selectedNodeId)) {
setSelectedNodeId(null);
}
return result;
}
if (selectedNodeId === nodeId) setSelectedNodeId(null);
return result;
}
},
[activeImageId, editorDeleteNode, editorUiTrees, selectedNodeId],
);
function selectSprite(id: SpriteAssetId) {
setSelectedNodeId(null);
@@ -939,6 +974,7 @@ export function useUiEditorSession(
async function suggestUiDesignSemantics() {
if (isSuggesting || isWorkflowBusy) return;
setCompletionNotice(null);
setSuggestionStatus(null);
setIsSuggesting(true);
try {
@@ -949,11 +985,19 @@ export function useUiEditorSession(
);
editor.replaceState(applyUiDesignSuggestions(snapshot, suggestions));
setHasSuggested(true);
setSuggestionStatus(`已应用 ${suggestions.length} 条参考图语义建议。`);
reportWorkflowCompletion(
'reference-analysis',
'success',
`参考图分析完成:已应用 ${suggestions.length} 条参考图语义建议`,
setSuggestionStatus,
);
});
} catch (cause) {
setSuggestionStatus(
reportWorkflowCompletion(
'reference-analysis',
'failure',
cause instanceof Error ? cause.message : String(cause),
setSuggestionStatus,
);
} finally {
setIsSuggesting(false);
@@ -962,6 +1006,7 @@ export function useUiEditorSession(
async function recognizeUi() {
if (isRecognizing || isWorkflowBusy) return;
setCompletionNotice(null);
setRecognitionStatus(null);
setIsRecognizing(true);
try {
@@ -970,14 +1015,27 @@ export function useUiEditorSession(
projectPath,
state: snapshot,
});
editor.replaceState(applyRecognitionResult(snapshot, result));
const nextState = applyRecognitionResult(snapshot, result);
editor.replaceState(nextState);
setHasRecognized(true);
setSelectedNodeId(null);
setRecognitionStatus(`已替换 ${result.ui_trees.length} 棵界面树。`);
const overview = getStageStatusOverview(
nextState.ui_trees,
'layout_status',
);
reportWorkflowCompletion(
'structure-recognition',
'success',
`界面结构识别完成:已替换 ${result.ui_trees.length} 棵界面树,待检查 ${overview.needsAttention} 项(必须修复 ${overview.blocked} 项)`,
setRecognitionStatus,
);
});
} catch (cause) {
setRecognitionStatus(
reportWorkflowCompletion(
'structure-recognition',
'failure',
cause instanceof Error ? cause.message : String(cause),
setRecognitionStatus,
);
} finally {
setIsRecognizing(false);
@@ -1005,6 +1063,7 @@ export function useUiEditorSession(
async function bindComponents() {
if (isBinding || isWorkflowBusy) return;
setCompletionNotice(null);
setBindingStatus(null);
setIsBinding(true);
try {
@@ -1032,13 +1091,21 @@ export function useUiEditorSession(
history: index < batches.length - 1 ? 'skip' : 'record',
});
}
setBindingStatus(
`组件绑定完成(${batches.length}/${batches.length})。`,
reportWorkflowCompletion(
'visual-binding',
'success',
`视觉素材绑定完成:已处理 ${batches.length}/${batches.length} 个批次`,
setBindingStatus,
);
setHasBound(true);
});
} catch (cause) {
setBindingStatus(cause instanceof Error ? cause.message : String(cause));
reportWorkflowCompletion(
'visual-binding',
'failure',
cause instanceof Error ? cause.message : String(cause),
setBindingStatus,
);
} finally {
setIsBinding(false);
}
@@ -1303,6 +1370,7 @@ export function useUiEditorSession(
isBinding,
hasBound,
bindingStatus,
completionNotice,
bindComponents,
requestStepChange,
continueToNextStep: () => {
@@ -1310,6 +1378,7 @@ export function useUiEditorSession(
},
confirmStepChange,
cancelStepChange: () => setPendingWorkflowStepChange(null),
dismissCompletionNotice: () => setCompletionNotice(null),
},
dialogs: {
projectPath,

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