Compare commits

..

42 Commits

Author SHA1 Message Date
lhk229 a439b2cb34 修复 Linux CI 缺少策略路径归一化实现
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 5m11s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 5m15s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 5m21s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 5m7s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m47s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m25s
Project CI / Repository checks (pull_request) Successful in 3m34s
Project CI / Frontend tests (pull_request) Successful in 4m25s
Project CI / Native shell tests (pull_request) Successful in 6m6s
Project CI / Backend tests (pull_request) Successful in 7m5s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m45s
Project CI / AI game creator shell Rust smoke (push) Has been cancelled
Project CI / AI game creator shell Rust crates (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / AI game creator shell Rust shard 4/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 1/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 2/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (push) Has been cancelled
为非 Windows 平台补充 normalize_windows_policy_path 恒等实现,修复 Linux Rust 编译失败。
2026-09-14 22:35:56 +08:00
kdletters 1877aea33c 修复 Windows verbatim AppData 路径权限识别
Project CI / AI game creator shell Rust shard 1/4 (push) Failing after 2m2s
Project CI / AI game creator shell Rust shard 2/4 (push) Failing after 2m15s
Project CI / AI game creator shell Rust shard 3/4 (push) Failing after 2m13s
Project CI / AI game creator shell Rust shard 4/4 (push) Failing after 2m13s
Project CI / AI game creator shell Rust smoke (push) Failing after 1m44s
Project CI / AI game creator shell Rust crates (push) Successful in 2m58s
Project CI / Native shell tests (push) Failing after 4m10s
Project CI / Frontend tests (push) Successful in 4m10s
Project CI / Repository checks (push) Successful in 3m4s
Project CI / AI game creator shell web tests (push) Successful in 2m32s
Project CI / Backend tests (push) Successful in 6m55s
归一化 \\?\ 与 \\?\UNC\ 路径后再判断受管 ACL scope

补充 verbatim AppData 回归测试
2026-09-14 21:04:01 +08:00
kdletters 2bdee990a2 AGC 开发态补齐后台 Web 启动与端口汇总,端口归属探测从 WMI 换成 netstat
- dev-port.mjs 新增 resolveAgcAdminWebEndpoint:后台 Web 端口按 Linux 用户端口段 start + 3、非 Linux 优先 3102 解析,ADMIN_WEB_PORT 可显式指定,并把已解析的 AGC Vite 端口列为保留端口
- start-dev-stack.mjs 随 AGC 一起拉起 apps/admin-web(AGC_DEV_ADMIN_WEB=0 关闭):与 AGC Vite 一样由启动器直接持有并纳入信号与退出收束,不走会整体重写 .app/dev-stack.json 的 dev:admin-web
- start-dev-stack.mjs 新增启动汇总行,一次给出前端、后端、后台、数据库与 bgfilter-worker 的实际地址,端口漂移后以该行为准
- start-dev-stack.mjs 端口归属探测改用 netstat -ano 取端口到 PID、.NET Process 读可执行文件路径,仅在核对 SpacetimeDB --data-dir 归属时按 PID 取命令行并做 5 分钟 TTL 缓存:本机实测单轮探测由约 43 秒降到 0.37 秒,agc:serve 的 starting backend stack 到 backend ready 由约 80 秒降到 16.7 秒
- 后台 Web 端口解析或启动失败只告警,不阻断也不连带停止 AGC 客户端与配套后端
- tests/dev-port.test.ts、tests/start-dev-stack.test.ts 补充用例覆盖后台 Web 端口解析与严格占用、失败软化、启动汇总、netstat 探测脚本与命令行缓存失效
- docs/【开发运维】与 docs/technical 同步 AGC 开发态启动口径,pitfalls.md 记录 WMI 慢速探测的实测数据、netstat 改法与 PID 取最后一列的易错点
2026-09-14 21:02:08 +08:00
suzmii 330e4d6c5b Merge pull request 'AGC 画布:生成期间可退出与任务侧栏(高优)、画布手动整理、透明底判据、素材类型独立入口' (#358) from fix/agc-canvas-acceptance-batch into master
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 4m41s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 4m38s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m34s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 4m25s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 4m34s
Project CI / AI game creator shell Rust crates (push) Successful in 2m30s
Project CI / Frontend tests (push) Successful in 4m9s
Project CI / Repository checks (push) Successful in 3m15s
Project CI / Backend tests (push) Successful in 6m49s
Project CI / Native shell tests (push) Successful in 6m28s
Project CI / AI game creator shell web tests (push) Successful in 3m15s
2026-09-14 20:31:21 +08:00
suzmii aeac6c7b74 合并 origin/master(279a5e8e0,#356 UI 编辑器返回后资源画布平移)到验收批次
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 4m55s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m32s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 4m43s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 4m38s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 4m15s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m3s
Project CI / Frontend tests (pull_request) Successful in 3m56s
Project CI / Repository checks (pull_request) Successful in 3m28s
Project CI / Native shell tests (pull_request) Successful in 6m19s
Project CI / Backend tests (pull_request) Successful in 7m32s
Project CI / AI game creator shell web tests (pull_request) Successful in 3m0s
- 合并最新 master,使 PR #358 的头包含当前 master 顶端(CI 门禁要求)
- 冲突按「两侧语义都保住」解:
  - docs/project-memory/shared-memory/pitfalls.md:保留我方新增的「严格 IPC 桩缺登记新命令」
    与上游新增的「UI 编辑器返回后资源画布滚轮平移失效」两条,只删除冲突标记行
  - index.tsx / tests/appSurface/harness.ts / project-development.suite.ts:保留本批生成任务与
    资源画布改动,同时并入上游的资源画布平移回归用例与共享 ResizeObserver 桩
- 工作树里另有 5 个版本号改动(package.json / Cargo.toml / Cargo.lock / tauri.conf.json /
  package-lock.json,0.1.29 → 0.1.34)属发布动作,**本次刻意未提交**,原样留在工作树
2026-09-14 20:27:05 +08:00
suzmii 2292ae1fc1 Merge branch 'master' into fix/agc-canvas-acceptance-batch
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 4m48s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m35s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 4m29s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 4m25s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 4m33s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 3m8s
Project CI / Frontend tests (pull_request) Successful in 4m30s
Project CI / Repository checks (pull_request) Successful in 3m2s
Project CI / Backend tests (pull_request) Successful in 6m51s
Project CI / Native shell tests (pull_request) Successful in 6m28s
Project CI / AI game creator shell web tests (pull_request) Successful in 3m2s
2026-09-14 19:59:44 +08:00
suzmii b077cb5277 更正 decision-log:「整理画布」最终位置固定在「生成素材」之后、「管理未完成编辑」之前(不在行尾)
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
2026-09-14 19:30:02 +08:00
suzmii 8ad0487b8d 合并 fix/agc-resource-layout-manual:「整理画布」移入资源动作簇(离开行尾)
- 冲突解在 index.tsx 工具条动作区:两侧都要在同一位置插入按钮(WS1 的「生成任务」入口 +
  WS2 把「整理画布」从行尾移到「生成素材」之后),按「两侧语义都保住」解——
  「整理画布」在前(紧邻「生成素材」,满足其 previousElementSibling 断言与「不是行尾」断言),
  「生成任务」紧随其后,共用同一个闭合标签;行尾那份旧「整理画布」由 WS2 的删除 hunk 干净应用
- 已核对:ria-label="整理画布" 与 ria-label="生成任务" 在文件中各恰好一处
- 合并后实测:gc:typecheck 通过;resourceCanvasManualLayout 9 + appSurface 439 = 448 passed
2026-09-14 19:29:51 +08:00
suzmii 417b5ec630 「整理画布」移入资源动作簇:排在「生成素材」之后,不再挂在这一行行尾
- index.tsx:把「整理画布」从 game-workbench-view-actions 行尾(排序 tab 之后)
  移到「生成素材」之后、「管理未完成编辑」之前,与其它资源动作同类相邻,
  标签仍为 aria-label=\"整理画布\",零 CSS 改动
- index.tsx:语义与行为一字未改——disabled={!resourceLayoutReady}、
  aria-busy={resourceLayoutSaving}、onClick 仍调 activeResourceLayout.rederiveNow(),
  反馈仍走既有 resourceLayoutNotice / resourceLayoutSaving,changed 门保留
- resourceCanvasManualLayout.test.tsx:位置断言改为钉新位置——该行最后一个按钮不是它、
  它在「生成素材」之后(previousElementSibling)、在排序组左侧;
  继续保留「资源排列方式」组内只有「按依赖 / 按类型」与本按钮不在该组 /
  不在 .game-resource-sort-tabs 内的断言
2026-09-14 19:25:21 +08:00
suzmii 5adc16fbda Merge branch 'feat/agc-generation-task-progress' into fix/agc-canvas-acceptance-batch 2026-09-14 19:16:16 +08:00
suzmii 6c98c19c12 更正 decision-log:「整理画布」是资源工具条动作区里的独立动作按钮,不在「资源排列方式」分组内 2026-09-14 19:11:33 +08:00
suzmii 41c59a37a0 style(agc): 「生成任务」侧栏做视觉与信息层级收口(行为与语义一个不动)
- 新增 `resourceCanvasAssetGenerationTasksSidebar.css`:侧栏样式独立成文件,取值全部走平台设计 token
  (`--platform-subpanel-fill/-border`、`--platform-panel-shadow`、`--platform-text-muted`、`--platform-line-soft`、
  `--platform-neutral-*`、`--platform-accent`、`--platform-success-*`、`--platform-button-danger-*`、
  `--platform-input-focus-ring`),不硬编码任何颜色,也不新增第二套色板
- 条目卡片化:素材名为主信息(加粗、单行省略)、阶段文案为次要信息(muted、小一号)、
  已耗时 `font-variant-numeric: tabular-nums` + 右对齐(秒数跳动不抖)、失败原因 `overflow-wrap: anywhere`
  折行不截断、「定位到素材」轻量下划线按钮 + hover 换色
- 状态徽标成体系:`data-tone` 四档——排队中 `--platform-neutral-*`(中性)、生成中 `--platform-accent`
  (品牌色 + 呼吸感圆点,**不做百分比**)、已完成 `--platform-success-*`、失败 `--platform-button-danger-*`;
  tone 映射提到模型层 `resourceCanvasAssetGenerationTaskTone`(组件只导出组件与常量,保住 react-refresh 边界)
- 分栏标题小字 + 条数胶囊,两栏之间用 `--platform-line-soft` 分隔;空态只给一句简短文案
- 侧栏容器圆角 + 边框 + `--platform-panel-shadow` + `backdrop-filter: blur(10px)`;展开/折叠各有进场动画
  (位移 + 淡入,不做宽度 reflow 抖动);折叠把手是竖向标签 + 在途数量胶囊;列表滚动条细样式
- 无障碍与降级:`:focus-visible` 用 `--platform-accent` 焦点环 + `--platform-input-focus-ring`;
  `@media (prefers-reduced-motion: reduce)` 关掉全部 animation / transition 与 hover 位移;
  `@media (max-width: 480px)` 下侧栏撑满可用宽度、把手收窄,不挡画布操作
- 测试:新增 `resourceCanvasAssetGenerationTasksSidebarStyle.test.ts`(用仓库既有 `tests/styleCascade.ts`
  按真实层叠断言:四档 tone 互不相同且只引平台 token、tabular-nums + 右对齐、卡片圆角与 hover、
  过渡与把手、细滚动条、360px 降级、reduced-motion 关动效、焦点环);
  侧栏行为用例补「四种状态各自输出对应 data-tone」,并把原先断言 Tailwind 类名的部分交还给样式用例
- 未改任何行为:分栏、条数、已完成封顶 20、折叠不清列表、提交后自动展开、入口在途计数、
  定位终局化、非模态(无遮罩 / 无焦点陷阱 / 不进模态遮挡判据)全部保持原样
2026-09-14 19:11:21 +08:00
suzmii a6de1b5570 Merge remote-tracking branch 'origin/master' into fix/agc-canvas-acceptance-batch 2026-09-14 19:07:46 +08:00
suzmii 444bf9bc24 Merge branch 'fix/agc-resource-layout-manual' into fix/agc-canvas-acceptance-batch 2026-09-14 19:07:45 +08:00
suzmii 21c85c4dd1 「整理画布」移出「资源排列方式」分组,成为独立动作按钮
- index.tsx:把「整理画布」从 aria-label=\"资源排列方式\" 的 role=group 里搬出来,
  紧跟排序 tab 之后、作为 game-workbench-view-actions 动作区里的独立按钮,
  外观直接复用该容器既有动作按钮样式(有边圆角 + secondary 填充),不新增 CSS
- index.tsx:语义与行为一字未改——aria-label=\"整理画布\"、disabled={!resourceLayoutReady}、
  aria-busy={resourceLayoutSaving}、onClick 仍调 activeResourceLayout.rederiveNow(),
  成功/失败反馈仍复用既有 resourceLayoutNotice / resourceLayoutSaving,changed 门保留
- resourceCanvasManualLayout.test.tsx:新增结构断言——「资源排列方式」分组里只有
  「按依赖 / 按类型」两枚按钮、「整理画布」不在该容器内也不在 .game-resource-sort-tabs 里,
  同时仍是动作区内可用按钮(布局就绪后不再 disabled)
2026-09-14 19:07:22 +08:00
suzmii a45172e055 同步文档:生成进度面收敛为常驻可折叠侧栏、提交即关面板、定位终局化、类型选项纵向列表
- PRD §3.10:进度面由「非模态浮层」改为「常驻画布的可折叠任务侧栏」(折叠把手带在途计数、两个分栏、已完成封顶 20、左侧覆盖式、提交后自动展开);定位动作终局化(跨栏目先切栏目 / 不在投影给结论 / 3 秒有界兜底)
- PRD §3.10 面板形态:改为「点「生成」即同步关闭面板、面板内不出现阶段文案;只有点击瞬间失败才带草稿重开;受理后失败只在侧栏收口」
- 栏目画布入口矩阵 §4/§4a:同步上述两条,并把 4a 标题由「「生成任务」面板」改为「「生成任务」侧栏」
- decision-log:新增 2026-09-14 条目(进度面收敛为常驻可折叠侧栏 + 提交即关面板 + 定位终局化,含位置取舍与覆盖式理由、五条变异证据);并在「素材类型」条目补记选项形态修订(纵向单选列表、radio 语义、方向键只移焦点、行列与滚动口径、建议后续抽 PlatformRadioList)
2026-09-14 18:50:48 +08:00
suzmii e6d92b2d98 Merge branch 'feat/agc-generation-task-progress' into fix/agc-canvas-acceptance-batch 2026-09-14 18:50:00 +08:00
suzmii ec02c39a5d feat(agc): 「生成任务」改造成常驻画布的可折叠侧栏(分栏 / 计数 / 自动展开 / 折叠不影响任务)
- ResourceCanvasAssetGenerationTasksPanelView 由非模态浮层改造成任务侧栏:展开时是贴边
  `<aside>`(`role="region"`、`aria-label="生成任务"`、不铺遮罩、不做焦点陷阱、不进模态遮挡判据),
  折叠时只留一个可点的贴边把手(`aria-label="展开生成任务"`)并带在途数量角标
- 位置取舍:右侧是「智能创作」对话面板、底部是栏目工具栏、顶部是栏目标题栏,所以挂在**左侧、标题栏
  之下、工具栏之上**(`top-16 / bottom-24 / left-3 / w-[300px]`)且是**覆盖式**而不是把画布挤窄——
  画布视口数学与资源卡排布都不被 reflow 动到,收起即完全让出画布;不再保留原来的浮层形态,没有平行入口
- 两个分栏:「排队/生成中」与「已完成」各带条数,只做客户端分组;「已完成」条数封顶 20 条(超出给出
  还剩多少条的说明),列表自身 `overflow-y-auto`,侧栏高度有界
- 入口按钮常驻可见并显示在途数量(`生成任务 · N`,无在途时仍是「生成任务」,`aria-label="生成任务"` 保留),
  把手上的角标同步;提交受理后侧栏自动展开(对齐网页端排队提交后主动弹任务栏)
- index.tsx:新增在途数量派生;侧栏常驻渲染并受同一个开合开关控制,折叠只影响这个视图,
  任务本身仍活在账本与本地队列里
- 测试:任务侧栏用例改为侧栏形态并补「两个分栏 + 条数」「高度有界 + 列表滚动 + 已完成封顶 20」
  「折叠后把手带上在途计数(0 / 2)」;AppSurface 补「提交后自动展开」「折叠期间任务照常推进、
  在途计数归零、再展开仍能看到结果」
2026-09-14 18:49:22 +08:00
suzmii 14a0286f70 fix(agc): 生成面板点击即关闭、面板内不再出现阶段文案,只有即时失败才把草稿带回来
- ResourceCanvasAssetGenerationPanelView:删掉 `phaseDetail` 通道与整套在途状态(`submitting` /
  禁用输入 / 「后台运行并关闭」),主按钮文案恒为动作名;提交时**同步**调用 onSubmit 并 onClose,
  面板不等 IPC、不等排队、不等生成。新增 `draft` / `error` 两个入参:只有「后端从未受理」的即时
  失败重开时,宿主才把草稿与原因带回来(`role="alert"` 可见、改完即可重试)
- index.tsx:`submitResourceAssetGeneration` 改为同步返回(不再 await 终局),记录本次提交上下文
  (taskId / action / 草稿 / 是否本来就该立刻派发),提交后自动开任务面板并给一次提示条;
  收尾回调里区分两类失败:`record === null` 且本来就该立刻派发 → 重开提交面板并带回草稿;
  受理之后才失败 → 不重开面板,只在任务面板收口为失败 + 提示条
- index.tsx:删掉只为旧「在途阶段文案」服务的 `resourceAssetGenerationPanelTaskIdRef` 与成功后的
  自动收面板逻辑(面板此时已关闭)
- 测试:`resourceCanvasAssetGenerationBackgroundClose.test.tsx` 的图片类用例改写为「点击即关闭 +
  面板 DOM 里从不出现 排队中。/正在生成。/提交中…」+「即时失败重开带回草稿与原因」;音频面板用例不变
- 测试:`resourceCanvasBottomToolbar.test.tsx` 的失败重试用例按新语义改写(点击即关闭、重开时显示原因)
- 测试:AppSurface 新增三条(同步关闭且面板无阶段文案、后端未受理时重开并保留草稿、受理后失败不重开
  只在任务面板收口),并把既有生成任务 e2e 的提交步骤改为「点击即关闭」
- 顺带按仓库规范修正以上文件的 eslint import sort 与 prettier 格式(未触碰无关文件)
2026-09-14 18:35:11 +08:00
suzmii 2f47540eeb fix(agc): 「定位到素材」每次点击都终局化,不再卡在「正在定位生成的素材…」
- index.tsx 新增 `resourceAssetGenerationFocusRequest` 序号并加进聚焦 effect 的依赖:手动点定位不改画布
  任何状态,原依赖一个都不变 → effect 不重跑、intent 永远没人消费,这正是「点了没反应 + 提示条永久
  停在中转文案」的根因
- index.tsx `focusResourceAssetGenerationTask` 重写:先按当前投影与清单判一次目标——不在投影里就当场给
  结论(素材已不在项目里 / 已登记但尚未同步),不挂 intent 也不留中转提示;在别的栏目先切栏目再定位;
  挂 intent 后推进聚焦请求序号,并起一个 3 秒有界兜底:仍停在中转文案就收口成可执行提示,intent 被判
  invalid 被静默清掉时也给一条「定位请求已失效」的结论,绝不留下悬而未决的状态
- index.tsx 聚焦 effect 的 `focusedCommitIdsRef` 提前返回分支补 `setResourceWorkbenchNotice('')`:
  已聚焦过时把中转提示一并收掉,不再永久留在提示条上(自动落卡那条链同源问题)
- index.tsx `resourceAssetGenerationContextRef` 扩到带 `manifest` / `resources` / `activePageCategory`
  并在每次渲染刷新:定位回调据此取权威目标与栏目,不再依赖可能过期的闭包值
- 新增 4 条 AppSurface 用例(同栏目直点定位并选中卡片、素材在另一个栏目先切栏目再定位、被搜索挡住时给出
  既有「清除搜索并定位」、素材不在项目里时给明确结论且清掉中转提示)
2026-09-14 18:20:50 +08:00
suzmii a2b016bb9b Merge branch 'feat/agc-generation-task-progress' into fix/agc-canvas-acceptance-batch 2026-09-14 18:18:45 +08:00
suzmii b898590c7d 合并 feat/agc-resource-type-entry:类型选项改纵向单选列表
- 合并 WS4 追加修复(928a0ea13:类型选项由横排 PlatformSegmentedTabs 改为 role=radiogroup 纵向单选列表,
  复用共享 PlatformNavigableListItem,列表有界独立滚动,rogiving tabindex + 方向键移焦点、Enter 落盘)
- 同时并入 origin/master a70ef2d6e(PR #354 Windows sccache / CI 分片改「一片一 job」)
- decision-log.md / pitfalls.md 冲突按「上游改写优先、我方新增保留」解:
  保留我方新增的「放开手工图片生成本地并发」「素材类型独立入口」「退役同步命令」三条与
  「严格 IPC 桩缺登记新命令」一条;AGC 壳 Rust 套件条目采用上游改写后的新标题(「一片一 job」),
  删掉被它取代的旧标题(旧正文早已被上游替换),不留自相矛盾的两条
2026-09-14 18:18:30 +08:00
suzmii 4e277798b8 Merge remote-tracking branch 'origin/master' into feat/agc-resource-type-entry 2026-09-14 18:17:10 +08:00
suzmii 928a0ea13e 素材类型选项改成纵向单选列表,不再横排成一条
- ResourceTypePanel:选项区从 PlatformSegmentedTabs(3~6 列网格,6 项正好挤成一行)换成纵向单选列表,复用共享的 PlatformNavigableListItem 作行骨架
- ResourceTypePanel:容器改为 role="radiogroup" + aria-label,每项 role="radio" + aria-checked,选中态与读屏读的是同一个属性
- ResourceTypePanel:roving tabindex(只有选中项 tabIndex=0)与方向键在选项间移动焦点,走到头不越界;方向键不顺手选中,Enter/Space 才落盘(选中=一次 CAS 写盘,浏览选项不该连环写盘)
- ResourceTypePanel:选中行额外渲染勾选图标作为第二视觉线索,图标 aria-hidden 不污染可读名字
- resourceTypePanel.css:选项容器改成单列网格(grid-template-columns: minmax(0, 1fr) + grid-auto-flow: row + align-content: start),一行一个选项
- resourceTypePanel.css:选项行补 width/min-width/min-height: 44px 与 overflow-wrap,移动端热区与窄屏换行都不依赖共享件的工具类
- resourceTypePanel.css:选中态由 [aria-checked='true'] 驱动并显式提权到 (0,3,0) 以上(含覆盖 :hover:not(:disabled)),避免被共享列表行的悬停底色顶掉
- resourceTypePanel.css:滚动从 body 挪到选项列表(列表现有 max-height: min(320px, 40dvh) + overflow-y: auto),body 改为 auto/minmax(0,1fr)/auto 三段,标题、素材名与错误提示不跟着滚
- 测试:新增「选项区是纵向单列列表」用例,按 styleCascade 解析真实生效声明(360/390/1440 三档宽度都是单列、行满宽、热区 44px),并断言 6 项都是 radiogroup 的直接子元素
- 测试:新增「单选语义与键盘」用例(radiogroup/radio + aria-checked + roving tabindex,方向键只移焦点不落盘,Enter 才写盘)
- 测试:新增「选中态由 aria-checked 驱动并压过共享列表行悬停底色」用例
- 测试:把「面板有高度上界且中间行可滚动」改成「选项区有上界并独立滚动,标题与错误提示不滚」
- 测试:集成用例的类型选项查询从 button/aria-pressed 改为 radio/aria-checked
2026-09-14 18:16:56 +08:00
suzmii cdc0e3d163 fix(agc): 生成任务读门禁用合法 command id、中断任务按清单核对不谎报未完成,并同步注释事实
- asset_generation_tasks.rs:`list_local_project_asset_generations` 的读门禁 command id 由 `asset.read`
  改为契约里真实存在的 `asset.list`(`asset.read` 不在命令表里,写进 `denied_commands` 也不可能命中
  → 原门禁恒不生效);补用例把 `asset.list` 放进项目策略 `denied_commands` 后读账本必须被拒
- asset_generation_tasks.rs:中断任务收口改为与 manifest 交叉核对(新增账本字段 `output_path`,serde 默认值
  兼容旧账本):能找到本次请求的精确落点 → 按事实收口为已完成并带上 assetId;落点存在但清单里没有
  → 报「目标素材未登记,生成未完成」;没有精确落点或清单读不到 → 只报「状态未知(可能已完成)」,不再
  对「素材已登记、账本还停在 running」的窗口谎报「生成未完成」
- asset_generation_tasks.rs:补四条用例(已登记→已完成、未登记→未完成、无落点→状态未知、读门禁可拒绝),
  并把既有的两条中断用例按新语义改为断言「状态未知」
- resourceCanvasBottomToolbarModel.ts:两处注释的入口命令名改为 `start_local_project_asset_generation`
- check-config.mjs:`generate_local_project_asset` 白名单注释改为「没有生产调用方,只有 Rust 集成测试与
  commands.rs 单测在调,待后续批次删除或改为转调 start_local_project_asset_generation」
- resourceCanvasAssetGenerationTaskModel.ts / resourceCanvasAssetGenerationQueue.ts:把「远端 single-flight
  输出槽会拒绝并发」这个已不成立的排队理由改为当前事实——本地 durable 输出槽已按精确动作指纹分槽,
  本批仍按「同一时刻只派发一条」排队是**提交节流**,真并行派发需要并发收口设计,留待下一批
2026-09-14 18:13:12 +08:00
suzmii 8ac77cb090 同步共享记忆与专题文档:并发槽身份、素材类型入口、退役同步命令
- PRD §3.10 本地排队一条:删掉已失效的「共用 single-flight 输出槽」理由,改为「本地槽已按精确动作指纹分槽(具备并行能力),本批前端仍按单条在途排队,真并行派发留待下一批」
- 栏目画布底部工具栏入口矩阵 §4a:同上更正,并写明槽身份材料的 8 个字段与旧槽懒迁移
- decision-log:追加三条 2026-09-14 条目(放开手工图片生成本地并发 / 素材类型拆成独立入口 / 同步命令 generate_local_project_asset 退役为仅测试调用)
- decision-log:更正同日「图片类生成后台化」条目里已被同批次推翻的两处表述(槽身份、本地排队的理由),并给 2026-09-11 的类型选择器入口决策加「已被取代」标记
- decision-log/pitfalls:把「严格 IPC 桩缺登记新命令」的排障经验记入共享记忆(unhandled rejection 与不相干提示断言的组合症状、桩必须返回数组、形状错会「看着绿」)
- 端到端验收用例 S12:更正「用户手动设置 category 已移除」与「面板内删除资源是唯一入口」两处与当前实现不符的描述
2026-09-14 17:52:27 +08:00
suzmii 7240b3793f Merge branch 'feat/agc-generation-task-progress' into fix/agc-canvas-acceptance-batch 2026-09-14 17:50:40 +08:00
suzmii 4fff733516 fix(agc): 生成任务账本读不到时不再抛未处理拒绝,改为保留本地状态并给出提示
- resourceCanvasAssetGenerationTaskModel.applyLocalProjectAssetGenerationRecords:入参在函数入口归一化,
  非数组(undefined / null / 字符串 / 数字 / 对象)一律按「没有后端记录」处理,数组内的垃圾条目
  (null / 数字 / 缺 taskId)逐条丢弃;类型放宽为 `readonly …[] | null | undefined`
- resourceCanvasAssetGenerationQueue:轮询 `list_local_project_asset_generations` 的 IPC 拒绝与非数组
  返回值都在本地接住,按「本轮读不到」计数;到上限后按「账本不可用」与「账本里没有这条」两种原因分别收口;
  派发循环再补两道兜底(dispatch 意外抛出、宿主 onSettled 抛出),保证 `void (async …)()` 不产生未处理拒绝、
  也不会把任务永远卡在「已派发但未终态」
- index.tsx 恢复任务账本的 effect:IPC 拒绝与返回非数组都落到既有提示条
  「生成任务列表读取失败,暂时无法恢复历史任务」,本地任务列表保持原样不清空
- 测试:模型层补「undefined / 非数组 / 垃圾条目不抛错且列表不变」,驱动器层补
  「轮询返回 undefined」「轮询 IPC reject」两条(收口原因、不产生未处理拒绝、后续排队任务照常补发)
- AppSurface 补两条:账本返回 undefined / 读取被拒绝时视图照常渲染、生成入口还在、提示可见
- AppSurface 既有的「布局读时提示」用例显式登记 `list_local_project_asset_generations` 返回空账本:
  该命令是只读的独立链路,严格桩把它判成 unexpected invoke 会让新提示混进「没有任何提示」的断言
2026-09-14 17:47:03 +08:00
suzmii 5d336bf147 Merge branch 'fix/agc-resource-layout-manual' into fix/agc-canvas-acceptance-batch 2026-09-14 17:40:50 +08:00
suzmii 3d6d51737c 测试 harness 登记 WS1 新增的后台生成任务账本 IPC
- resourceCanvasManualLayout.test.tsx:installLayoutTauri 登记
  list_local_project_asset_generations(工作台打开 / 切项目时由 WS1 的生成任务面板读取),
  回空账本,形状按 src-tauri/src/asset_generation_tasks.rs 的
  list_local_project_asset_generations 返回值核对为数组(Vec<AssetGenerationTaskRecord>),
  与前端 invoke<LocalProjectAssetGenerationTaskRecord[]> 一致,不是 { tasks: [] }
- 保留 unexpectedCommands 断言不放宽,也不删除:这条门禁继续拦截意外 IPC
2026-09-14 17:38:21 +08:00
suzmii 1de661df10 Merge branch 'feat/agc-generation-concurrency' into fix/agc-canvas-acceptance-batch 2026-09-14 17:31:40 +08:00
suzmii 6b5f67d207 Merge branch 'feat/agc-generation-task-progress' into fix/agc-canvas-acceptance-batch 2026-09-14 17:31:32 +08:00
suzmii 74b3760d0f docs(prd): 同步功能画布生成入口的后台化事实
- 头部更新时间改为 2026-09-14 并说明本次同步内容,2026-09-13 的原口径与其余合同保持不变的声明保留
- §3.10 实现状态补 2026-09-14 后台化口径(提交即返回 + 项目内任务账本 + 本地排队 + 非模态「生成任务」面板)
- §3.10 接线:图片类入口由 `generate_local_project_asset` 改写为 `start_local_project_asset_generation` + `list_local_project_asset_generations`
  + 项目内账本 `.agent/runtime/asset-generation-tasks/tasks.json`;音频 / 上传两条接线不变
- §3.10 新增「本地排队与进度可见」一条:第二条停在本地队列不调用提交 IPC、阶段文案由后端账本提供、
  非模态「生成任务」浮层与按素材卡定位
- §3.10 面板形态:补「提交期间面板不锁关闭」(×/遮罩/Esc/「后台运行并关闭」),并明确关闭不等于取消请求
- §3.10 前置规范图:命令名同步,并明确「后台化没有改这条前置判据」(`ui-prototype` / `art-spritesheet` 判据不变)
- §7.9 第 3 条:改写为 `start_local_project_asset_generation` 的载荷与「提交即返回」事实,
  保留「原载荷字段名与取值口径逐字不变」的说明,并补 `projectId` / `taskId` 两个新增字段
- §7.9 第 6 条:配对读改为「发生在该任务收口之后(提交命令不再等待生成)」,音频入口描述不变
- §7.9 新增第 8 条:生成期间可退出、面板关闭后进度可见、第二条本地排队(IPC 次数 1→2)、
  两条各自完成后各走一次「配对读 + 落卡」且 revision 单调
2026-09-14 17:30:08 +08:00
suzmii 53026fe8f3 放开 AGC 手工图片生成的 durable 输出槽并发生成限制
- canvas_generation.rs:standalone 图片生成的 durable 输出槽身份改为由精确动作身份派生(run_id = slot-<actionFingerprint>),同一项目里不同 prompt/素材名/参数的生成各自独立成槽,可以同时在途
- canvas_generation.rs:动作指纹材料逐字节保持升级前取值,新增 legacy_standalone_platform_art_generation_run_id 复现升级前 {outputPath, requireSlices} 槽公式,只用于定位升级前遗留账本
- canvas_generation.rs:在取得 durable guard 之后、任何远端 POST 之前调用旧槽账本一次性迁移
- external_generation_state.rs:新增 adopt_legacy_standalone_platform_art_generation_runtime_state_at,在项目写锁内把仍属于本次精确动作的旧 slot- 账本迁移到新身份路径并保留原 idempotencyKey 与 operationId;属于其他动作的旧账本原样保留,不迁移、不删除、不阻塞
- external_generation_state.rs:新增三个 #[cfg(test)] 只读访问器,供跨模块用例断言账本身份
- canvas_generation.rs:新增两条不同动作并发都成功且远端各 POST 一次、不同动作的 durable 槽互不阻塞、旧槽账本仅被同一精确动作迁移三组用例;同动作并发冲突用例改用同一精确动作以钉住幂等语义
2026-09-14 17:30:00 +08:00
suzmii bb7cbae118 feat(agc): 图片类生成后台化,生成面板可随时关闭并新增「生成任务」进度面板
- Rust 新增 asset_generation_tasks.rs:图片类生成拆成「提交即返回任务记录 + 后台 spawn 生成」,
  生成本身仍转调既有 generate_platform_art_asset_with_options_at(不复制生成 / 幂等 / 登记逻辑)
- 任务账本落项目内 .agent/runtime/asset-generation-tasks/tasks.json(复用 agent runtime sidecar 原语),
  阶段文案 phaseDetail 由后端拥有,读取时把上次运行留下的非终态记录收口为「中断失败」,账本上限 50 条
- Rust 新增 IPC start_local_project_asset_generation / list_local_project_asset_generations 并在 main.rs 注册;
  同步命令 generate_local_project_asset 保留不动
- 新增前端纯模型 resourceCanvasAssetGenerationTaskModel.ts:任务状态机、在途判据、账本恢复、耗时文案
- 新增 resourceCanvasAssetGenerationQueue.ts:本地排队驱动器,第二条不发提交 IPC、前一条终态后自动补发,
  状态与阶段一律以后端账本为准,账本查不到该任务时按上限停止等待
- 两块生成浮层在提交期间放开 × / 遮罩 / Esc,次按钮在途时改为「后台运行并关闭」(关闭不等于取消请求),
  图片类面板的在途文案改为渲染后端 phaseDetail
- 新增非模态「生成任务」面板(不铺遮罩 / 不做焦点陷阱 / 不进模态遮挡判据),显示状态、后端阶段、
  已耗时与素材名,已完成条目复用 pendingResourceFocusRef 聚焦链定位素材卡
- index.tsx 宿主接线:任务列表状态与 ref、提交回调不再读面板状态、按项目恢复任务账本、
  工具栏「生成任务」入口按钮与面板渲染点
- check-config.mjs:把已无前端调用方的同步命令 generate_local_project_asset 登记进 native-only 白名单
- 测试:新增「生成面板提交期间可关闭」「本地排队驱动器」「生成任务面板」三组用例;
  appSurface 生成载荷断言改为 start_local_project_asset_generation + list 轮询桩
- 文档:入口矩阵补 §4a 与验证命令、V3 验收 S11a 判据、decision-log 记录本次决策
2026-09-14 17:26:36 +08:00
suzmii 0b3bf3e52d 合并 fix/agc-resource-layout-manual:资源画布改为手动整理口径
- 合并 WS2 分支(新素材不再自动重排 + 显式「整理画布」+ 生成后自动聚焦)
- index.tsx 与 useProjectResourceCanvasLayout.ts 自动合并成功,无需手工介入
- decision-log.md / pitfalls.md 冲突按「两侧语义都保住」解:保留 origin/master 新增的
  「AGC 壳 Rust 套件分片并行」与「根门禁 label 字面量契约」两条,同时保留 WS2 新增的
  「AGC 资源画布改为手动整理」与「不要恢复无条件重派生」两条,只删除冲突标记行
- 新增 tests/resourceCanvasManualLayout.test.tsx
2026-09-14 17:16:58 +08:00
suzmii 6d5cd51649 Merge branch 'fix/agc-preview-alpha-background' into fix/agc-canvas-acceptance-batch 2026-09-14 17:16:30 +08:00
suzmii 181e364d80 AGC 资源卡棋盘格底改按图像真实 alpha 判定
- 原生预览新增头部级 alpha 判据:`LocalProjectImagePreview.hasAlpha`(PNG colorType 4/6 或 tRNS、WebP VP8X/VP8L alpha 标志;JPEG 恒 false),只读签名与 chunk 头、不做像素解码。
- 前端预览 payload 增加可选 `hasAlpha`,并在 `materializeProjectResourceCardPreview` 的两条分支里透传。
- 资源卡根节点新增排障属性 `data-preview-has-alpha`:只有预览已加载且判据为真才写 `'true'`,其余一律不写(缺属性与不透明同档)。
- 棋盘格底选择器加 `[data-preview-has-alpha='true']` 条件;无 alpha 时退回卡片既有纯色底,不引入第二套底色。
- 新增 TS 用例(真渲染资源卡 + styles.css 声明级层叠求值)与 Rust 用例(PNG/WebP/JPEG 头部判据、tRNS、坏文件失败关闭、非解码证明、IPC 字段序列化契约)。
- 验收文档补记 `data-preview-has-alpha` 判据与「真假棋盘格」取证脚本,便于现场区分真透明底与 AI 画出的假棋盘格。
2026-09-14 17:13:43 +08:00
suzmii 3b8ecb02d4 记录 AGC 资源画布「手动整理」口径变更
- decision-log:追加 2026-09-14 条目,记录默认 preserve、显式「整理画布」、
  依赖侧关系图首次就绪的一次性重派生、新素材按 manifest 新增 id 聚焦四条决策与变异验证
- pitfalls:追加「不要恢复无条件重派生」条目,含 changed 门、ready 时序、
  项目作用域 flag 与新素材基线四个易错点
2026-09-14 17:08:00 +08:00
suzmii d02d0ed7a4 AGC 资源画布:新素材不再自动重排,改为显式「整理画布」并在生成后自动聚焦新素材
- useProjectResourceCanvasLayout:资源写意图新增 rederive 标记,显式整理那一笔按 rederive
  策略重算自动坐标(手动坐标原样保留),自动同步仍是 preserve 只补新卡
- useProjectResourceCanvasLayout:新增 rederiveNow(),复用既有写队列 / sidecar 写回链路,
  只在坐标确有变化时落盘,成功复用既有「布局已保存」提示
- index.tsx:两个排序模式的 rederiveAutomaticPositions 都固定 false,画布不再因为资源
  协调签名变化(新增素材、改标签、改分类、拓扑重建)整体重排
- index.tsx:依赖侧「关系图首次就绪」改为按项目作用域的一次性 flag,等该侧 sidecar ready
  后调用一次 rederiveNow(),之后一律只补新卡(切排序 tab 不重新武装)
- index.tsx:依赖/类型 tab 组新增「整理画布」按钮,作为整张画布重排的唯一显式入口
- index.tsx:新增 manifest.assets 新增 id 检测(seenManifestAssetIdsRef),把新卡交给既有
  pendingResourceFocusRef + advanceFocusGeneration() 裁决链聚焦;首次打开 / 切项目只登记
  基线不聚焦,重命名不改 id 天然不触发,已有同目标意图时不重复挂
- 新增 tests/resourceCanvasManualLayout.test.tsx(8 条):新素材入库后既有自动坐标逐值不变、
  依赖侧首次就绪后不再重排、新素材自动聚焦、被搜索挡住走既有「清除搜索并定位」、
  「整理画布」重算并保留手动坐标、已经整齐时再按一次不写盘、首次打开与切项目不聚焦
2026-09-14 17:07:55 +08:00
suzmii ef3e783ab1 Merge remote-tracking branch 'origin/master' into feat/agc-resource-type-entry 2026-09-14 17:07:36 +08:00
suzmii 7ce4a3a9c6 把素材类型从「编辑素材标签」面板拆成独立入口
- 新增 ResourceTypePanel(src/view/project-development/ResourceTypePanel.tsx):类型选一项即落盘,category 传用户选中值、tags 逐字回传落盘原值,保存在飞时沿用 closeOnBackdrop/closeOnEscape = !saving
- 新增 resourceAssetDisplayName.ts:两块面板共用同一个素材名副标题口径(localPath basename),不再各写一份
- 新增 resourceTypePanel.css:类型面板的弹窗骨架与信息浮层「分类」行入口样式,按 AGC 内独立文件约定,不动 packages/** 与 styles.css
- ResourceClassificationPanel 删掉类型 chip 与 categoryChoice:保存时恒回传 gameCreationAppAssetPersistedCategory(asset),「只改标签不动分类」由结构保证
- index.tsx 新增 resourceTypeAssetId 与 resourceTypeAsset;工具条在「编辑标签」旁新增「素材类型」按钮(Shapes 图标)
- index.tsx 用合并后的 resourceClassificationOverlayOpen 纳入点外部/Esc 浮层遮挡判据,并同步 useEffect 依赖项
- index.tsx 渲染新面板:保存成功后由宿主收起面板并沿用 manifest 重载路径,让卡片立刻落到新栏目
- 信息浮层「分类」行接出第二入口:按钮渲染在 dd 之外,不污染 dt/dd 字段读取口径;非 manifest 资产不给入口
- resourceCanvasInfoModel 导出 RESOURCE_INFO_CATEGORY_FIELD_LABEL,模型与入口共用同一行标识
- 新增 tests/resourceTypePanel.test.tsx:选中即落盘、tags 逐字回传、没碰过不写盘、显式待归类、失败回退、saving 锁、Esc/遮罩、面板骨架共 11 条
- 迁移「没碰过分类就回传落盘原值」对照用例并保留两条(标签面板侧),chip 用例搬到新面板文件
- 集成用例改走新入口:选中即落盘并自动关窗,另补 Esc/点外部不串台、只改标签后 manifest category 逐字不变两条宿主级用例
- appSurface 工具条清单补上「素材类型」
2026-09-14 17:07:15 +08:00
62 changed files with 9679 additions and 854 deletions
@@ -113,6 +113,11 @@ const allowedUncalledTauriCommands = [
'chat_with_game_creator_agent',
'check_ui_editor_font_glyph_coverage',
'create_ui_design_resource',
// 图片类生成的同步变体:GUI 已改为 `start_local_project_asset_generation` + 项目内任务账本
// (提交即返回、后台生成)。这条命令**没有生产调用方**,只有 Rust 集成测试
// `src/tests/project.rs`)与 `commands.rs` 单测在调;待后续批次删除,或改为转调
// `start_local_project_asset_generation`。
'generate_local_project_asset',
'open_game_creator_launcher_window',
'open_game_creator_workspace_window',
'read_direct_project_conversation',
@@ -9,6 +9,10 @@ import {
const agcDevHost = '127.0.0.1';
const legacyAgcDevPort = 3080;
const agcVitePortEnvKey = 'GENARRATIVE_AGC_VITE_PORT';
const agcAdminWebHost = '127.0.0.1';
const legacyAgcAdminWebPort = 3102;
// 与 scripts/dev.mjs 的后台 Web 端口配置保持同一环境变量名。
const agcAdminWebPortEnvKey = 'ADMIN_WEB_PORT';
function readConfiguredAgcDevPort(env = process.env) {
const rawPort = String(env[agcVitePortEnvKey] ?? '').trim();
@@ -99,13 +103,100 @@ function withAgcDevEndpointEnv(endpoint, env = process.env) {
};
}
function readConfiguredAgcAdminWebPort(env = process.env) {
const rawPort = String(env[agcAdminWebPortEnvKey] ?? '').trim();
if (!rawPort) {
return null;
}
const port = normalizePort(rawPort, -1);
if (port < 1024) {
throw new Error(`${agcAdminWebPortEnvKey} 必须是 1024-65535 的有效端口`);
}
return port;
}
function createAgcAdminWebEndpoint(port, portRange = null) {
const origin = `http://${agcAdminWebHost}:${port}`;
return {
host: agcAdminWebHost,
port,
origin,
basePath: '/admin/',
url: `${origin}/admin/`,
portRange,
};
}
// AGC 开发态的后台 Web 与 `npm run dev` 的后台 Vite 共用同一套优先端口约定:
// Linux 取当前用户端口段的 `start + 3` 槽位,非 Linux 保留 `3102` 兼容首选并允许统一漂移。
async function resolveAgcAdminWebEndpoint({
env = process.env,
platform = process.platform,
strictConfigured = false,
reservedPorts = [],
reservePortRange = reserveLinuxDevPortRange,
findPort = findAvailablePort,
} = {}) {
const configuredPort = readConfiguredAgcAdminWebPort(env);
let portRange = null;
let preferredPort = configuredPort ?? legacyAgcAdminWebPort;
if (platform === 'linux') {
const allocation = await reservePortRange({ env });
if (!allocation?.range) {
throw new Error('无法取得当前 Linux 用户的 dev 端口段');
}
portRange = allocation.range;
const mappedAdminWebPort = mapDevPortsToPortRange(portRange)?.adminWebPort;
if (!Number.isInteger(mappedAdminWebPort)) {
throw new Error(
`当前 Linux dev 端口段 ${portRange.label} 缺少后台 Web 槽位;请先迁移为至少 6 个端口且不与其它用户重叠的端口段`,
);
}
preferredPort = configuredPort ?? mappedAdminWebPort;
}
const reservedPortSet = new Set(
reservedPorts.filter((value) => Number.isInteger(value) && value > 0),
);
const port = await findPort({
host: agcAdminWebHost,
preferredPort,
portRange,
reservedPorts: reservedPortSet,
strict: strictConfigured && configuredPort != null,
});
console.log(
formatPortDecision({
name: 'ai-game-creator-shell-admin-web',
host: agcAdminWebHost,
preferredPort,
resolvedPort: port,
}),
);
if (portRange) {
console.log(
`[ai-game-creator-shell] admin-web port-range: ${portRange.label}`,
);
}
return createAgcAdminWebEndpoint(port, portRange);
}
export {
agcAdminWebHost,
agcAdminWebPortEnvKey,
agcDevHost,
agcVitePortEnvKey,
createAgcAdminWebEndpoint,
createAgcDevEndpoint,
legacyAgcAdminWebPort,
legacyAgcDevPort,
readAgcDevEndpoint,
readConfiguredAgcAdminWebPort,
readConfiguredAgcDevPort,
resolveAgcAdminWebEndpoint,
resolveAgcDevEndpoint,
withAgcDevEndpointEnv,
};
@@ -14,12 +14,14 @@ import {
import {
agcVitePortEnvKey,
readAgcDevEndpoint,
resolveAgcAdminWebEndpoint,
resolveAgcDevEndpoint,
withAgcDevEndpointEnv,
} from './dev-port.mjs';
const appRoot = fileURLToPath(new URL('..', import.meta.url));
const repoRoot = resolve(appRoot, '../..');
const adminWebDir = resolve(repoRoot, 'apps/admin-web');
const devStackStatePath = resolve(repoRoot, '.app/dev-stack.json');
const apiServerExePath = resolve(
repoRoot,
@@ -32,6 +34,8 @@ const backendSpacetimeDataDir = resolve(
repoRoot,
'server-rs/.spacetimedb/ai-game-creator/data',
);
// 后台 Web 默认跟随 AGC 一起起来,便于联调后台页面;`AGC_DEV_ADMIN_WEB=0` 可关闭。
const agcDevAdminWebEnvKey = 'AGC_DEV_ADMIN_WEB';
const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm';
const childLifecycles = new WeakMap();
@@ -200,36 +204,122 @@ function urlPort(url) {
}
}
// 读取端口当前真正的监听进程身份。返回 null 表示探测本身不可用(例如缺少
// Get-NetTCPConnection),此时调用方必须退化为旧行为,不能让本地启动直接失败。
// 端口归属探测脚本。历史实现用 `Get-NetTCPConnection` 取监听进程,而它底层走
// WMI:实测单端口单次 11.2 秒、再叠加每个 PID 的 `Get-CimInstance` 3.3 秒,
// 一轮探测约 43 秒,直接把"配套后端就绪"等待拖到分钟级。改用原生
// `netstat -ano`(约 30 毫秒)取端口 -> PID,再用 .NET `Process` 读进程名和
// 可执行文件路径(毫秒级);只有核对 SpacetimeDB `--data-dir` 归属时才按 PID
// 取命令行,并允许调用方把已知命令行传进来复用。
const windowsPortOwnerProbeCommand = [
'$ErrorActionPreference = "SilentlyContinue"',
'$queriedPorts = @()',
'foreach ($raw in ($env:GENARRATIVE_QUERY_PORTS -split ",")) {',
' if ($raw -match "^\\d+$") { $queriedPorts += [int]$raw }',
'}',
'$knownCommandLines = @{}',
'if ($env:GENARRATIVE_KNOWN_COMMAND_LINES) {',
' try {',
' foreach ($property in (ConvertFrom-Json $env:GENARRATIVE_KNOWN_COMMAND_LINES).PSObject.Properties) {',
' $knownCommandLines[[int]$property.Name] = [string]$property.Value',
' }',
' } catch { }',
'}',
'$listenerPidByPort = @{}',
'foreach ($line in (netstat -ano -p tcp)) {',
' $fields = @($line -split "\\s+" | Where-Object { $_ })',
' if ($fields.Count -lt 4) { continue }',
' if ($fields[0] -ne "TCP") { continue }',
' # A listening socket always has foreign address 0.0.0.0:0 / [::]:0, which',
' # is locale-independent unlike the localized netstat State column.',
' if ($fields[2] -notmatch ":0$") { continue }',
' $localPort = [int]($fields[1].Split(":")[-1])',
' if ($queriedPorts -notcontains $localPort) { continue }',
' # The PID is the last column; do not hardcode its index.',
' if ($fields[-1] -notmatch "^\\d+$") { continue }',
' $listenerPidByPort[$localPort] = [int]$fields[-1]',
'}',
'$result = @()',
'foreach ($port in ($listenerPidByPort.Keys | Sort-Object)) {',
' $processId = $listenerPidByPort[$port]',
' $name = $null',
' $executablePath = $null',
' $commandLine = $null',
' try {',
' $process = [System.Diagnostics.Process]::GetProcessById($processId)',
' $name = $process.ProcessName + ".exe"',
' try { $executablePath = $process.MainModule.FileName } catch { }',
' } catch { }',
' if ($knownCommandLines.ContainsKey($processId)) {',
' $commandLine = $knownCommandLines[$processId]',
' } elseif (($name -like "spacetime*") -or (-not $executablePath)) {',
' try { $commandLine = (Get-CimInstance Win32_Process -Filter ("ProcessId=" + $processId)).CommandLine } catch { }',
' }',
' $result += [pscustomobject]@{ port = [int]$port; processId = $processId; name = $name; executablePath = $executablePath; commandLine = $commandLine }',
'}',
'ConvertTo-Json -InputObject @($result) -Compress',
].join('\n');
// 进程命令行在进程生命周期内不变,但 PID 会被系统复用;按 PID 记 TTL 缓存,
// 让"等配套后端就绪"的轮询只在首个周期付出 WMI 成本。TTL 取 5 分钟:本轮实测
// 这台机器上首次 WMI 调用约 18 秒(热调用 3.3 秒),而 PID 在 5 分钟内被复用
// 成另一个运行本工作树 data dir 的 SpacetimeDB 才能造成误判,概率可忽略。
// 默认实现才缓存,注入实现(测试)与显式 env 始终重新读取。
const WINDOWS_COMMAND_LINE_CACHE_TTL_MS = 300_000;
const windowsPortOwnerCommandLineCache = new Map();
function resolveCommandLineCache({ spawnImpl, env }) {
return spawnImpl === spawnSync && env === process.env
? windowsPortOwnerCommandLineCache
: new Map();
}
// 读取端口当前真正的监听进程身份。返回 null 表示探测本身不可用(例如系统缺少
// netstat),此时调用方必须退化为旧行为,不能让本地启动直接失败。
function readWindowsPortOwnerIdentities(
ports,
{ spawnImpl = spawnSync, env = process.env } = {},
{
spawnImpl = spawnSync,
env = process.env,
now = Date.now,
commandLineTtlMs = WINDOWS_COMMAND_LINE_CACHE_TTL_MS,
commandLineCache = resolveCommandLineCache({ spawnImpl, env }),
} = {},
) {
const uniquePorts = [...new Set(ports.filter((port) => port > 0))];
if (uniquePorts.length === 0) {
return null;
}
const command = [
'$ErrorActionPreference = "SilentlyContinue"',
'$ports = ($env:GENARRATIVE_QUERY_PORTS -split ",") | Where-Object { $_ }',
'$result = @()',
'foreach ($port in $ports) {',
' $connection = Get-NetTCPConnection -State Listen -LocalPort ([int]$port) -ErrorAction SilentlyContinue | Select-Object -First 1',
' if (-not $connection) { continue }',
' $owner = Get-CimInstance Win32_Process -Filter ("ProcessId=" + $connection.OwningProcess) -ErrorAction SilentlyContinue',
' $result += [pscustomobject]@{ port = [int]$port; processId = [int]$connection.OwningProcess; name = $owner.Name; executablePath = $owner.ExecutablePath; commandLine = $owner.CommandLine }',
'}',
'ConvertTo-Json -InputObject @($result) -Compress',
].join('\n');
const knownCommandLines = {};
for (const [processId, record] of [...commandLineCache]) {
if (record && now() - record.at < commandLineTtlMs) {
knownCommandLines[processId] = record.commandLine;
} else {
commandLineCache.delete(processId);
}
}
const childEnv = {
...env,
GENARRATIVE_QUERY_PORTS: uniquePorts.join(','),
};
if (Object.keys(knownCommandLines).length > 0) {
childEnv.GENARRATIVE_KNOWN_COMMAND_LINES =
JSON.stringify(knownCommandLines);
}
const result = spawnImpl(
'powershell.exe',
['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', command],
[
'-NoProfile',
'-ExecutionPolicy',
'Bypass',
'-Command',
windowsPortOwnerProbeCommand,
],
{
encoding: 'utf8',
env: { ...env, GENARRATIVE_QUERY_PORTS: uniquePorts.join(',') },
env: childEnv,
maxBuffer: 8 * 1024 * 1024,
},
);
@@ -240,9 +330,22 @@ function readWindowsPortOwnerIdentities(
const owners = new Map();
for (const entry of parseWindowsProcessSnapshot(result.stdout)) {
const port = Number(entry?.port);
if (Number.isInteger(port) && port > 0) {
owners.set(port, entry);
if (!Number.isInteger(port) || port <= 0) {
continue;
}
const processId = Number(entry?.processId);
if (
Number.isInteger(processId) &&
processId > 0 &&
typeof entry?.commandLine === 'string' &&
entry.commandLine
) {
commandLineCache.set(processId, {
commandLine: entry.commandLine,
at: now(),
});
}
owners.set(port, entry);
}
return owners;
}
@@ -879,10 +982,102 @@ async function startVite(apiTarget, endpoint = readAgcDevEndpoint()) {
);
}
function readAdminWebEnabled(env = process.env) {
return String(env[agcDevAdminWebEnvKey] ?? '').trim() !== '0';
}
// 后台 Web 与 AGC Vite 一样直接由本启动器持有,不经过 `dev.mjs admin-web`
// 后者会整体重写 `.app/dev-stack.json`,把本次配套后端的状态覆盖掉。
function startAdminWeb(
apiUrl,
endpoint,
{ env = process.env, spawnImpl = spawnChild } = {},
) {
return spawnImpl(
npm,
[
'--prefix',
'../..',
'exec',
'vite',
'--',
'--host',
endpoint.host,
'--port',
String(endpoint.port),
'--strictPort',
],
{
cwd: adminWebDir,
env: {
...env,
ADMIN_API_TARGET: apiUrl,
GENARRATIVE_API_TARGET: apiUrl,
GENARRATIVE_API_PORT: String(urlPort(apiUrl) || 8082),
ADMIN_WEB_BASE: endpoint.basePath,
},
},
);
}
function formatStartupSummary({
frontendUrl = '',
apiUrl = '',
adminWebUrl = '',
spacetimeUrl = '',
bgfilterWorkerUrl = '',
} = {}) {
const segments = [
['前端', frontendUrl],
['后端', apiUrl],
['后台', adminWebUrl],
['数据库', spacetimeUrl],
['bgfilter-worker', bgfilterWorkerUrl],
]
.filter(([, value]) => Boolean(value))
.map(([label, value]) => `${label} ${value}`);
return `[ai-game-creator-shell] 启动汇总: ${segments.join(' | ')}`;
}
// 后台 Web 是可选联调服务:端口解析或启动失败只告警,不能阻断 AGC 客户端与配套后端。
async function ensureAdminWeb({
apiUrl,
reservedPorts = [],
env = process.env,
enabled = readAdminWebEnabled(env),
resolveEndpoint = resolveAgcAdminWebEndpoint,
spawnAdminWeb = startAdminWeb,
waitForExit = waitForChildTermination,
warn = (message) => console.warn(message),
} = {}) {
if (!enabled) {
return { endpoint: null, child: null };
}
try {
const endpoint = await resolveEndpoint({ env, reservedPorts });
const child = spawnAdminWeb(apiUrl, endpoint, { env });
waitForExit(child).then((failure) => {
warn(
`[ai-game-creator-shell] 后台 Web 已退出(${formatChildFailure(failure)}),AGC 继续运行。`,
);
});
return { endpoint, child };
} catch (error) {
warn(
`[ai-game-creator-shell] 后台 Web 未能启动(${
error instanceof Error ? error.message : String(error)
}),AGC 继续运行。`,
);
return { endpoint: null, child: null };
}
}
async function main() {
let backendChild = null;
let startedBackend = false;
let viteChild = null;
let adminWebChild = null;
let shutdownSignal = '';
const signalHandlers = new Map();
@@ -905,6 +1100,7 @@ async function main() {
const handler = () => {
shutdownSignal = signal;
stopChild(viteChild, signal);
stopChild(adminWebChild, signal);
stopChild(backendChild, signal);
// 立刻清扫,避免外层 taskkill /F 抢在 finally 之前把本进程杀掉。
sweepStartedBackend();
@@ -937,6 +1133,25 @@ async function main() {
throw new Error(`启动期收到 ${shutdownSignal},已停止前端服务`);
}
const adminWeb = await ensureAdminWeb({
apiUrl: backend.targets.apiUrl,
// AGC Vite 端口尚未监听,必须显式保留,避免被后台 Web 抢先占用。
reservedPorts: [endpoint.port],
});
adminWebChild = adminWeb.child;
if (shutdownSignal) {
throw new Error(`启动期收到 ${shutdownSignal},已停止后台 Web`);
}
console.log(
formatStartupSummary({
frontendUrl: endpoint.url,
apiUrl: backend.targets.apiUrl,
adminWebUrl: adminWeb.endpoint?.url ?? '',
spacetimeUrl: backend.targets.spacetimeUrl,
bgfilterWorkerUrl: backend.targets.bgfilterWorkerUrl,
}),
);
const children = [backendChild, viteChild].filter(Boolean);
if (children.length === 0) {
return 0;
@@ -946,10 +1161,12 @@ async function main() {
children.map((child) => waitForChildTermination(child)),
);
stopChild(viteChild);
stopChild(adminWebChild);
stopChild(backendChild);
return failure.type === 'error' || failure.signal ? 1 : (failure.code ?? 0);
} catch (error) {
stopChild(viteChild);
stopChild(adminWebChild);
stopChild(backendChild);
console.error(
`[ai-game-creator-shell] ${error instanceof Error ? error.message : String(error)}`,
@@ -958,6 +1175,7 @@ async function main() {
} finally {
await Promise.all([
terminateChildTree(viteChild),
terminateChildTree(adminWebChild),
terminateChildTree(backendChild),
]);
sweepStartedBackend();
@@ -975,9 +1193,12 @@ function isDirectModuleExecution() {
}
export {
agcDevAdminWebEnvKey,
ensureAdminWeb,
ensureBackend,
formatChildFailure,
formatOwnerLabel,
formatStartupSummary,
isAiGameCreatorServer,
isBackendReady,
isDirectModuleExecution,
@@ -985,6 +1206,7 @@ export {
isWorktreeApiServerOwner,
isWorktreeSpacetimeOwner,
preflightExistingVite,
readAdminWebEnabled,
readBackendServiceFailure,
readChildFailure,
readExistingViteServer,
@@ -993,6 +1215,7 @@ export {
resolveBackendTargetsFromState,
runWindowsTaskkill,
spawnChild,
startAdminWeb,
stopChild,
terminateChildTree,
verifyAgcBackendOwnership,
@@ -1,8 +1,4 @@
use super::*;
use crate::ui_editor::persistence::{
generate_ui_design_code_at, GenerateUiDesignCodeInput, UI_DESIGN_DOC_ASSET_KIND,
UI_DESIGN_DOC_MEDIA_TYPE,
};
pub(crate) const MAX_DIRECT_CODEX_REFERENCES: usize = 32;
const MAX_DIRECT_CODEX_REFERENCE_ID_CHARS: usize = 200;
@@ -137,35 +133,7 @@ fn validate_resource_reference_id(value: &str) -> Result<String, String> {
Ok(resource_id.to_string())
}
/// Render the prompt context for a UI design asset.
///
/// Keep this separate from the generic resource renderer so UI-specific
/// instructions/metadata can evolve without changing other asset kinds.
fn render_ui_design_reference_line(
root: &Path,
manifest: &GameCreationAppManifest,
asset: &GameCreationAppAssetManifestEntry,
resource_id: &str,
label: &str,
local_path: &str,
source: &str,
) -> String {
let context = match generate_ui_design_code_at(GenerateUiDesignCodeInput {
project_path: root.to_string_lossy().into_owned(),
expected_project_id: manifest.project_id.clone(),
asset_id: resource_id.to_string(),
}) {
Ok(result) => format!("请先阅读生成的带有文档的代码片段: {}", result.relative_path),
Err(error) => format!("生成代码遇到错误{error}"),
};
format!(
"- 素材 ID{resource_id};名称:{label};类型:{};媒体类型:{};项目路径:{local_path};来源:{source}",
asset.kind, asset.media_type
) + "\n" + &context
}
fn render_resource_reference_line(
root: &Path,
manifest: &GameCreationAppManifest,
reference: &DirectCodexResourceReference,
) -> Result<String, String> {
@@ -181,19 +149,6 @@ fn render_resource_reference_line(
.unwrap_or_else(|| asset_display_label(asset));
let source = sanitize_reference_source(reference.source.as_deref())
.unwrap_or_else(|| "unknown".to_string());
let is_ui_design =
asset.kind == UI_DESIGN_DOC_ASSET_KIND && asset.media_type == UI_DESIGN_DOC_MEDIA_TYPE;
if is_ui_design {
return Ok(render_ui_design_reference_line(
root,
manifest,
asset,
&resource_id,
&label,
&local_path,
&source,
));
}
Ok(format!(
"- 素材 ID{resource_id};名称:{label};类型:{};媒体类型:{};项目路径:{local_path};来源:{source}",
asset.kind, asset.media_type
@@ -279,7 +234,7 @@ pub(crate) fn render_direct_codex_references_section(
for reference in references {
lines.push(match reference {
DirectCodexTurnReference::Resource(reference) => {
render_resource_reference_line(root, &manifest, reference)?
render_resource_reference_line(&manifest, reference)?
}
DirectCodexTurnReference::RuntimeRegion(reference) => {
render_runtime_region_reference_line(&manifest, reference)?
File diff suppressed because it is too large Load Diff
@@ -1074,6 +1074,102 @@ pub(in crate::agent) fn remove_platform_art_generation_runtime_state_at(
}
}
/// 一次性兼容:升级前 standalone 槽身份只由 `{outputPath, requireSlices}` 派生,
/// 同一项目所有图片类生成共用一个槽;升级后槽身份按精确动作派生,路径随之变化。
///
/// 若旧槽路径上的账本仍然属于本次精确动作(`agentId` 与 `actionFingerprint` 都与
/// 当前上下文一致),就在项目写锁内把它迁移到新身份路径:保留原 `idempotencyKey`
/// 与 `operationId`,避免同一精确动作在升级后二次 POST 计费。旧账本属于其他动作时
/// 原样保留(不迁移、不删除、不阻塞),由对应动作自己的请求迁移。
///
/// 返回 `Ok(false)` 表示没有需要迁移的旧账本。任何身份无法安全解释的情形都失败关闭。
pub(super) fn adopt_legacy_standalone_platform_art_generation_runtime_state_at(
root: &Path,
context: &PlatformArtGenerationRuntimeContext,
legacy_run_id: &str,
) -> Result<bool, String> {
if !is_standalone_platform_art_generation_runtime_context(context)
|| legacy_run_id == context.run_id
|| !is_lowercase_sha256(legacy_run_id.strip_prefix("slot-").unwrap_or_default())
{
return Ok(false);
}
// 与账本创建互斥:迁移必须在同一把项目写锁内完成,否则两个调用可能同时把同一份
// 旧账本迁移到新路径,或与新建账本互相覆盖。
let _claim_lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
root,
"canvas.asset_generate.runtime.claim",
)?;
if game_creator_agent_runtime_external_generation_exists(
root,
&context.agent_id,
&context.run_id,
) {
// 新身份账本已经存在:旧账本不属于本次动作的权威状态,保持两边各自的身份。
return Ok(false);
}
let legacy_relative_path =
platform_art_generation_runtime_relative_path(&context.agent_id, legacy_run_id);
let Some(legacy_state) =
read_agent_runtime_json_sidecar_with_max_bytes::<PlatformArtGenerationRuntimeState>(
root,
&legacy_relative_path,
"External Editor 生成账本",
PLATFORM_ART_GENERATION_RUNTIME_MAX_BYTES,
)?
else {
return Ok(false);
};
if legacy_state.agent_id != context.agent_id
|| legacy_state.run_id != legacy_run_id
|| legacy_state.action_fingerprint != context.action_fingerprint
{
// 旧槽里是另一个精确动作的账本:它仍归那个动作所有,本次调用不得消费、改写或删除它。
return Ok(false);
}
let legacy_identity = format!("{}:{legacy_run_id}", context.agent_id);
let legacy_context = PlatformArtGenerationRuntimeContext {
task_id: legacy_identity.clone(),
session_id: legacy_identity.clone(),
run_id: legacy_run_id.to_string(),
action_id: legacy_identity,
..context.clone()
};
let Some(mut migrated) = read_platform_art_generation_runtime_state(root, &legacy_context)?
else {
return Ok(false);
};
migrated.run_id = context.run_id.clone();
migrated.task_id = context.task_id.clone();
migrated.session_id = context.session_id.clone();
migrated.action_id = context.action_id.clone();
migrated.updated_at = unix_timestamp();
write_platform_art_generation_runtime_state(root, &migrated)?;
remove_platform_art_generation_runtime_state_at(root, &context.agent_id, legacy_run_id)?;
Ok(true)
}
#[cfg(test)]
pub(super) fn platform_art_generation_runtime_operation_id_for_test(
state: &PlatformArtGenerationRuntimeState,
) -> Option<&str> {
state.operation_id.as_deref()
}
#[cfg(test)]
pub(super) fn platform_art_generation_runtime_run_id_for_test(
state: &PlatformArtGenerationRuntimeState,
) -> &str {
&state.run_id
}
#[cfg(test)]
pub(super) fn platform_art_generation_runtime_action_fingerprint_for_test(
state: &PlatformArtGenerationRuntimeState,
) -> &str {
&state.action_fingerprint
}
#[cfg(test)]
pub(crate) fn write_platform_art_generation_runtime_accepted_for_test(
root: &Path,
File diff suppressed because it is too large Load Diff
@@ -2144,10 +2144,7 @@ pub(crate) fn create_ui_design_resource(
let next_index = manifest
.assets
.iter()
.filter(|asset| {
asset.kind == crate::ui_editor::persistence::UI_DESIGN_DOC_ASSET_KIND
&& asset.media_type == crate::ui_editor::persistence::UI_DESIGN_DOC_MEDIA_TYPE
})
.filter(|asset| asset.kind == "UI")
.count()
+ 1;
let resource_name = format!("UI 设计 {next_index}");
@@ -2181,8 +2178,8 @@ pub(crate) fn create_ui_design_resource(
let asset = match register_local_asset_at(
root,
&relative_path,
crate::ui_editor::persistence::UI_DESIGN_DOC_ASSET_KIND,
crate::ui_editor::persistence::UI_DESIGN_DOC_MEDIA_TYPE,
"UI",
"application/json",
"generated",
GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Generated,
@@ -27,6 +27,7 @@ fn user_selected_path_grants() -> &'static Mutex<HashMap<String, UserSelectedPat
#[cfg(windows)]
fn normalize_user_selected_path_key(path: &Path) -> Option<String> {
let path = normalize_windows_policy_path(path);
if !path.is_absolute()
|| path
.components()
@@ -42,6 +43,20 @@ fn normalize_user_selected_path_key(path: &Path) -> Option<String> {
)
}
#[cfg(windows)]
fn normalize_windows_policy_path(path: &Path) -> PathBuf {
let value = path.to_string_lossy();
if let Some(rest) = value.strip_prefix(r"\\?\UNC\") {
return PathBuf::from(format!(r"\\{rest}"));
}
PathBuf::from(value.strip_prefix(r"\\?\").unwrap_or(&value))
}
#[cfg(not(windows))]
fn normalize_windows_policy_path(path: &Path) -> PathBuf {
path.to_path_buf()
}
#[cfg(windows)]
pub(crate) fn register_game_creator_user_selected_path(path: &Path, is_directory: bool) {
let Some(key) = normalize_user_selected_path_key(path) else {
@@ -838,6 +853,7 @@ pub(crate) fn validate_game_creator_private_path_ancestors(
/// separate, explicit user-selected scope below covers native picker/project
/// root results, including projects stored outside the current profile.
fn game_creator_private_path_allows_auto_elevation(path: &Path) -> bool {
let path = normalize_windows_policy_path(path);
if !path.is_absolute()
|| path
.components()
@@ -846,7 +862,10 @@ fn game_creator_private_path_allows_auto_elevation(path: &Path) -> bool {
return false;
}
let starts_with_path = |root: &Path| path == root || path.starts_with(root);
let starts_with_path = |root: &Path| {
let root = normalize_windows_policy_path(root);
path == root || path.starts_with(root)
};
if game_creator_runtime_config_dir()
.as_deref()
.is_some_and(starts_with_path)
@@ -1056,6 +1075,7 @@ pub(crate) fn parse_windows_acl_repair_scope(value: &str) -> Result<WindowsAclRe
#[cfg(windows)]
fn game_creator_runtime_config_repair_scope(path: &Path) -> WindowsAclRepairScope {
let path = normalize_windows_policy_path(path);
let is_builtin_root = |root: PathBuf| path == root || path.starts_with(root);
if let Some(home) = std::env::var_os("USERPROFILE")
.or_else(|| std::env::var_os("HOME"))
@@ -4506,6 +4526,23 @@ mod private_path_elevation_policy_tests {
);
}
#[cfg(windows)]
#[test]
fn verbatim_packaged_appdata_path_keeps_managed_repair_scope() {
let root = std::env::var_os("LOCALAPPDATA")
.or_else(|| std::env::var_os("APPDATA"))
.map(PathBuf::from)
.expect("local appdata");
let packaged = root.join("world.genarrative.ai-game-creator");
let verbatim = PathBuf::from(format!(r"\\?\{}", packaged.display()));
assert!(game_creator_private_path_allows_auto_elevation(&verbatim));
assert_eq!(
game_creator_runtime_config_repair_scope(&verbatim),
WindowsAclRepairScope::Managed
);
}
#[cfg(windows)]
#[test]
fn picker_grant_is_required_and_directory_grant_covers_descendants() {
@@ -26,6 +26,12 @@ pub(crate) struct LocalProjectImagePreview {
pub(crate) byte_len: u64,
pub(crate) pixel_width: u32,
pub(crate) pixel_height: u32,
/// 这张图是否**真的**带 alpha 通道,判据见 [`detect_raster_image_has_alpha`]。
///
/// 资源卡只按它决定要不要铺棋盘格底:`data-preview-kind` 只说明「走图片预览分支」,
/// 与这张图有没有透明像素无关 —— 无条件铺底会让「AI 把棋盘格画进像素里」的不透明图
/// 与卡面棋盘格叠成两套,验收时无法区分「真透明底」与「假棋盘格」。
pub(crate) has_alpha: bool,
pub(crate) data_url: String,
}
@@ -102,12 +108,17 @@ pub(crate) fn load_local_project_image_preview_with_cancellation(
false,
)?;
cancellation.check()?;
// 头部级 alpha 判据:只读签名与头部标志(PNG 还会按 chunk 头跳过数据体找 `tRNS`),
// 不做熵解码、不做逐像素扫描,成本不随像素数增长,因此大图与「AI 把棋盘格画进图里」
// 的不透明图都不会因此变慢。
let has_alpha = detect_raster_image_has_alpha(&image.bytes, image.media_type);
Ok(LocalProjectImagePreview {
path: image.relative_path.clone(),
media_type: image.media_type.to_string(),
byte_len: image.byte_len,
pixel_width: image.pixel_width,
pixel_height: image.pixel_height,
has_alpha,
data_url: image.data_url_with_cancellation(cancellation)?,
})
}
@@ -420,6 +431,84 @@ fn detect_raster_image_dimensions(bytes: &[u8], media_type: &str) -> Option<(u32
}
}
/// 头部级 alpha 判据:这张图**有没有 alpha 通道 / 透明像素**,只看签名与头部标志
/// PNG 还会按 chunk 头跳过数据体找 `tRNS`)。
///
/// 为什么必须是头部级而不是像素级:资源卡预览按 8 MiB / 8192 边长 / 3270 万像素上限读取,
/// 逐像素扫描意味着对每张卡都做一次全量 RGBA 解码(真机单栏 51 张、单张均值 591 KB),
/// 成本与「卡面装饰底」的收益完全不成比例;而 alpha 是否存在在容器头部就是确定信息。
///
/// 判据(保守方向一致:判不出就当作不透明,宁可不铺棋盘格):
/// - PNG:颜色类型 4(灰度 + alpha/ 6(真彩 + alpha);0 / 2 / 3 本身没有 alpha 通道,
/// 但可以用 `tRNS` 声明透明色,因此还要在第一个 `IDAT` 之前找一次 `tRNS`
/// - WebP:扩展格式 `VP8X` 的 flags 第 4 位、无损 `VP8L` 位流头的 `alpha_is_used` 位;
/// 简单有损 `VP8 ` 不带 alpha 通道(带 alpha 的有损 WebP 一定走 `VP8X` + `ALPH`);
/// - JPEG:没有 alpha 通道,恒不透明(也绝不为了判 alpha 去扫它的段)。
fn detect_raster_image_has_alpha(bytes: &[u8], media_type: &str) -> bool {
match media_type {
"image/png" => detect_png_has_alpha(bytes),
"image/webp" => detect_webp_has_alpha(bytes),
_ => false,
}
}
fn detect_png_has_alpha(bytes: &[u8]) -> bool {
// 签名 8 字节 + IHDR 长度 4 + "IHDR" 4 + 宽 4 + 高 4 + 位深 1 + 颜色类型 1 = 26。
if bytes.len() < 26 || &bytes[12..16] != b"IHDR" {
return false;
}
if matches!(bytes[25], 4 | 6) {
return true;
}
png_has_transparency_chunk(bytes)
}
/// 按 chunk 头前进并查找 `tRNS`:只读 8 字节 chunk 头并按长度跳过数据体,不做 zlib 解压。
fn png_has_transparency_chunk(bytes: &[u8]) -> bool {
let mut offset = 8usize;
loop {
let Some(header_end) = offset.checked_add(8) else {
return false;
};
if header_end > bytes.len() {
return false;
}
let chunk_type = &bytes[offset + 4..header_end];
// `tRNS` 必须出现在第一个 `IDAT` 之前;碰到 `IDAT` / `IEND` 就没有再往下扫的意义。
if chunk_type == b"tRNS" {
return true;
}
if chunk_type == b"IDAT" || chunk_type == b"IEND" {
return false;
}
let chunk_len =
u32::from_be_bytes(bytes[offset..offset + 4].try_into().unwrap_or([0_u8; 4])) as usize;
let Some(next) = header_end
.checked_add(chunk_len)
.and_then(|value| value.checked_add(4))
else {
return false;
};
if next <= offset || next > bytes.len() {
return false;
}
offset = next;
}
}
fn detect_webp_has_alpha(bytes: &[u8]) -> bool {
if bytes.len() < 16 || &bytes[..4] != b"RIFF" || &bytes[8..12] != b"WEBP" {
return false;
}
match &bytes[12..16] {
// `VP8X` 的 flags 第 4 位(0x10)就是 alpha 标志(第 20 字节)。
b"VP8X" => bytes.get(20).is_some_and(|flags| flags & 0x10 != 0),
// `VP8L` 位流头第 28 位是 `alpha_is_used`,落在第 25 个字节(下标 24)的 0x10 位。
b"VP8L" => bytes.len() >= 25 && bytes[24] & 0x10 != 0,
_ => false,
}
}
#[derive(Clone, Copy)]
enum TiffByteOrder {
LittleEndian,
@@ -745,6 +834,74 @@ mod tests {
.expect("valid 1x1 png")
}
/// PNG 的「签名 + IHDR」头。判据只读这一段的位深 / 颜色类型,因此后续 chunk 由用例自行拼。
fn png_header(color_type: u8) -> Vec<u8> {
png_header_with_size(color_type, 1, 1)
}
fn png_header_with_size(color_type: u8, width: u32, height: u32) -> Vec<u8> {
let mut bytes = b"\x89PNG\r\n\x1a\n".to_vec();
let mut ihdr = Vec::new();
ihdr.extend_from_slice(&width.to_be_bytes());
ihdr.extend_from_slice(&height.to_be_bytes());
ihdr.push(8);
ihdr.push(color_type);
ihdr.extend_from_slice(&[0, 0, 0]);
push_png_chunk(&mut bytes, b"IHDR", &ihdr);
bytes
}
/// 追加一个结构合法(长度、类型、CRC 位置正确)但数据体可以是任意字节的 PNG chunk。
/// alpha 判据不消费 CRC,因此这里填零;正因数据体不必是合法 deflate 流,它同时能证明
/// 判据没有解码像素。
fn push_png_chunk(bytes: &mut Vec<u8>, kind: &[u8; 4], data: &[u8]) {
bytes.extend_from_slice(
&u32::try_from(data.len())
.expect("chunk length")
.to_be_bytes(),
);
bytes.extend_from_slice(kind);
bytes.extend_from_slice(data);
bytes.extend_from_slice(&[0, 0, 0, 0]);
}
/// 扩展格式 WebP`VP8X`):`flags` 第 4 位(0x10)是 alpha 标志。
fn webp_vp8x(flags: u8) -> Vec<u8> {
let mut bytes = b"RIFF".to_vec();
bytes.extend_from_slice(&0_u32.to_le_bytes());
bytes.extend_from_slice(b"WEBP");
bytes.extend_from_slice(b"VP8X");
bytes.extend_from_slice(&10_u32.to_le_bytes());
bytes.push(flags);
bytes.extend_from_slice(&[0, 0, 0]);
bytes.extend_from_slice(&[0, 0, 0]);
bytes.extend_from_slice(&[0, 0, 0]);
bytes
}
/// 无损 WebP`VP8L`):位流头第 28 位是 `alpha_is_used`,落在下标 24 的 0x10 位。
fn webp_vp8l(has_alpha: bool) -> Vec<u8> {
let mut bytes = b"RIFF".to_vec();
bytes.extend_from_slice(&0_u32.to_le_bytes());
bytes.extend_from_slice(b"WEBP");
bytes.extend_from_slice(b"VP8L");
bytes.extend_from_slice(&5_u32.to_le_bytes());
bytes.push(0x2f);
bytes.extend_from_slice(&[0, 0, 0, if has_alpha { 0x10 } else { 0 }]);
bytes
}
/// 简单有损 WebP`VP8 `):容器上没有 alpha 通道;带 alpha 的有损 WebP 一定走
/// `VP8X` 扩展格式(+ `ALPH` chunk)。
fn webp_vp8_simple() -> Vec<u8> {
let mut bytes = b"RIFF".to_vec();
bytes.extend_from_slice(&0_u32.to_le_bytes());
bytes.extend_from_slice(b"WEBP");
bytes.extend_from_slice(b"VP8 ");
bytes.extend_from_slice(&0_u32.to_le_bytes());
bytes
}
fn jpeg_bytes(width: u16, height: u16, app1_payload: Option<&[u8]>) -> Vec<u8> {
let mut bytes = vec![0xff, 0xd8];
if let Some(payload) = app1_payload {
@@ -840,6 +997,138 @@ mod tests {
assert_eq!(preview.media_type, "image/png");
assert_eq!(preview.byte_len, png_bytes().len() as u64);
assert!(preview.data_url.starts_with("data:image/png;base64,"));
// 这份 fixture 是 PNG colorType 4(灰度 + alpha),因此预览必须报「有 alpha」——
// 资源卡据此才铺棋盘格底。
assert!(preview.has_alpha);
}
#[test]
fn png_alpha_follows_color_type_and_transparency_chunk() {
let color_type_alpha = |color_type: u8| {
let mut bytes = png_header(color_type);
push_png_chunk(&mut bytes, b"IDAT", &[0, 0, 0]);
push_png_chunk(&mut bytes, b"IEND", &[]);
detect_raster_image_has_alpha(&bytes, "image/png")
};
// 颜色类型 4(灰度 + alpha)与 6(真彩 + alpha)才带 alpha 通道。
assert!(color_type_alpha(4), "colorType 4 应判为有 alpha");
assert!(color_type_alpha(6), "PNG-32colorType 6)应判为有 alpha");
// 0 / 2 / 3 本身没有 alpha 通道:这是「AI 把棋盘格画进像素里」那张不透明 PNG 的形状。
assert!(!color_type_alpha(0), "colorType 0 不应判为有 alpha");
assert!(
!color_type_alpha(2),
"PNG-24colorType 2)不应判为有 alpha"
);
assert!(
!color_type_alpha(3),
"colorType 3 无 tRNS 时不应判为有 alpha"
);
// 未定义的颜色类型失败关闭为「不透明」,不能把坏文件当成透明。
assert!(!color_type_alpha(7), "未定义 colorType 不应判为有 alpha");
// 灰度 / 真彩 / 调色板可以靠 tRNS 声明透明色,那也是真透明 PNG,必须铺棋盘格。
for color_type in [0_u8, 2, 3] {
let mut bytes = png_header(color_type);
push_png_chunk(&mut bytes, b"tRNS", &[0]);
push_png_chunk(&mut bytes, b"IDAT", &[0, 0, 0]);
push_png_chunk(&mut bytes, b"IEND", &[]);
assert!(
detect_raster_image_has_alpha(&bytes, "image/png"),
"colorType {color_type} + tRNS 也是真透明 PNG"
);
}
// tRNS 规范上必须在 IDAT 之前:出现在之后不再继续扫 chunk(成本有界)。
let mut late_trns = png_header(3);
push_png_chunk(&mut late_trns, b"IDAT", &[0, 0, 0]);
push_png_chunk(&mut late_trns, b"tRNS", &[0]);
push_png_chunk(&mut late_trns, b"IEND", &[]);
assert!(!detect_raster_image_has_alpha(&late_trns, "image/png"));
}
#[test]
fn jpeg_and_webp_alpha_follow_container_flags() {
// JPEG 没有 alpha 通道:恒不透明(也绝不为了判 alpha 去解码扫描段)。
assert!(!detect_raster_image_has_alpha(
&jpeg_bytes(40, 20, None),
"image/jpeg"
));
// 扩展格式 VP8X 的 flags 第 4 位就是 alpha 标志。
assert!(detect_raster_image_has_alpha(
&webp_vp8x(0x10),
"image/webp"
));
assert!(!detect_raster_image_has_alpha(
&webp_vp8x(0x00),
"image/webp"
));
// 只有 ICC0x20/ EXIF0x08)等其它标志时不是 alpha。
assert!(!detect_raster_image_has_alpha(
&webp_vp8x(0x28),
"image/webp"
));
// 无损 VP8L 的 alpha_is_used 位。
assert!(detect_raster_image_has_alpha(
&webp_vp8l(true),
"image/webp"
));
assert!(!detect_raster_image_has_alpha(
&webp_vp8l(false),
"image/webp"
));
// 简单有损格式不带 alpha 通道。
assert!(!detect_raster_image_has_alpha(
&webp_vp8_simple(),
"image/webp"
));
// 头部被截断时失败关闭为「不透明」,且不得 panic。
let truncated_webp = webp_vp8x(0x10);
assert!(!detect_raster_image_has_alpha(
&truncated_webp[..18],
"image/webp"
));
let truncated_png = png_header(6);
assert!(!detect_raster_image_has_alpha(
&truncated_png[..20],
"image/png"
));
}
#[test]
fn alpha_judgement_never_decodes_pixels() {
// 4096×4096 的 PNG-32:真按像素解码要 64 MiB 缓冲,而下面的 IDAT 数据体不是合法
// deflate 流(全零),任何真正的解码器都会失败。判据只看头部,所以这里必须成功,
// 并且仍然判 has_alpha=true —— 这就是「不做全量解码」的可执行证据。
let root = tempfile::tempdir().expect("temp root");
fs::create_dir_all(root.path().join("assets/ui")).expect("asset dir");
let mut bytes = png_header_with_size(6, 4_096, 4_096);
push_png_chunk(&mut bytes, b"IDAT", &[0x00, 0x00, 0x00, 0x00]);
push_png_chunk(&mut bytes, b"IEND", &[]);
fs::write(root.path().join("assets/ui/large.png"), &bytes).expect("large image");
let preview = load_local_project_image_preview(root.path(), "assets/ui/large.png")
.expect("header-only preview");
assert_eq!(preview.pixel_width, 4_096);
assert_eq!(preview.byte_len, bytes.len() as u64);
assert!(preview.has_alpha);
}
#[test]
fn image_preview_serializes_alpha_flag_for_the_shell() {
let root = tempfile::tempdir().expect("temp root");
fs::create_dir_all(root.path().join("assets/ui")).expect("asset dir");
fs::write(root.path().join("assets/ui/prototype.png"), png_bytes()).expect("image");
let preview = load_local_project_image_preview(root.path(), "assets/ui/prototype.png")
.expect("load project preview");
// 前端按 camelCase 读 `hasAlpha``ProjectResourceCardPreviewTransportPayload`);
// 字段名或大小写改了会让资源卡永远退回纯色底,所以这里钉住 IPC 契约。
let serialized = serde_json::to_value(&preview).expect("serialize preview");
assert_eq!(serialized["hasAlpha"], serde_json::json!(true));
}
#[test]
@@ -244,6 +244,7 @@ macro_rules! app_log {
// 用 #[cfg] 编译期门控:仅开发(debug)且非测试构建编入;生产 release 与 cargo test 下整体剔除。
mod agent;
mod agent_native_tools;
mod asset_generation_tasks;
mod assets;
mod browser;
mod builtin_plugins;
@@ -289,6 +290,7 @@ mod windows;
use agent::*;
use agent_native_tools::*;
use asset_generation_tasks::*;
use assets::*;
use browser::*;
use cli::*;
@@ -2736,6 +2738,8 @@ fn main() {
ensure_ui_design_resource_for_prototype,
generate_platform_art_asset,
generate_local_project_asset,
start_local_project_asset_generation,
list_local_project_asset_generations,
open_canvas_project,
get_game_creation_agent_capabilities,
get_limited_local_commands,
@@ -2031,8 +2031,8 @@ fn register_local_asset_keeps_explicit_category_when_kind_is_unchanged() {
let registered = register_local_asset_at(
&root,
"ui/UI 设计 1.json",
shared_contracts::game_creation_app::GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND,
shared_contracts::game_creation_app::GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE,
"UI",
"application/json",
"ui-workflow",
source(),
)
@@ -2053,8 +2053,8 @@ fn register_local_asset_keeps_explicit_category_when_kind_is_unchanged() {
register_local_asset_at(
&root,
"ui/UI 设计 1.json",
shared_contracts::game_creation_app::GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND,
shared_contracts::game_creation_app::GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE,
"UI",
"application/json",
"ui-workflow",
source(),
)
@@ -2063,10 +2063,7 @@ fn register_local_asset_keeps_explicit_category_when_kind_is_unchanged() {
let manifest: Value =
serde_json::from_str(&fs::read_to_string(root.join(".agent/manifest.json")).unwrap())
.expect("manifest json");
assert_eq!(
manifest["assets"][0]["kind"],
shared_contracts::game_creation_app::GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND
);
assert_eq!(manifest["assets"][0]["kind"], "UI");
assert_eq!(manifest["assets"][0]["category"], "audio");
assert_eq!(manifest["assets"][0]["tags"], serde_json::json!(["界面"]));
@@ -2077,7 +2074,7 @@ fn register_local_asset_keeps_explicit_category_when_kind_is_unchanged() {
///
/// 这里的字面量与写入侧逐字一致:UI 设计资产是
/// `ui_editor/resource_bridge.rs` / `workflow.rs` / `persistence.rs` 的
/// `register_local_asset_at` 使用 UI 文档 kind/media 常量
/// `register_local_asset_at(root, path, "UI", "application/json", ...)`
/// 字体是 `commands.rs` 字体上传的 `register_local_asset_entry(root, path, "font", ...)`。
/// 只要别名表漏掉它们,真机资产就会永远停在「待归类」且读时自愈也救不回来。
#[test]
@@ -2102,8 +2099,8 @@ fn register_local_asset_derives_category_from_real_write_side_kinds() {
register_local_asset_at(
&root,
"ui/UI 设计 1.json",
shared_contracts::game_creation_app::GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND,
shared_contracts::game_creation_app::GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE,
"UI",
"application/json",
"ui-workflow",
source(),
)
@@ -2136,11 +2133,7 @@ fn register_local_asset_derives_category_from_real_write_side_kinds() {
assert_eq!(
categories,
vec![
(
shared_contracts::game_creation_app::GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND
.to_string(),
"ui-interaction".to_string(),
),
("UI".to_string(), "ui-interaction".to_string()),
("font".to_string(), "document".to_string()),
]
);
@@ -19,10 +19,6 @@ use std::time::{SystemTime, UNIX_EPOCH};
use typed_floats::tf32::StrictlyPositiveFinite;
const UI_DESIGN_STATE_SCHEMA_VERSION: &str = "game-creator-ui-design-state.v1";
pub(crate) use shared_contracts::game_creation_app::{
GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND as UI_DESIGN_DOC_ASSET_KIND,
GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE as UI_DESIGN_DOC_MEDIA_TYPE,
};
const UI_DESIGN_STATE_MAX_BYTES: usize = 2 * 1024 * 1024;
const UI_DESIGN_CODE_MAX_BYTES: usize = UI_DESIGN_STATE_MAX_BYTES * 8;
const UI_DESIGN_STATE_MAX_IMAGES: usize = 4;
@@ -325,7 +321,7 @@ fn ui_design_asset(
.into_iter()
.find(|asset| asset.id == asset_id)
.ok_or_else(|| "UI 设计资源不存在".to_string())?;
if asset.kind != UI_DESIGN_DOC_ASSET_KIND || asset.media_type != UI_DESIGN_DOC_MEDIA_TYPE {
if asset.kind != "UI" || asset.media_type != "application/json" {
return Err("目标资源不是 UI 设计 JSON 资产".to_string());
}
normalize_relative_path(&asset.local_path)?;
@@ -819,8 +815,8 @@ mod tests {
let asset = register_local_asset_at(
directory.path(),
relative_path,
UI_DESIGN_DOC_ASSET_KIND,
UI_DESIGN_DOC_MEDIA_TYPE,
"UI",
"application/json",
"test",
GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Generated,
@@ -1,7 +1,4 @@
use crate::ui_editor::persistence::{
initialize_ui_design_state_with_source_image_at, UI_DESIGN_DOC_ASSET_KIND,
UI_DESIGN_DOC_MEDIA_TYPE,
};
use crate::ui_editor::persistence::initialize_ui_design_state_with_source_image_at;
use crate::{
acquire_project_write_lock, advance_agent_runtime_project_revision_locked,
enforce_project_permission_policy, read_existing_manifest_for_project,
@@ -92,8 +89,8 @@ pub(crate) fn ensure_ui_design_resource_for_prototype(
}
if let Some(asset) = manifest.assets.iter().find(|asset| {
asset.kind == UI_DESIGN_DOC_ASSET_KIND
&& asset.media_type == UI_DESIGN_DOC_MEDIA_TYPE
asset.kind == "UI"
&& asset.media_type == "application/json"
&& asset.source.reference_resource_ids.iter().any(|reference| {
source_reference_ids
.iter()
@@ -121,8 +118,8 @@ pub(crate) fn ensure_ui_design_resource_for_prototype(
let asset = match register_local_asset_at(
root,
&relative_path,
UI_DESIGN_DOC_ASSET_KIND,
UI_DESIGN_DOC_MEDIA_TYPE,
"UI",
"application/json",
"generated",
GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Generated,
@@ -196,9 +193,7 @@ fn next_ui_design_path(
let mut index = manifest
.assets
.iter()
.filter(|asset| {
asset.kind == UI_DESIGN_DOC_ASSET_KIND && asset.media_type == UI_DESIGN_DOC_MEDIA_TYPE
})
.filter(|asset| asset.kind == "UI")
.count()
+ 1;
loop {
@@ -286,9 +281,11 @@ mod tests {
};
let first = ensure_ui_design_resource_for_prototype(input.clone()).expect("bridge");
assert!(first.created);
assert_eq!(first.asset.kind, UI_DESIGN_DOC_ASSET_KIND);
assert_eq!(first.asset.kind, "UI");
// 写侧 → 分类的端到端断言:这条路径走的是与
// 写侧与 persistence/workflow 共用 UI 文档 kind/media 常量
// `workflow.rs` / `persistence.rs` 完全相同的 `register_local_asset_at(..., "UI", ...)`
// 别名表漏掉大写 `UI` 时,这里会落 unclassified(真机 8 条 UI 资产的表现),
// 且派生值本身就是 unclassified,读时自愈也救不回来。
assert_eq!(
first.asset.category,
GameCreationAppAssetCategory::UiInteraction
@@ -325,10 +322,7 @@ mod tests {
.manifest
.assets
.iter()
.filter(|asset| {
asset.kind == UI_DESIGN_DOC_ASSET_KIND
&& asset.media_type == UI_DESIGN_DOC_MEDIA_TYPE
})
.filter(|asset| asset.kind == "UI")
.count(),
1
);
@@ -7,7 +7,6 @@ use crate::ui_editor::layout::node::{Node, StageStatus};
use crate::ui_editor::persistence::{
initialize_ui_design_state_at, load_ui_design_state_at, save_ui_design_state_at,
LoadUiDesignStateInput, SaveUiDesignStateInput, SaveUiDesignStateResult,
UI_DESIGN_DOC_ASSET_KIND, UI_DESIGN_DOC_MEDIA_TYPE,
};
use crate::ui_editor::resource::font::FontAsset;
use crate::ui_editor::resource::sprite::{SpriteAsset, SpriteAssetMetadata, SpriteBorder};
@@ -684,8 +683,8 @@ fn find_page_ui_resource(
let Some(asset) = matches.into_iter().next() else {
return Ok(None);
};
if asset.kind != UI_DESIGN_DOC_ASSET_KIND
|| asset.media_type != UI_DESIGN_DOC_MEDIA_TYPE
if asset.kind != "UI"
|| asset.media_type != "application/json"
|| asset.local_path != workflow_relative_path(source, &page.page_id)
{
return Err(format!("页面 {} 的 UI workflow 资源身份冲突", page.page_id));
@@ -749,8 +748,8 @@ fn ensure_page_ui_resource(
let registered = register_local_asset_at(
root,
&relative_path,
UI_DESIGN_DOC_ASSET_KIND,
UI_DESIGN_DOC_MEDIA_TYPE,
"UI",
"application/json",
"ui-workflow",
GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Generated,
@@ -1180,7 +1179,7 @@ fn update_page_manifest_stage(root: &Path, asset_id: &str, stage: &str) -> Resul
.iter_mut()
.find(|asset| asset.id == asset_id)
.ok_or_else(|| format!("UI workflow 资源 {} 未登记", asset_id))?;
if asset.kind != UI_DESIGN_DOC_ASSET_KIND || asset.media_type != UI_DESIGN_DOC_MEDIA_TYPE {
if asset.kind != "UI" || asset.media_type != "application/json" {
return Err(format!("UI workflow 资源 {} 类型不匹配", asset_id));
}
let next_kind = format!("ui-workflow.{stage}");
@@ -53,7 +53,6 @@ import { createPortal, flushSync } from 'react-dom';
import { PlatformResourceFilterBar } from '../../../../../packages/shared/src/components/PlatformResourceFilterBar';
import { PlatformSegmentedTabs } from '../../../../../packages/shared/src/components/PlatformSegmentedTabs';
import {
GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE,
type GameCreationAppAssetManifestEntry,
gameCreationAppAssetTags,
type GameIterationVersion,
@@ -1254,10 +1253,7 @@ function ResourcePickerThumbnail({
if (mediaType.startsWith('image/')) {
return <Loader2 size={18} className="animate-spin" aria-hidden="true" />;
}
if (
kind === 'ui' ||
mediaType === GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE
) {
if (kind === 'ui' || mediaType === 'application/json') {
return <FileText size={18} aria-hidden="true" />;
}
return <ImageIcon size={18} aria-hidden="true" />;
@@ -0,0 +1,130 @@
/*
* 设置素材类型面板的弹窗骨架纵向单选列表与信息浮层的类型入口
*
* 单独一个文件而不是塞进 styles.css编辑素材标签面板当初同样的理由
* 这份样式只服务本次的素材类型入口与工作台其它区块没有共享选择器独立文件让改动
* 边界更清楚也不会与同一时段其它 Agent styles.css 里的编辑互相踩
*
* 骨架沿用编辑素材标签那套三段式契约`auto / minmax(0, 1fr)`标题常驻
* 中间一行可压缩`max-height` 兜住上界类型面板没有底部按钮所以只有两行
*/
.game-resource-type-dialog {
width: min(480px, 100%);
max-height: min(720px, calc(100dvh - 40px));
grid-template-rows: auto minmax(0, 1fr);
}
/*
* body 分三段提示 / 选项列表 / 错误提示
*
* `min-height: 0` 是网格项能被 `1fr` 压缩的前提**滚动不在这里**滚动权交给选项列表
* 见下否则往下滚时素材名和错误提示会跟着跑掉用户看不到"改的是哪件素材、为什么失败"
*/
.game-resource-type-body {
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
gap: 10px;
min-height: 0;
}
/*
* 类型选项之上的一句短提示只说这一屏要选什么不写规则说明或开发解释
*/
.game-resource-type-hint {
margin: 0;
color: var(--platform-text-base);
font-size: 12px;
}
/*
* 纵向单选列表`role="radiogroup"`**一行一个选项**
*
* 之前 6 项横排在一条里`PlatformSegmentedTabs` 3~6 列网格窄屏上互相叠字读不出来
* 单列网格 + 按行流向是"每项一行、互不重叠"的充分条件只声明一列6 个子元素必然上下排 6
* 不存在两项挤一行的可能选项多时列表自己滚`max-height` + `overflow-y: auto`
* 面板不会被撑高移动端优先360px 宽的窄屏同样是这一套声明没有按宽度改列数的媒体查询
*/
.game-resource-type-options {
display: grid;
grid-template-columns: minmax(0, 1fr);
grid-auto-flow: row;
align-content: start;
gap: 6px;
min-height: 0;
max-height: min(320px, 40dvh);
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-gutter: stable;
}
/*
* 单个选项行复用共享的 `PlatformNavigableListItem` 骨架w-full / flex / text-left /
* 圆角 / 悬停 / 焦点环都由它给这里只补"整行可点 + 明确选中态"的表现
*
* `width/min-width` 显式写出来不依赖共享件里的 Tailwind `w-full`这一行是不是满宽
* 决定了"一项一行"能不能成立不能挂在另一份文件的工具类上
* `min-height: 44px` 是移动端点击热区下限`overflow-wrap` 让长选项名在窄屏换行而不是溢出
*/
.game-resource-type-option {
width: 100%;
min-width: 0;
min-height: 44px;
padding: 8px 12px;
border: 1px solid var(--platform-subpanel-border);
background: rgb(255 255 255 / 62%);
color: var(--platform-text-base);
font-size: 13px;
font-weight: 600;
overflow-wrap: anywhere;
}
.game-resource-type-option:hover:not(:disabled) {
border-color: var(--platform-surface-hover-border);
}
/*
* 选中态完全由 `aria-checked="true"` 驱动视觉与读屏读的是同一个属性不会各说一套
*
* 选择器显式提权到 (0,3,0) 以上共享列表行自带的 `.platform-navigable-list-item:hover:not(:disabled)`
* 也是 (0,3,0)只写 `.game-resource-type-option[aria-checked='true']`(0,2,0)会在悬停时
* 被它的底色顶掉 `:hover:not(:disabled)` 的那条 (0,5,0) 保证选中行悬停时也不变色
*/
.game-resource-type-options .game-resource-type-option[aria-checked='true'],
.game-resource-type-options
.game-resource-type-option[aria-checked='true']:hover:not(:disabled) {
border-color: var(--platform-warm-border);
background: var(--platform-warm-bg);
color: var(--platform-text-strong);
}
.game-resource-type-error {
margin: 0;
color: #b3261e;
font-size: 11px;
}
/*
* 第二入口信息浮层分类行右侧的入口按钮
*
* 放在 `dd` **外面**信息字段的读取口径`dt` / `dd` 文本逐行比对在两处共用
* 把按钮塞进 `dd` 会让分类值变成角色与对象设置这类拼接文案
*/
.game-resource-info-field-action {
align-self: start;
margin-left: auto;
padding: 0 6px;
border: 1px solid var(--platform-subpanel-border);
border-radius: 8px;
background: transparent;
color: var(--platform-text-base);
font-size: 11px;
line-height: 20px;
cursor: pointer;
}
.game-resource-info-field-action:hover,
.game-resource-info-field-action:focus-visible {
border-color: var(--platform-surface-hover-border);
background: var(--platform-warm-bg);
color: var(--platform-text-strong);
}
@@ -22,24 +22,47 @@ export type ResourceCanvasAssetGenerationSubmitInput = {
imageSize: string;
};
export type ResourceCanvasAssetGenerationPanelViewProps = {
action: ResourceCanvasAssetToolAction;
onSubmit: (input: ResourceCanvasAssetGenerationSubmitInput) => Promise<void>;
onClose: () => void;
/** 提交面板的草稿:点击即关闭之后,只有「即时失败」重开时才需要把这份草稿带回来。 */
export type ResourceCanvasAssetGenerationPanelDraft = {
prompt: string;
assetName: string;
aspectRatio: string;
imageSize: string;
};
function assetGenerationErrorMessage(error: unknown) {
if (typeof error === 'string' && error.trim()) return error;
if (error instanceof Error && error.message) return error.message;
return '生成素材失败';
}
export type ResourceCanvasAssetGenerationPanelViewProps = {
action: ResourceCanvasAssetToolAction;
/**
* 稿
*
* 稿宿
*/
draft?: ResourceCanvasAssetGenerationPanelDraft;
/** 上一次即时失败的原因;重开时直接以 `role="alert"` 呈现。 */
error?: string | null;
/**
* ****
*
* 宿
*
*/
onSubmit: (input: ResourceCanvasAssetGenerationSubmitInput) => void;
onClose: () => void;
};
/**
* / / / /
* UI
*
* `ThemedModal`宿 chrome
* 宿 `index.tsx` 稿
* 宿 `index.tsx`
*
* **** IPC
*
* ****
*
* / / IPC 宿
* `draft` `error`
*
* / `ImageCanvasGenerationModel.ts` IPC
* `4:3`
@@ -48,57 +71,52 @@ function assetGenerationErrorMessage(error: unknown) {
*/
export function ResourceCanvasAssetGenerationPanelView({
action,
draft,
error: initialError,
onSubmit,
onClose,
}: ResourceCanvasAssetGenerationPanelViewProps) {
const [prompt, setPrompt] = useState('');
const [assetName, setAssetName] = useState(action.assetName);
const [aspectRatio, setAspectRatio] = useState(action.aspectRatio);
const [imageSize, setImageSize] = useState(action.imageSize);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [prompt, setPrompt] = useState(draft?.prompt ?? '');
const [assetName, setAssetName] = useState(
draft?.assetName ?? action.assetName,
);
const [aspectRatio, setAspectRatio] = useState(
draft?.aspectRatio ?? action.aspectRatio,
);
const [imageSize, setImageSize] = useState(
draft?.imageSize ?? action.imageSize,
);
const [error, setError] = useState<string | null>(initialError ?? null);
// 提示词上限复用资源编辑模型的同一份口径:图片类入口默认 32000,与 Rust
// `LOCAL_PROJECT_ASSET_MAX_PROMPT_CHARS` 一致,不在面板里另抄常量。
const promptMaxLength = resourceEditPromptMaxLength('image-reference');
const canSubmit =
!submitting && prompt.trim().length > 0 && assetName.trim().length > 0;
const canSubmit = prompt.trim().length > 0 && assetName.trim().length > 0;
async function submit(event: FormEvent<HTMLFormElement>) {
function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const normalizedPrompt = prompt.trim();
const normalizedAssetName = assetName.trim();
if (!normalizedPrompt || !normalizedAssetName || submitting) {
if (!normalizedPrompt || !normalizedAssetName) {
return;
}
setSubmitting(true);
setError(null);
try {
await onSubmit({
kind: action.assetKind,
prompt: normalizedPrompt,
assetName: normalizedAssetName,
aspectRatio,
imageSize,
});
} catch (submitError) {
// 成功路径由宿主卸载面板;失败保留草稿,用户可直接用同一份输入重试。
setError(assetGenerationErrorMessage(submitError));
} finally {
setSubmitting(false);
}
// 点击即关闭:不等 IPC、不等排队、不等生成结束。失败要不要把面板带回来由宿主决定
// (只有「从未被后端受理」的即时失败才重开并带回草稿),面板不持有在途状态。
onSubmit({
kind: action.assetKind,
prompt: normalizedPrompt,
assetName: normalizedAssetName,
aspectRatio,
imageSize,
});
onClose();
}
return (
<ThemedModal
open
ariaLabel={action.label}
closeOnBackdrop={!submitting}
closeOnEscape={!submitting}
onClose={() => {
if (!submitting) {
onClose();
}
}}
onClose={onClose}
panelClassName="game-approval-dialog game-resource-generation-dialog"
>
<header>
@@ -108,7 +126,6 @@ export function ResourceCanvasAssetGenerationPanelView({
<button
type="button"
aria-label={`关闭${action.label}`}
disabled={submitting}
onClick={onClose}
>
<X size={16} aria-hidden="true" />
@@ -120,7 +137,6 @@ export function ResourceCanvasAssetGenerationPanelView({
<PlatformTextField
aria-label="素材名称"
maxLength={120}
disabled={submitting}
value={assetName}
onChange={(event) => setAssetName(event.currentTarget.value)}
/>
@@ -132,7 +148,6 @@ export function ResourceCanvasAssetGenerationPanelView({
aria-label="生成提示词"
rows={6}
autoFocus
disabled={submitting}
maxLength={promptMaxLength}
placeholder={action.promptPlaceholder}
value={prompt}
@@ -152,7 +167,6 @@ export function ResourceCanvasAssetGenerationPanelView({
columns="threeToSix"
gap="sm"
size="compact"
disabled={submitting}
onChange={setAspectRatio}
/>
<PlatformSegmentedTabs
@@ -166,7 +180,6 @@ export function ResourceCanvasAssetGenerationPanelView({
columns="three"
gap="sm"
size="compact"
disabled={submitting}
onChange={setImageSize}
/>
</div>
@@ -182,7 +195,6 @@ export function ResourceCanvasAssetGenerationPanelView({
subject={`素材生成提示词(${action.label}`}
editKind="image-reference"
prompt={prompt}
disabled={submitting}
applyPrompt={setPrompt}
/>
{error ? (
@@ -194,14 +206,13 @@ export function ResourceCanvasAssetGenerationPanelView({
<PlatformActionButton
type="button"
tone="secondary"
disabled={submitting}
onClick={onClose}
>
</PlatformActionButton>
<PlatformActionButton type="submit" disabled={!canSubmit}>
<Sparkles size={15} aria-hidden="true" />
{submitting ? '生成中…' : action.label}
{action.label}
</PlatformActionButton>
</div>
</form>
@@ -0,0 +1,263 @@
import './resourceCanvasAssetGenerationTasksSidebar.css';
import { ChevronLeft, ChevronRight, ListChecks, X } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import {
RESOURCE_CANVAS_ASSET_GENERATION_STATUS_LABELS,
resourceCanvasAssetGenerationElapsedLabel,
type ResourceCanvasAssetGenerationTask,
resourceCanvasAssetGenerationTaskElapsedMillis,
resourceCanvasAssetGenerationTaskIsTerminal,
resourceCanvasAssetGenerationTaskTone,
sortResourceCanvasAssetGenerationTasks,
} from './resourceCanvasAssetGenerationTaskModel';
/** 「已完成」分栏的展示上限:触顶后只提示还有多少条,不无限拉长侧栏。 */
export const RESOURCE_CANVAS_ASSET_GENERATION_DONE_SECTION_LIMIT = 20;
export type ResourceCanvasAssetGenerationTasksPanelViewProps = {
tasks: readonly ResourceCanvasAssetGenerationTask[];
/** 侧栏是否展开;折叠时只留贴边把手。 */
open: boolean;
onToggleOpen: () => void;
/** 定位到该任务产出的素材卡(宿主复用既有 `pendingResourceFocusRef` 聚焦链)。 */
onFocusTask: (task: ResourceCanvasAssetGenerationTask) => void;
};
function taskRow(
task: ResourceCanvasAssetGenerationTask,
nowMillis: number,
onFocusTask: (task: ResourceCanvasAssetGenerationTask) => void,
) {
const focusable = task.status === 'completed' && Boolean(task.assetId);
return (
<li
key={task.taskId}
className="game-resource-generation-task-card"
data-task-status={task.status}
>
<div className="game-resource-generation-task-card-title-row">
<strong className="game-resource-generation-task-card-name">
{task.assetName}
</strong>
<span className="game-resource-generation-task-card-action">
{task.actionLabel}
</span>
</div>
<div className="game-resource-generation-task-card-meta">
<span
className="game-resource-generation-task-badge"
data-tone={resourceCanvasAssetGenerationTaskTone(task.status)}
>
{RESOURCE_CANVAS_ASSET_GENERATION_STATUS_LABELS[task.status]}
</span>
<span className="game-resource-generation-task-card-phase">
{task.phaseDetail}
</span>
<span className="game-resource-generation-task-card-elapsed">
{`已耗时 ${resourceCanvasAssetGenerationElapsedLabel(
resourceCanvasAssetGenerationTaskElapsedMillis(task, nowMillis),
)}`}
</span>
</div>
{task.error ? (
<p className="game-resource-generation-task-card-error" role="alert">
{task.error}
</p>
) : null}
{focusable ? (
<button
type="button"
className="game-resource-generation-task-locate"
aria-label={`定位素材 ${task.assetName}`}
onClick={() => onFocusTask(task)}
>
</button>
) : null}
</li>
);
}
/**
*
*
* `<aside>`
* ****
* `isResourceCanvasFloatingPanelOpen` / `resourceCanvasHostGenerationPanelOpen`
*
*
* AGC
* ********
* reflow 300px 280340px
*
* CSS
* `--platform-*` token
*/
export function ResourceCanvasAssetGenerationTasksPanelView({
tasks,
open,
onToggleOpen,
onFocusTask,
}: ResourceCanvasAssetGenerationTasksPanelViewProps) {
const [nowMillis, setNowMillis] = useState(() => Date.now());
const inFlightCount = tasks.filter(
(task) => !resourceCanvasAssetGenerationTaskIsTerminal(task),
).length;
const hasLiveTask = inFlightCount > 0;
const ordered = useMemo(
() => sortResourceCanvasAssetGenerationTasks(tasks),
[tasks],
);
const active = ordered.filter(
(task) => !resourceCanvasAssetGenerationTaskIsTerminal(task),
);
const done = ordered.filter((task) =>
resourceCanvasAssetGenerationTaskIsTerminal(task),
);
const visibleDone = done.slice(
0,
RESOURCE_CANVAS_ASSET_GENERATION_DONE_SECTION_LIMIT,
);
// 已耗时是前端计时(后端只给时间戳):只在还有未终态任务时走秒表,全部收口后停掉。
useEffect(() => {
if (!hasLiveTask) {
return undefined;
}
const timer = setInterval(() => setNowMillis(Date.now()), 1_000);
return () => clearInterval(timer);
}, [hasLiveTask]);
if (!open) {
return (
<button
type="button"
className="platform-theme platform-theme--light game-resource-generation-tasks-handle"
aria-label="展开生成任务"
aria-expanded={false}
data-resource-generation-task-count={inFlightCount}
onClick={onToggleOpen}
>
<ListChecks size={15} aria-hidden="true" />
<span className="game-resource-generation-tasks-handle-label">
</span>
{inFlightCount > 0 ? (
<span
className="game-resource-generation-tasks-handle-badge"
aria-label={`在途生成任务 ${inFlightCount}`}
>
{inFlightCount}
</span>
) : null}
<ChevronRight size={13} aria-hidden="true" />
</button>
);
}
return (
<aside
className="platform-theme platform-theme--light game-resource-generation-tasks-sidebar"
role="region"
aria-label="生成任务"
data-resource-generation-task-count={inFlightCount}
>
<header className="game-resource-generation-tasks-sidebar-header">
<h2 className="game-resource-generation-tasks-sidebar-title">
<ListChecks size={15} aria-hidden="true" />
<span
className="game-resource-generation-tasks-sidebar-count"
aria-label={`在途生成任务 ${inFlightCount}`}
>
{inFlightCount}
</span>
</h2>
<button
type="button"
className="game-resource-generation-tasks-sidebar-icon-button"
aria-label="收起生成任务"
onClick={onToggleOpen}
>
<ChevronLeft size={16} aria-hidden="true" />
</button>
</header>
<div
className="game-resource-generation-tasks-scroll"
data-resource-generation-task-scroll=""
>
{ordered.length === 0 ? (
<p className="game-resource-generation-tasks-empty" role="status">
</p>
) : (
<>
<section
className="game-resource-generation-tasks-section"
aria-label="排队与生成中"
>
<h3 className="game-resource-generation-tasks-section-title">
<span>/</span>
<span className="game-resource-generation-tasks-section-title-count">
{active.length}
</span>
</h3>
{active.length === 0 ? (
<p className="game-resource-generation-tasks-empty">
</p>
) : (
<ul className="game-resource-generation-tasks-list">
{active.map((task) => taskRow(task, nowMillis, onFocusTask))}
</ul>
)}
</section>
<section
className="game-resource-generation-tasks-section"
aria-label="已完成"
>
<h3 className="game-resource-generation-tasks-section-title">
<span></span>
<span className="game-resource-generation-tasks-section-title-count">
{done.length}
</span>
</h3>
{done.length === 0 ? (
<p className="game-resource-generation-tasks-empty">
</p>
) : (
<>
<ul className="game-resource-generation-tasks-list">
{visibleDone.map((task) =>
taskRow(task, nowMillis, onFocusTask),
)}
</ul>
{done.length > visibleDone.length ? (
<p className="game-resource-generation-tasks-empty">
{`仅显示最近 ${RESOURCE_CANVAS_ASSET_GENERATION_DONE_SECTION_LIMIT} 条,另有 ${
done.length - visibleDone.length
} `}
</p>
) : null}
</>
)}
</section>
</>
)}
</div>
<footer className="game-resource-generation-tasks-sidebar-footer">
<button
type="button"
className="game-resource-generation-tasks-sidebar-icon-button"
aria-label="关闭生成任务"
onClick={onToggleOpen}
>
<X size={16} aria-hidden="true" />
</button>
</footer>
</aside>
);
}
@@ -58,6 +58,10 @@ function resourceGenerationErrorMessage(error: unknown) {
* `ThemedModal` / 宿 chrome
* 宿 `index.tsx`
* 稿
*
* ****× / / Esc /
* view 宿宿 `await onSubmit(...)`
* ****稿
*/
export function ResourceCanvasGenerationPanelView({
kinds = RESOURCE_CANVAS_GENERATION_OPTIONS.map((option) => option.kind),
@@ -115,8 +119,8 @@ export function ResourceCanvasGenerationPanelView({
} catch (submitError) {
setError(resourceGenerationErrorMessage(submitError));
} finally {
// 成功路径也要收回在飞标记:宿主现在还靠卸载面板兜底,但组件本身不该
// 在 `onSubmit` 正常 resolve 后永久停在「生成中…」并把关闭路径全部锁住
// 成功路径也要收回在飞标记:宿主随后会卸载面板,但组件本身不该在 `onSubmit` 正常
// resolve 后永久停在「生成中…」;失败时收回标记才能让用户用同一份输入重试
setSubmitting(false);
}
}
@@ -125,13 +129,7 @@ export function ResourceCanvasGenerationPanelView({
<ThemedModal
open
ariaLabel={panelTitle}
closeOnBackdrop={!submitting}
closeOnEscape={!submitting}
onClose={() => {
if (!submitting) {
onClose();
}
}}
onClose={onClose}
panelClassName="game-approval-dialog game-resource-generation-dialog"
>
<header>
@@ -141,7 +139,6 @@ export function ResourceCanvasGenerationPanelView({
<button
type="button"
aria-label={`关闭${panelTitle}`}
disabled={submitting}
onClick={onClose}
>
<X size={16} aria-hidden="true" />
@@ -203,10 +200,9 @@ export function ResourceCanvasGenerationPanelView({
<PlatformActionButton
type="button"
tone="secondary"
disabled={submitting}
onClick={onClose}
>
{submitting ? '后台运行并关闭' : '取消'}
</PlatformActionButton>
{error ? (
<PlatformActionButton type="submit" disabled={submitting}>
@@ -0,0 +1,315 @@
import {
applyLocalProjectAssetGenerationRecords,
type LocalProjectAssetGenerationTaskRecord,
mergeLocalProjectAssetGenerationRecord,
nextResourceCanvasAssetGenerationDispatch,
type ResourceCanvasAssetGenerationTask,
} from './resourceCanvasAssetGenerationTaskModel';
export const RESOURCE_CANVAS_ASSET_GENERATION_POLL_INTERVAL_MILLIS = 2_000;
/**
*
*
* 35 ****
*
*/
export const RESOURCE_CANVAS_ASSET_GENERATION_MISSING_RECORD_POLL_LIMIT = 15;
/** 账本读回的形状不合法(`undefined` / 非数组)时的原因文案。 */
export const RESOURCE_CANVAS_ASSET_GENERATION_MALFORMED_READ_ERROR =
'生成任务状态返回值不是数组';
/**
*
*
* `record` IPC
* `null` `error`
*/
export type ResourceCanvasAssetGenerationSettlement = {
taskId: string;
/**
*
*
* 宿
*
*/
projectId: string;
status: 'completed' | 'failed';
record: LocalProjectAssetGenerationTaskRecord | null;
error: string | null;
};
export type ResourceCanvasAssetGenerationQueueDeps = {
invoke(command: string, args: Record<string, unknown>): Promise<unknown>;
/**
*
*
* `draining`
* 宿
*/
projectPath(): string;
/** 提交前的平台会话刷新(与既有生成入口同一条前置动作)。 */
refreshPlatformSession?: <T>(operation: () => Promise<T>) => Promise<T>;
listTasks(): readonly ResourceCanvasAssetGenerationTask[];
replaceTask(task: ResourceCanvasAssetGenerationTask): void;
onSettled(
settlement: ResourceCanvasAssetGenerationSettlement,
): void | Promise<void>;
pollIntervalMillis?: number;
wait?: (millis: number) => Promise<void>;
nowMillis?: () => number;
};
export type ResourceCanvasAssetGenerationQueue = {
/**
*
*
* resolve = reject = 稿
* ** IPC******
* AGC durable prompt /
* + manifest CAS +
*
*/
submit(task: ResourceCanvasAssetGenerationTask): Promise<void>;
/** 推进已有队列(宿主挂载 / 重开项目恢复任务时调一次)。 */
drain(): void;
};
function errorMessage(error: unknown) {
if (typeof error === 'string' && error.trim()) return error;
if (error instanceof Error && error.message) return error.message;
return '生成素材失败';
}
function isTerminalStatus(status: string) {
return status === 'completed' || status === 'failed';
}
type SettlementWaiter = {
resolve: () => void;
reject: (error: Error) => void;
};
/**
* +
*
* **** `submit`
*
* `list_local_project_asset_generations`
*/
export function createResourceCanvasAssetGenerationQueue(
deps: ResourceCanvasAssetGenerationQueueDeps,
): ResourceCanvasAssetGenerationQueue {
const pollIntervalMillis =
deps.pollIntervalMillis ??
RESOURCE_CANVAS_ASSET_GENERATION_POLL_INTERVAL_MILLIS;
const wait =
deps.wait ??
((millis: number) =>
new Promise<void>((resolve) => {
setTimeout(resolve, millis);
}));
const nowMillis = deps.nowMillis ?? (() => Date.now());
const waiters = new Map<string, SettlementWaiter[]>();
let draining = false;
function settleWaiters(settlement: ResourceCanvasAssetGenerationSettlement) {
const pending = waiters.get(settlement.taskId);
if (!pending) {
return;
}
waiters.delete(settlement.taskId);
for (const waiter of pending) {
if (settlement.status === 'completed') {
waiter.resolve();
} else {
waiter.reject(new Error(settlement.error ?? '生成素材失败'));
}
}
}
function replaceFromRecord(
task: ResourceCanvasAssetGenerationTask,
record: LocalProjectAssetGenerationTaskRecord,
) {
deps.replaceTask(mergeLocalProjectAssetGenerationRecord(task, record));
}
async function dispatch(
task: ResourceCanvasAssetGenerationTask,
): Promise<ResourceCanvasAssetGenerationSettlement> {
const projectPath = deps.projectPath();
// 命令名写成字面量:`scripts/check-config.mjs` 的 invoke 门禁按字符串字面量登记调用方,
// 抽成常量会让这两条 IPC 被判成「没有前端调用方」。
const start = async () =>
(await deps.invoke('start_local_project_asset_generation', {
projectPath,
projectId: task.projectId,
taskId: task.taskId,
kind: task.assetKind,
prompt: task.prompt,
aspectRatio: task.aspectRatio,
imageSize: task.imageSize,
assetName: task.assetName,
outputPath: task.outputPath,
})) as LocalProjectAssetGenerationTaskRecord;
let started: LocalProjectAssetGenerationTaskRecord;
try {
started = await (deps.refreshPlatformSession
? deps.refreshPlatformSession(start)
: start());
} catch (error) {
return {
taskId: task.taskId,
projectId: task.projectId,
status: 'failed',
record: null,
error: errorMessage(error),
};
}
replaceFromRecord(task, started);
let missingRecordPolls = 0;
let readFailed = false;
let lastReadError = RESOURCE_CANVAS_ASSET_GENERATION_MALFORMED_READ_ERROR;
for (;;) {
let records: LocalProjectAssetGenerationTaskRecord[];
try {
records = (await deps.invoke('list_local_project_asset_generations', {
projectPath,
})) as LocalProjectAssetGenerationTaskRecord[];
} catch (error) {
// IPC 拒绝(未注册 / 权限拒绝 / 账本读坏):按「本轮读不到」处理,绝不把拒绝往上抛——
// 派发循环是 `void (async …)()`,抛出去就是未处理的 Promise 拒绝。
records = [];
readFailed = true;
lastReadError = errorMessage(error);
}
if (!Array.isArray(records)) {
records = [];
readFailed = true;
lastReadError = RESOURCE_CANVAS_ASSET_GENERATION_MALFORMED_READ_ERROR;
}
const record = records.find((item) => item.taskId === task.taskId);
if (record) {
missingRecordPolls = 0;
readFailed = false;
replaceFromRecord(task, record);
if (isTerminalStatus(record.status)) {
return {
taskId: task.taskId,
projectId: task.projectId,
status: record.status === 'completed' ? 'completed' : 'failed',
record,
error: record.error,
};
}
} else {
missingRecordPolls += 1;
if (
missingRecordPolls >=
RESOURCE_CANVAS_ASSET_GENERATION_MISSING_RECORD_POLL_LIMIT
) {
// 「读不到通道」与「读到数组但没有这条」是两件事,文案必须能区分:前者是账本不可用,
// 后者是记录被账本上限淘汰或项目被换掉。
return {
taskId: task.taskId,
projectId: task.projectId,
status: 'failed',
record: null,
error: readFailed
? `生成任务状态读取失败,已停止等待:${lastReadError}`
: '生成任务账本里已找不到这条任务,已停止等待',
};
}
}
await wait(pollIntervalMillis);
}
}
function markSettled(settlement: ResourceCanvasAssetGenerationSettlement) {
const settled = deps
.listTasks()
.find((task) => task.taskId === settlement.taskId);
if (!settled || isTerminalStatus(settled.status)) {
return;
}
// 连后端都没进去的任务(record === null)也必须收口为终态:留在「已派发但未终态」
// 会让本地队列认为还有在途任务,后面的排队任务永远补发不出去。
deps.replaceTask({
...settled,
status: settlement.status,
phaseDetail:
settlement.record?.phaseDetail ??
`生成失败:${settlement.error ?? '未知原因'}`,
error: settlement.error,
finishedAtMillis: settlement.record?.finishedAtMillis ?? nowMillis(),
});
}
function drain(): void {
if (draining) {
return;
}
draining = true;
void (async () => {
try {
for (;;) {
const next = nextResourceCanvasAssetGenerationDispatch(
deps.listTasks(),
);
if (!next) {
return;
}
deps.replaceTask({ ...next, dispatched: true });
let settlement: ResourceCanvasAssetGenerationSettlement;
try {
settlement = await dispatch(next);
} catch (error) {
// `dispatch` 已经把可预期的失败(提交失败 / 账本读失败 / 记录丢失)收口成
// settlement;这里是最后一道兜底:任何意外抛出都不能变成未处理的 Promise 拒绝,
// 也不能让这条任务永远停在「已派发但未终态」把后面的排队任务卡死。
settlement = {
taskId: next.taskId,
projectId: next.projectId,
status: 'failed',
record: null,
error: errorMessage(error),
};
}
markSettled(settlement);
settleWaiters(settlement);
try {
await deps.onSettled(settlement);
} catch {
// 宿主收尾(配对读清单 / 提示条)失败不改变任务终局,也不能打断队列:
// 任务本身的状态已经写进列表并通知了等待者。
}
}
} finally {
draining = false;
}
})();
}
return {
submit(task) {
deps.replaceTask(task);
const promise = new Promise<void>((resolve, reject) => {
const pending = waiters.get(task.taskId) ?? [];
waiters.set(task.taskId, [...pending, { resolve, reject }]);
});
drain();
return promise;
},
drain,
};
}
/** 用后端记录刷新整个任务列表(重开项目后恢复历史任务时用)。 */
export function mergeResourceCanvasAssetGenerationTasksWithRecords(
tasks: readonly ResourceCanvasAssetGenerationTask[],
records: readonly LocalProjectAssetGenerationTaskRecord[],
): ResourceCanvasAssetGenerationTask[] {
return applyLocalProjectAssetGenerationRecords(tasks, records);
}
@@ -0,0 +1,328 @@
import {
resolveResourceCanvasBottomTools,
type ResourceCanvasAssetToolAction,
resourceCanvasBottomToolActions,
} from './resourceCanvasBottomToolbarModel';
/** 一条生成任务在宿主里的状态。`queued` 包含「本地排队」和「后端排队」两种来源。 */
export type ResourceCanvasAssetGenerationTaskStatus =
| 'queued'
| 'running'
| 'completed'
| 'failed';
/**
* Rust `list_local_project_asset_generations`
*
* `phaseDetail` ****
*/
export type LocalProjectAssetGenerationTaskRecord = {
taskId: string;
projectId: string;
kind: string;
assetName: string;
status: string;
phaseDetail: string;
createdAtMillis: number;
startedAtMillis: number | null;
finishedAtMillis: number | null;
assetId: string | null;
error: string | null;
};
/** 宿主「生成任务」列表里的一条:本地排队信息 + 后端账本记录。 */
export type ResourceCanvasAssetGenerationTask = {
/** 本地任务 id,同时作为提交给后端的 taskId(重开项目后靠它对上账本记录)。 */
taskId: string;
actionId: string;
actionLabel: string;
assetKind: string;
assetName: string;
prompt: string;
aspectRatio: string;
imageSize: string;
outputPath: string | null;
projectId: string;
/** 是否已经把这次提交交给后端。未派发的任务只活在本地队列里。 */
dispatched: boolean;
status: ResourceCanvasAssetGenerationTaskStatus;
phaseDetail: string;
createdAtMillis: number;
startedAtMillis: number | null;
finishedAtMillis: number | null;
assetId: string | null;
error: string | null;
/** 从账本恢复出来的历史任务(本地没有对应的草稿)。 */
restored: boolean;
};
export const RESOURCE_CANVAS_ASSET_GENERATION_STATUS_LABELS: Record<
ResourceCanvasAssetGenerationTaskStatus,
string
> = {
queued: '排队中',
running: '生成中',
completed: '已完成',
failed: '失败',
};
/**
* tone / / /
*
*
* `react-refresh/only-export-components`
* `resourceCanvasAssetGenerationTasksSidebar.css` `[data-tone=…]`
*/
export type ResourceCanvasAssetGenerationTaskTone =
| 'queued'
| 'running'
| 'completed'
| 'failed';
export function resourceCanvasAssetGenerationTaskTone(
status: ResourceCanvasAssetGenerationTaskStatus,
): ResourceCanvasAssetGenerationTaskTone {
return status;
}
/**
*
*
* ****
* IPC `nextResourceCanvasAssetGenerationDispatch`
* `phaseDetail`
*/
export const RESOURCE_CANVAS_ASSET_GENERATION_LOCAL_QUEUE_PHASE = '排队中。';
const RESOURCE_CANVAS_ASSET_GENERATION_CATEGORIES = [
'ui-interaction',
'character',
'scene',
] as const;
/**
* kind
*
* kind `resourceCanvasBottomToolbarModel`
* kind kind
*/
export function resourceCanvasAssetGenerationKindLabel(
kind: string,
): string | null {
for (const category of RESOURCE_CANVAS_ASSET_GENERATION_CATEGORIES) {
for (const tool of resolveResourceCanvasBottomTools(category)) {
for (const action of resourceCanvasBottomToolActions(tool)) {
if (action.route === 'asset' && action.assetKind === kind) {
return action.label;
}
}
}
}
return null;
}
export function resolveResourceCanvasAssetGenerationTaskStatus(
status: string,
): ResourceCanvasAssetGenerationTaskStatus {
switch (status) {
case 'queued':
case 'running':
case 'completed':
return status;
default:
return 'failed';
}
}
export function resourceCanvasAssetGenerationTaskIsTerminal(
task: Pick<ResourceCanvasAssetGenerationTask, 'status'>,
): boolean {
return task.status === 'completed' || task.status === 'failed';
}
/** 新提交的任务:先本地排队,派发之前不进后端账本。 */
export function createResourceCanvasAssetGenerationTask(input: {
taskId: string;
action: ResourceCanvasAssetToolAction;
prompt: string;
assetName: string;
aspectRatio: string;
imageSize: string;
outputPath: string | null;
projectId: string;
nowMillis: number;
}): ResourceCanvasAssetGenerationTask {
return {
taskId: input.taskId,
actionId: input.action.id,
actionLabel: input.action.label,
assetKind: input.action.assetKind,
assetName: input.assetName,
prompt: input.prompt,
aspectRatio: input.aspectRatio,
imageSize: input.imageSize,
outputPath: input.outputPath,
projectId: input.projectId,
dispatched: false,
status: 'queued',
phaseDetail: RESOURCE_CANVAS_ASSET_GENERATION_LOCAL_QUEUE_PHASE,
createdAtMillis: input.nowMillis,
startedAtMillis: null,
finishedAtMillis: null,
assetId: null,
error: null,
restored: false,
};
}
const RESOURCE_CANVAS_ASSET_GENERATION_TASK_LIMIT = 50;
/** 账本记录 → 任务列表里的一条(重开项目后恢复显示)。 */
export function restoreResourceCanvasAssetGenerationTask(
record: LocalProjectAssetGenerationTaskRecord,
): ResourceCanvasAssetGenerationTask {
return {
taskId: record.taskId,
actionId: `restored:${record.kind}`,
actionLabel:
resourceCanvasAssetGenerationKindLabel(record.kind) ?? record.assetName,
assetKind: record.kind,
assetName: record.assetName,
prompt: '',
aspectRatio: '',
imageSize: '',
outputPath: null,
projectId: record.projectId,
dispatched: true,
status: resolveResourceCanvasAssetGenerationTaskStatus(record.status),
phaseDetail: record.phaseDetail,
createdAtMillis: record.createdAtMillis,
startedAtMillis: record.startedAtMillis,
finishedAtMillis: record.finishedAtMillis,
assetId: record.assetId,
error: record.error,
restored: true,
};
}
/**
* +
*
*
*
*
*
* `records` ****宿 IPC
* `undefined`
* effect Promise
* `null` / / `taskId`
*/
export function applyLocalProjectAssetGenerationRecords(
tasks: readonly ResourceCanvasAssetGenerationTask[],
records: readonly LocalProjectAssetGenerationTaskRecord[] | null | undefined,
): ResourceCanvasAssetGenerationTask[] {
const safeRecords = Array.isArray(records)
? records.filter(
(record): record is LocalProjectAssetGenerationTaskRecord =>
typeof record === 'object' &&
record !== null &&
typeof (record as LocalProjectAssetGenerationTaskRecord).taskId ===
'string' &&
(record as LocalProjectAssetGenerationTaskRecord).taskId.trim()
.length > 0,
)
: [];
const recordsByTaskId = new Map(
safeRecords.map((record) => [record.taskId, record]),
);
const merged = tasks.map((task) => {
const record = recordsByTaskId.get(task.taskId);
return record ? mergeLocalProjectAssetGenerationRecord(task, record) : task;
});
const knownTaskIds = new Set(tasks.map((task) => task.taskId));
const restored = safeRecords
.filter((record) => !knownTaskIds.has(record.taskId))
.map(restoreResourceCanvasAssetGenerationTask);
if (restored.length === 0) {
return merged;
}
return [...merged, ...restored].slice(
-RESOURCE_CANVAS_ASSET_GENERATION_TASK_LIMIT,
);
}
/** 用后端记录刷新一条本地任务:状态、阶段、时间戳、资源 id 与失败原因都以后端为准。 */
export function mergeLocalProjectAssetGenerationRecord(
task: ResourceCanvasAssetGenerationTask,
record: LocalProjectAssetGenerationTaskRecord,
): ResourceCanvasAssetGenerationTask {
return {
...task,
dispatched: true,
status: resolveResourceCanvasAssetGenerationTaskStatus(record.status),
phaseDetail: record.phaseDetail,
startedAtMillis: record.startedAtMillis ?? task.startedAtMillis,
finishedAtMillis: record.finishedAtMillis ?? task.finishedAtMillis,
assetId: record.assetId ?? task.assetId,
error: record.error ?? task.error,
};
}
/**
* ****
*
* AGC durable **** prompt /
* ****
* + manifest CAS +
*
* `dispatched && 未终态` `status === 'running'`
* `queued`
*/
export function nextResourceCanvasAssetGenerationDispatch(
tasks: readonly ResourceCanvasAssetGenerationTask[],
): ResourceCanvasAssetGenerationTask | null {
const inFlight = tasks.some(
(task) =>
task.dispatched && !resourceCanvasAssetGenerationTaskIsTerminal(task),
);
if (inFlight) {
return null;
}
return (
tasks.find((task) => !task.dispatched && task.status === 'queued') ?? null
);
}
/** 面板展示顺序:新提交的在上。 */
export function sortResourceCanvasAssetGenerationTasks(
tasks: readonly ResourceCanvasAssetGenerationTask[],
): ResourceCanvasAssetGenerationTask[] {
return [...tasks].sort(
(left, right) =>
right.createdAtMillis - left.createdAtMillis ||
left.taskId.localeCompare(right.taskId),
);
}
/** 已耗时文案:排队中按创建时间算,已结束按结束时间算。 */
export function resourceCanvasAssetGenerationElapsedLabel(
elapsedMillis: number,
): string {
const totalSeconds = Math.max(0, Math.floor(elapsedMillis / 1000));
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return minutes > 0 ? `${minutes}${seconds}` : `${seconds}`;
}
export function resourceCanvasAssetGenerationTaskElapsedMillis(
task: Pick<
ResourceCanvasAssetGenerationTask,
'createdAtMillis' | 'finishedAtMillis'
>,
nowMillis: number,
): number {
return Math.max(
0,
(task.finishedAtMillis ?? nowMillis) - task.createdAtMillis,
);
}
@@ -0,0 +1,435 @@
/* 生成任务侧栏的样式
*
* 分层与网页端美术画布的任务侧栏`ImageCanvasTaskSidebarView.tsx` + `src/index.css:6084+`一一对应
* 容器 头部标题 + 在途计数 分栏标题各带条数 条目卡片状态徽标 / 阶段 / 耗时 / 定位
* 差别只有一处**颜色全部换成 AGC 的平台设计 token`--platform-*`**不再照抄网页端的固定色值
* 这样亮/暗主题都跟着 `platform-theme` 也不引入第二套色板
*
* 行为分栏条数已完成封顶折叠不清列表提交后自动展开都不在这里样式只负责表现
*/
.game-resource-generation-tasks-sidebar {
position: fixed;
top: 4rem;
bottom: 6rem;
left: 0.75rem;
z-index: 40;
display: flex;
width: min(300px, 80vw);
flex-direction: column;
overflow: hidden;
border: 1px solid var(--platform-subpanel-border);
border-radius: 0.75rem;
background: var(--platform-subpanel-fill);
color: var(--platform-text-strong);
box-shadow: var(--platform-panel-shadow);
backdrop-filter: blur(10px);
animation: game-resource-generation-tasks-enter 160ms ease-out;
}
/* 提交后面板会重新挂载,动画只负责「进场」这一下;不做宽度 reflow,避免画布抖动。 */
@keyframes game-resource-generation-tasks-enter {
from {
opacity: 0;
transform: translateX(-0.5rem);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.game-resource-generation-tasks-sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
border-bottom: 1px solid var(--platform-line-soft);
padding: 0.68rem 0.78rem;
}
.game-resource-generation-tasks-sidebar-title {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.8rem;
font-weight: 850;
}
.game-resource-generation-tasks-sidebar-count {
display: inline-flex;
min-width: 1.35rem;
height: 1.35rem;
align-items: center;
justify-content: center;
border: 1px solid var(--platform-neutral-border);
border-radius: 999px;
background: var(--platform-neutral-bg);
color: var(--platform-neutral-text);
font-size: 0.72rem;
font-variant-numeric: tabular-nums;
font-weight: 850;
}
.game-resource-generation-tasks-sidebar-icon-button {
display: grid;
width: 1.75rem;
height: 1.75rem;
place-items: center;
border: 1px solid transparent;
border-radius: 999px;
background: transparent;
color: var(--platform-button-ghost-text);
transition:
background 120ms ease,
color 120ms ease,
border-color 120ms ease;
}
.game-resource-generation-tasks-sidebar-icon-button:hover {
border-color: var(--platform-subpanel-border);
background: var(--platform-nav-item-hover-fill);
color: var(--platform-text-strong);
}
.game-resource-generation-tasks-sidebar-icon-button:focus-visible,
.game-resource-generation-tasks-handle:focus-visible,
.game-resource-generation-task-locate:focus-visible {
outline: 2px solid var(--platform-accent);
outline-offset: 2px;
box-shadow: 0 0 0 3px var(--platform-input-focus-ring);
}
.game-resource-generation-tasks-scroll {
min-height: 0;
flex: 1 1 auto;
overflow-y: auto;
overscroll-behavior: contain;
padding: 0.5rem 0.6rem;
scrollbar-color: var(--platform-line-soft) transparent;
scrollbar-width: thin;
}
.game-resource-generation-tasks-scroll::-webkit-scrollbar {
width: 6px;
}
.game-resource-generation-tasks-scroll::-webkit-scrollbar-thumb {
border-radius: 999px;
background: var(--platform-line-soft);
}
.game-resource-generation-tasks-scroll::-webkit-scrollbar-track {
background: transparent;
}
.game-resource-generation-tasks-empty {
padding: 1rem 0.5rem;
color: var(--platform-text-muted);
font-size: 0.74rem;
font-weight: 750;
text-align: center;
}
.game-resource-generation-tasks-section
+ .game-resource-generation-tasks-section {
margin-top: 0.65rem;
border-top: 1px solid var(--platform-line-soft);
padding-top: 0.6rem;
}
.game-resource-generation-tasks-section-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
color: var(--platform-text-muted);
font-size: 0.72rem;
font-weight: 850;
letter-spacing: 0.02em;
}
.game-resource-generation-tasks-section-title-count {
display: inline-flex;
min-width: 1.05rem;
height: 1.05rem;
align-items: center;
justify-content: center;
border-radius: 999px;
background: var(--platform-neutral-bg);
color: var(--platform-neutral-text);
font-size: 0.68rem;
font-variant-numeric: tabular-nums;
}
.game-resource-generation-tasks-list {
display: grid;
gap: 0.38rem;
margin-top: 0.4rem;
}
.game-resource-generation-task-card {
display: grid;
gap: 0.3rem;
width: 100%;
border: 1px solid var(--platform-subpanel-border);
border-radius: 0.5rem;
background: var(--platform-panel-fill);
padding: 0.5rem 0.55rem;
transition:
border-color 120ms ease,
box-shadow 120ms ease,
transform 120ms ease;
}
.game-resource-generation-task-card:hover {
border-color: var(
--platform-surface-hover-border,
var(--platform-subpanel-border)
);
box-shadow: var(--platform-desktop-hover-shadow);
transform: translateY(-1px);
}
.game-resource-generation-task-card-title-row {
display: grid;
min-width: 0;
grid-template-columns: minmax(0, 1fr) auto;
align-items: start;
gap: 0.45rem;
}
.game-resource-generation-task-card-name {
overflow: hidden;
color: var(--platform-text-strong);
font-size: 0.78rem;
font-weight: 850;
text-overflow: ellipsis;
white-space: nowrap;
}
.game-resource-generation-task-card-action {
overflow: hidden;
color: var(--platform-text-muted);
font-size: 0.68rem;
font-weight: 750;
text-overflow: ellipsis;
white-space: nowrap;
}
.game-resource-generation-task-card-meta {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 0.4rem;
}
.game-resource-generation-task-badge {
display: inline-flex;
align-items: center;
gap: 0.3rem;
border: 1px solid var(--platform-neutral-border);
border-radius: 999px;
background: var(--platform-neutral-bg);
color: var(--platform-neutral-text);
padding: 0.1rem 0.45rem;
font-size: 0.68rem;
font-weight: 800;
white-space: nowrap;
}
.game-resource-generation-task-badge::before {
content: '';
width: 0.375rem;
height: 0.375rem;
border-radius: 999px;
background: currentcolor;
}
/* 四种状态的 tone 映射:中性 / 品牌 / 成功 / 危险,全部走平台 token,不硬编码颜色。 */
.game-resource-generation-task-badge[data-tone='queued'] {
border-color: var(--platform-neutral-border);
background: var(--platform-neutral-bg);
color: var(--platform-neutral-text);
}
.game-resource-generation-task-badge[data-tone='running'] {
border-color: var(--platform-accent);
background: transparent;
color: var(--platform-accent);
}
.game-resource-generation-task-badge[data-tone='completed'] {
border-color: var(--platform-success-border);
background: var(--platform-success-bg);
color: var(--platform-success-text);
}
.game-resource-generation-task-badge[data-tone='failed'] {
border-color: var(--platform-button-danger-border);
background: var(--platform-button-danger-fill);
color: var(--platform-button-danger-text);
}
/* 生成中只给「有在动」的呼吸感,不做百分比 —— 后端没有可播报的百分比。 */
.game-resource-generation-task-badge[data-tone='running']::before {
animation: game-resource-generation-task-pulse 1.2s ease-in-out infinite;
}
@keyframes game-resource-generation-task-pulse {
50% {
opacity: 0.25;
}
}
.game-resource-generation-task-card-phase {
overflow: hidden;
color: var(--platform-text-muted);
font-size: 0.7rem;
line-height: 1.35;
text-overflow: ellipsis;
white-space: nowrap;
}
.game-resource-generation-task-card-elapsed {
color: var(--platform-text-muted);
font-size: 0.68rem;
font-variant-numeric: tabular-nums;
font-weight: 750;
text-align: right;
white-space: nowrap;
}
.game-resource-generation-task-card-error {
color: var(--platform-button-danger-text);
font-size: 0.7rem;
line-height: 1.35;
overflow-wrap: anywhere;
white-space: normal;
}
.game-resource-generation-task-locate {
justify-self: start;
border: 0;
background: transparent;
color: var(--platform-accent);
font-size: 0.7rem;
font-weight: 800;
padding: 0;
text-decoration: underline;
text-underline-offset: 0.16rem;
transition: color 120ms ease;
}
.game-resource-generation-task-locate:hover {
color: var(--platform-text-strong);
}
.game-resource-generation-tasks-sidebar-footer {
display: flex;
align-items: center;
justify-content: flex-end;
border-top: 1px solid var(--platform-line-soft);
padding: 0.4rem 0.6rem;
}
/* 折叠态:贴边竖向把手 + 在途数量角标。 */
.game-resource-generation-tasks-handle {
position: fixed;
top: 50%;
left: 0;
z-index: 40;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.3rem;
border: 1px solid var(--platform-subpanel-border);
border-left: 0;
border-radius: 0 0.75rem 0.75rem 0;
background: var(--platform-subpanel-fill);
color: var(--platform-text-strong);
padding: 0.6rem 0.3rem;
box-shadow: var(--platform-panel-shadow);
transform: translateY(-50%);
transition:
background 120ms ease,
box-shadow 120ms ease,
transform 120ms ease;
animation: game-resource-generation-tasks-handle-enter 160ms ease-out;
}
.game-resource-generation-tasks-handle:hover {
background: var(--platform-nav-item-hover-fill);
box-shadow: var(--platform-desktop-hover-shadow);
transform: translateY(-50%) translateX(0.1rem);
}
@keyframes game-resource-generation-tasks-handle-enter {
from {
opacity: 0;
transform: translateY(-50%) translateX(-0.5rem);
}
to {
opacity: 1;
transform: translateY(-50%) translateX(0);
}
}
.game-resource-generation-tasks-handle-label {
font-size: 0.7rem;
font-weight: 850;
writing-mode: vertical-rl;
}
.game-resource-generation-tasks-handle-badge {
display: inline-flex;
min-width: 1.1rem;
height: 1.1rem;
align-items: center;
justify-content: center;
border: 1px solid var(--platform-accent);
border-radius: 999px;
color: var(--platform-accent);
font-size: 0.68rem;
font-variant-numeric: tabular-nums;
font-weight: 850;
}
/* 窄屏(含 360px):侧栏占满可用宽度,把手只占一条窄边,不挡画布操作。 */
@media (max-width: 480px) {
.game-resource-generation-tasks-sidebar {
top: 3.5rem;
right: 0.5rem;
bottom: 5.5rem;
left: 0.5rem;
width: auto;
}
.game-resource-generation-tasks-handle {
padding: 0.5rem 0.2rem;
}
}
/* 降低动效偏好:进场动画、悬停位移与呼吸全部关掉。 */
@media (prefers-reduced-motion: reduce) {
.game-resource-generation-tasks-sidebar,
.game-resource-generation-tasks-handle {
animation: none;
}
.game-resource-generation-task-card,
.game-resource-generation-tasks-handle,
.game-resource-generation-tasks-sidebar-icon-button,
.game-resource-generation-task-badge::before,
.game-resource-generation-task-locate {
transition: none;
animation: none;
}
.game-resource-generation-task-card:hover,
.game-resource-generation-tasks-handle:hover {
transform: none;
}
}
@@ -21,7 +21,7 @@ import type { ResourceCanvasGenerationKind } from './resourceCanvasGenerationMod
*/
/**
* `generate_local_project_asset`
* `start_local_project_asset_generation`
*
* Rust `PLATFORM_ART_ASSET_GENERATION_KINDS` `publication-material`
* `spec` / `icon-spec` Rust `icon-spec`
@@ -72,7 +72,7 @@ type ResourceCanvasBottomToolActionBase = {
imageSize: string;
};
/** 图片类生成入口:走 `generate_local_project_asset`。 */
/** 图片类生成入口:走 `start_local_project_asset_generation`(提交即返回、后台生成)。 */
export type ResourceCanvasAssetToolAction =
ResourceCanvasBottomToolActionBase & {
route: 'asset';
@@ -19,6 +19,14 @@ export type ResourceInfoFieldRow = {
const EMPTY_TAGS_TEXT = '暂无标签';
/**
*
*
*
*
*/
export const RESOURCE_INFO_CATEGORY_FIELD_LABEL = '分类';
/**
* `version`
*/
@@ -43,7 +51,10 @@ export function resolveResourceInfoFieldRows(
{ label: '名称', value: resource.label },
{ label: '路径', value: resource.path },
{ label: '类型', value: resource.mediaType },
{ label: '分类', value: resourceCategoryLabel(resource.category) },
{
label: RESOURCE_INFO_CATEGORY_FIELD_LABEL,
value: resourceCategoryLabel(resource.category),
},
{
label: '标签',
value: tags.length > 0 ? tags.join('、') : EMPTY_TAGS_TEXT,

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