Compare commits

...

275 Commits

Author SHA1 Message Date
suzmii 5b62d1e4ef 修复资源登记把任意图片写成 ui 类型
Project CI / Repository checks (pull_request) Successful in 3m17s
Project CI / Frontend tests (pull_request) Successful in 3m55s
Project CI / Backend tests (pull_request) Successful in 6m29s
Project CI / Native shell tests (pull_request) Successful in 17m16s
停用本地导入图片的 ui 默认 kind:agent_local_project_file_type 的图片扩展名分支改写 canonical image(派生 unclassified,落「待归类」),并注明不许把 ui 当图片默认值及其不可恢复原因——读时自愈只在落盘 category 为 unclassified 时触发,kind 本身写错时自愈只会把错值放大成 ui-interaction
账户素材导入改用平台真实类型:新增 imported_platform_asset_kind,账户素材库记录自带的 assetKind 不再被常量 ui 顶掉,缺失才退回中性 image
平台素材导入改用响应里的 assetKind:网页项目画布与平台素材导入同样走 imported_platform_asset_kind,不再写死 ui
上传素材按内容证据推导 kind:upload_local_asset_at 不再把来源词 uploaded 当类型,改由 uploaded_asset_kind 按 mediaType 与扩展名推导 audio/video/image/document/code,判不出才用中性 asset;同一命令也收 .wav 与 .md,因此不能一刀切成 image
补 4 处回归与反查用例:本地导入与上传断言落盘 kind 与 category 且不得出现 ui、ui-interaction、uploaded;两处平台导入的 kind 解析加纯函数单测与调用点反查门禁;变异把错值改回来必须变红(M1/M2/M3 实测 4 个用例全红,还原后 diff 与基线逐字节一致)
覆盖边界:账户素材库与平台素材导入是异步 HTTP 路径,仓库内没有可复用的端到端夹具,这两条覆盖是纯函数单测加调用点反查门禁,不是端到端断言;本地导入与上传是读落盘 manifest 的行为级断言
后续事项:ImportedAsset.asset_kind 仍返回平台原始值(缺失为 none),与已落盘 kind 可能不一致,本次未改动
2026-09-12 02:30:31 +08:00
suzmii 90177308e0 pitfalls:补记「同一条命令的口径差异」——CI 用提交范围比较 git diff --check
Project CI / Repository checks (pull_request) Successful in 3m17s
Project CI / Frontend tests (pull_request) Successful in 3m55s
Project CI / Backend tests (pull_request) Successful in 6m17s
Project CI / Native shell tests (pull_request) Successful in 17m11s
- docs/project-memory/shared-memory/pitfalls.md:在「只跑门禁子集 → 同一天两次 CI 红」条目的「原因」里补第三类成因
- 口径差异:CI 跑 git diff --check "${base_ref}"..."${head_ref}"(提交范围),本地裸跑 git diff --check(工作树 vs 索引)在干净树上恒为空,所以本地绿不能证明 CI 这一跳会过
- 实例:pitfalls.md 末尾多出的空行(f16aa440b 引入),因前两次推送分别停在 check:git-hooks 与 check:rustfmt 而一直未被这一跳检查
2026-09-12 02:07:49 +08:00
suzmii a5c7938d05 修 CI:去掉 pitfalls.md 末尾多余空行(git diff --check 报 new blank line at EOF)
- docs/project-memory/shared-memory/pitfalls.md:删掉文件末尾多出的一个空行,使文件以单个换行结尾(UTF-8 无 BOM、LF 不变,仅 -1 行)
- 该空行由先前提交 f16aa440b 引入;前两次 CI 分别停在 check:git-hooks 与 check:rustfmt,都没走到最后一道 git diff --check,因此一直潜伏
- 自检口径按 CI:git diff --check fafe6b63c..HEAD(CI 是提交范围比较;本地裸跑 git diff --check 在干净树上恒为空,看不出来)
2026-09-12 02:02:12 +08:00
suzmii 31fcbb79dc pitfalls:记一条「只跑门禁子集 → 同一天两次 CI 红」
Project CI / Repository checks (pull_request) Failing after 2m51s
Project CI / Frontend tests (pull_request) Successful in 3m29s
Project CI / Backend tests (pull_request) Successful in 7m5s
Project CI / Native shell tests (pull_request) Successful in 17m8s
- 现象:dd7cf401a 给 lint-staged 加 *.rs 键,但本地从未跑过 check:git-hooks(它是 npm run lint 链里的一环),而 scripts/git-hooks.test.mjs 用 assert.deepEqual 钉住 lint-staged 的整份配置形状 → CI 以 deepStrictEqual 失败;本批更早还因 check:rustfmt 红过一次(15660a98b 修 8 处格式偏差),成因是 pre-commit 的 lint-staged 当时只覆盖 *.{js,mjs,cjs,ts,tsx},Rust 格式本地无守卫
- 原因:npm run lint 是一条 && 长链(encoding / npm-workspaces / git-hooks / rustfmt / spacetime-schema / production-ops / preview-deployer / maintenance-page / eslint / typecheck),中间任一步失败则其后步骤根本不执行,「跑到第 N 步就以为本地绿了」;更隐蔽的是本地若因环境原因在中间断掉(如 Windows 上 check:git-hooks 第 2 个用例的 EBUSY),后面那些本来能通过的步骤也从未被验证
- 处理:push 前按完整链路跑;&& 链在某一步失败时,必须把失败步之后的每一步单独再跑一遍(10 步 + git diff --check)并逐条记 exit code(取 exit code 不要接管道);改动碰到 package.json / .husky/ / scripts/ 下的门禁资产时,check:git-hooks 是必跑项
- 验证:本次 check:git-hooks 因本机 Windows EBUSY 中断后,补跑其后各步全部 exit 0(spacetime-schema / production-ops / preview-deployer / maintenance-page / lint:eslint / typecheck / encoding),只有 check:rustfmt 红且红在别人在途的 .rs(assets.rs:519 落在其未提交 hunk +470,62、commands.rs:3079 落在其未提交 hunk +3068,58);变异验证:把 *.rs 从 package.json 摘掉 → 第 1 个用例以同样的 deepStrictEqual operator 变红,还原(SHA256 一致)后回 ok
- 纯文档追加:文件末尾 +8 行、无删改;本条只提交 docs/project-memory/shared-memory/pitfalls.md 一个路径
2026-09-12 01:28:05 +08:00
suzmii 21da95387b decision-log:把守卫范围与本地限制两条裁定写进既有条目
- 取舍 bullet 补裁定理由:保持「整个 workspace」检查、不换成只查暂存文件(rustfmt --check --skip-children 与 cargo fmt 的口径不一致);本批两次 CI 红的共同根因是「本地绿 ≠ CI 绿」,守卫必须与 CI 同口径
- 取舍 bullet 补流程归因:共树里的硌人是「共享 worktree」的症状、不是守卫的问题,对应流程修正是「一条线一个 worktree」
- 本地限制 bullet 定性为「本机环境限制,不是 CI 会红」,写明根因(Windows/WSL 跨 /mnt/c 的临时目录句柄在子进程退出后仍被持有)、删除重试实测无效且已还原、残留目录会累积(清前 16 个、最早 2026-09-05,本轮已清 0),并给出「Linux CI 上通过」的依据
- 明确裁定:接受该用例在 Windows 上红,不改它语义、本批不再修
2026-09-12 01:15:11 +08:00
suzmii 9fea60b850 append 锁:预算与项目写锁对齐、超时报可诊断的持锁方、争用做有界退避重试
Project CI / Repository checks (pull_request) Failing after 3m22s
Project CI / Frontend tests (pull_request) Successful in 3m46s
Project CI / Backend tests (pull_request) Successful in 6m30s
Project CI / Native shell tests (pull_request) Successful in 17m14s
- project/agent_db.rs:等待预算从 10ms×100≈1s 改为 5ms×2000≈10s,与项目写锁完整窗口同口径;新增 lock_short(5ms×200≈1s)供只读路径使用
- project/agent_db.rs:超时文案在锁路径后追加持锁方线索(读锁文件里的诊断元数据,读不到就明说不可读),现场不再只拿到一句"检查运行时配置"
- project/agent_db.rs:取锁成功后把 label/pid/processStartedAt/acquiredAt 写进锁文件;仅作诊断,不参与判活、回收或抢占,同一进程重复取同一把锁不重写
- project/agent_db.rs:抽出 PROJECT_APPEND_LOCK_TIMEOUT_MARKER 常量,控制流不再各自复制中文
- project/conversation.rs:整份读对话记录的两处只读入口改用短窗口,避免把面板读路径一起拖住
- agent/direct_project_history.rs:争用类失败做一次有界退避重试(250ms);格式类失败不重试
- agent/direct_runtime.rs:锁争用给可操作提示(另一个客户端进程正在读写该项目历史或项目锁,请稍后重试,确认没有其它客户端再重启),retryable 保持 true,但现在确实会自动重试
- 用例:预算口径、锁文件诊断元数据、持锁方线索、被独占持有时超时且不留残留、争用重试成功、超出预算失败关闭、格式类不重试
- 变异验证:预算常量改回 1s → 预算用例红(left 10ms / right 5ms);去掉重试 → 重试用例红(append after retry 直接报跨进程锁超时);验证后已还原
2026-09-12 01:09:37 +08:00
suzmii cafd989bfb decision-log:记录 pre-commit 新增 Rust 格式守卫的取舍与本地限制
- 新增 2026-09-11 条目:背景(本地无 Rust 守卫、CI check:rustfmt 是唯一防线)、决策(lint-staged 增 *.rs + 包装脚本对两个 workspace 只查不改)、连带维护点(git-hooks.test.mjs 钉住整份配置形状,增键必须同步)
- 记下刻意保留的取舍:无暂存 .rs 时不触发;一旦暂存 .rs 则按整个 workspace 检查(与 CI 同口径),副作用是别人的未暂存未格式化 .rs 会挡住本次提交
- 记下未修的本地限制:check:git-hooks 第 2 个用例在 Windows 因 finally 里 rmSync 报 EBUSY 而红(断言全过、红在清理),TEMP 里堆了 16 个残留目录最早到 2026-09-05,加删除重试实测无效,CI 在 Linux 上不受影响
- 不动历史条目:2026-08-12 那条的「提交门禁」一行按本文件顶部口径只用于追溯
2026-09-12 01:01:51 +08:00
suzmii 2a7bfadd72 修 CI:git-hooks 回归用例跟上 lint-staged 新增的 *.rs 守卫
- scripts/git-hooks.test.mjs 用 assert.deepEqual 钉住 package.json 的 lint-staged 整份配置形状,dd7cf401a 新增 *.rs 键时漏了同步,Repository checks(经 npm run lint → check:git-hooks)deepStrictEqual 失败
- 按新形态补上 '*.rs': ['node scripts/lint-staged-rustfmt.mjs'],不放宽断言:仍是整份配置的 deepStrictEqual,改回「只要含 js 那条就算过」这类弱化写法一律不做
- 第 2 个用例里的本地 lintStagedConfig 是 temp repo 的测试替身,不是对真实配置的期望:temp repo 里没有 Rust 文件,加 *.rs 只会命中 0 个文件,故不动
- 变异验证:把 *.rs 从 package.json 摘掉 → 本用例以同样的 deepStrictEqual operator 变红(# fail 2);还原(package.json 字节级哈希一致)后复跑该用例回到 ok
2026-09-12 00:43:49 +08:00
suzmii 77871bfa56 把可能弹 UAC 的 ACL 提权修复移出 append 锁的持锁窗口
- project/agent_db.rs:Windows 取锁成功路径不再在"已持有零共享句柄"时做带提权的 ACL 修复;持锁期间只做不提权的严格校验,校验不过就先释放句柄、再提权修复,并返回"这轮没取到锁"由外层重试循环按修复后的 DACL 重新打开
- project/agent_db.rs:新增 release_project_append_os_lock_then_repair_acl,参数按值接收锁句柄且函数体第一件事是 drop,使"边持锁边等 UAC"在签名层面无法被表达
- 原因:提权修复走 powershell Start-Process -Verb RunAs -Wait 同步等用户点 UAC,在持锁期间等它等于把用户犹豫的时间记进别人的持锁窗口;只有 Windows 存在这条路径
- 判据(结构级):提权修复只在此一处被调用,且调用点必须交出句柄所有权;测试环境不弹真 UAC(config.rs 测试分支本地收紧),故不写假 UAC 用例
- 真机判据:DACL 有缺陷的机器上,持锁方进入修复期间不再持锁,等待方从"超时"变为可成功
- 不变项:首次创建锁文件的本地 harden 仍留在持锁期间(本进程新建对象不因继承 DACL 自动提权)
2026-09-12 00:12:52 +08:00
suzmii 1affb5eec3 锁内只保留 append:DirectProject 历史的幂等回扫移出持锁窗口
- direct_project_history.rs:幂等回扫与序列化移到取锁之前,取锁后常态只做一次追加,不再在锁内读完并逐行解析整份历史
- direct_project_history.rs:回扫到取锁之间若历史文件变了(len/mtime 与回扫时不一致)才回到锁内重扫一次;同一 id 不写第二行的语义不放宽,只是常态不再为它读整份文件
- direct_project_history.rs:新增 direct_project_history_duplicate_at 三态判定(Absent/Identical/Conflict)与回扫状态快照,对外行为与原实现一致(幂等 no-op、id 冲突失败关闭)
- direct_project_history.rs:新增回扫探针(两个时点回调)与三条用例:回扫在 append 锁外、锁外回扫后并发追加仍不重复写、id 冲突仍失败关闭
- project/agent_db.rs:新增测试专用探针 project_append_os_lock_is_held_for_test,用与生产同一套打开方式判断追加写目标的锁此刻是否被持有
- 变异验证:把回扫塞回锁内后 idempotency_reverse_scan_runs_outside_the_append_lock 变红(exit 101),报出「幂等回扫必须发生在 append 锁外」,验证后已还原
2026-09-12 00:07:04 +08:00
suzmii f11942f357 展开态补回空态提示:复用栏目页同一个 .game-resource-page-empty 与文案,不新增样式
Project CI / Repository checks (pull_request) Failing after 1m18s
Project CI / Frontend tests (pull_request) Successful in 3m29s
Project CI / Backend tests (pull_request) Successful in 6m42s
Project CI / Native shell tests (pull_request) Successful in 17m44s
- index.tsx:展开态在"可见资源为 0"时渲染 <p class="game-resource-page-empty">没有匹配资源</p>,与栏目页那一条同一条判据口径与同一句文案
- 它挂在既有的画布浮层 host 下(该 host 自身不定位),因此按画本场景根居中,观感与栏目页一致,不需要任何新 CSS
- 测试:空项目的「所有资源」用例补三条断言——进入展开态后标题栏是 .is-active[data-resource-book-category="all"]、卡片宿主数为 0、空态文案是「没有匹配资源」(把"复用既有空态、不新增样式"变成可执行判据)
2026-09-11 23:55:36 +08:00
suzmii 85358b2ca5 「所有资源」页的决策与验收判据跟上当前形态:分带几何口径 + 两页共用同一份布局数据
- decision-log 新增「各栏目按分带铺在同一张画布上」:记分带几何(buildResourceBookAllLayout + resourceBookAllTitlebarRects 复用总览标题栏渲染路径)与冻结口径(只在项目/排序/可见资源集变化时重算,拖动不重算;签名不含 token 的理由),以及本轮不动的三处"仍是另一套"(依赖连线 / 空态分带 / 总览缩略卡网格)与它们的后续顺序
- decision-log 新增「两页共用同一份布局数据」:记裁决(不开独立空间、写回同一 mode 同一 sidecar 的同一 section、提交前减带原点)、"为什么不会写偏"的依据、与"历史手动坐标只读恢复/不删除/不重置/不迁移"不冲突的理由,以及用户可自证的往返判据
- 验收文档 S7 行判据改写:旧判据 data-resource-book-all-page / data-resource-book-all-host 已随网格分支删除,改钉 .game-resource-book-scene-card[data-resource-book-category](每张资源恰好一个宿主)、.is-active[data-resource-book-category="all"] 与 .game-resource-book-view;补上"展开态可拖且在栏目页看到同一位置"这条往返判据
- 验收文档 S7 期望结果同步:由"汇总页"改为"与栏目页同一套画本场景、各栏目按竖向分带铺开"
2026-09-11 23:40:12 +08:00
suzmii 0d98bd05cc 删掉「所有资源」页的独立网格样式:它已经走画本场景,这六条没有调用方了
- 删除 .game-resource-all-resources-host:整页 absoulte 层与自己的底纹(原本是为了盖住画本场景底纹、并躲开画布容器的滚动处理)
- 删除 .game-resource-all-scroll:分组网格的自身滚动容器
- 删除 .game-resource-all-section:按栏目分组的分节卡片(边框 / 圆角 / 半透明底)
- 删除 .game-resource-all-grid:repeat(auto-fill, 180px) 卡片网格
- 删除 .game-resource-all-card:网格里的卡片占位格
- 删除 .game-resource-all-grid .game-resource-card:只读页的光标与 touch-action 覆盖(展开态已可拖,卡片要保留画布那套 touch-action: none)
- 不留任何 all 专属替代规则:展开态的外观全部由既有的 .game-resource-book-scene-card / .game-resource-card 与共享规则给出("零新增 CSS"是本次红线)
2026-09-11 23:33:59 +08:00
suzmii 9788248d9b 「所有资源」页改走画本场景:与栏目页同一套卡片宿主与几何,并可拖动
- 新增 resourceBookShowsAllCategories:把"子画布里的 all"这一态判定收在 resourceBookModel 一处
- resourceBookCategoryCardPresentation:展开态下真实栏目返回 child(卡片仍由各栏目组承载,每张资源只有一个宿主)、all 自己返回 null(只出标题栏与全量计数)
- 新增纯函数 buildResourceBookAllLayout:按栏目顺序纵向排"分带",用各栏目可见资源的 extent 定带宽高;卡片世界坐标 = 栏目内局部坐标 + 带原点
- 新增 resourceBookAllTitlebarRects:分带 → 栏目标题栏矩形,让展开态复用总览态"按矩形钉标题栏"的同一条渲染路径(ResourceBookScene 不需要知道"展开态"这个概念)
- 新增 resourceBookAllBandLocalPoint:展开态世界坐标 → 栏目内局部坐标,这是落盘前唯一的换算(减带原点)
- buildResourceBookScenePlan:展开态下 all 只出标题栏(钉在视口 + 全量计数),真实栏目按带偏移铺满 child 卡;真实栏目这一支仍按 presentation === child 取卡,"能出卡的判定"只有一处
- buildResourceBookScenePlan:返回总览的淡出改为按"刚离开的那段子画布"判定(exitingCategory 传 all 时覆盖所有栏目),并用带偏移位置淡出,否则返回动画会跳位置
- buildResourceBookScenePlan:「所有资源」组不再接 child / exiting 卡,避免同一张卡在两组里各挂一份(DOM 查询与 FLIP 键撞车)
- index.tsx:删掉独立网格页组件、其 host 块与 resourceBookAllResourcesSections memo,展开态整体交给画本场景
- index.tsx:新增内存态 resourceBookAllViewport(不落盘、不进按真实栏目键的 resourceCanvasViewports),滚轮 / 缩放 / 适应 / 平移 / 框选都作用到它
- index.tsx:分带几何按"项目 + 排序 + 可见资源集"冻结(签名 + ref 短路;签名不含 state.token——返回总览时 token 也会变,含它会在淡出期间重算),拖动不重算带高,避免"拖上面一带让后面所有带整体位移"
- index.tsx:拖动放开——展开态下每张卡都能拖,section 取 resource.category(栏目页仍只拖当前分页栏目)
- index.tsx:提交前用 resourceBookAllBandLocalPoint 把世界坐标减回带原点,写回的仍是同一 mode 同一 sidecar 的同一 section(.agent/workbench/resource-layouts/{dependency,type}.json),没有第二份空间,不改契约与 schema
- index.tsx:renderResourceBookCard 去掉 options.draggable:展开态已可拖,这个"只读"开关没有调用方了
- 测试:resourceBookModel.test 钉住展开态 presentation 与"栏目页仍是原判据";resourceBookLayout.test 退役旧分组函数用例,新增分带几何 / 标题栏矩形 / 减带原点换算 / 展开态计划(含"每张资源只有一个宿主"与按带位置淡出)断言
- 测试:appSurface 两处「所有资源」判据从旧网格宿主改为画本场景(.game-resource-book-scene-card[data-resource-book-category] 与 .is-active[data-resource-book-category="all"]),并断言旧网格容器 / 宿主一个都不再存在
2026-09-11 23:33:08 +08:00
suzmii f16aa440b9 补踩坑:第四类索引事故——git add <路径> 会把同一文件里别人的 hunk 一起暂存
- 记录 28781a420 的事故形态:该笔除我新增的用例(@@ -1260,6 +1260,51 @@)外,还混入了 peer 对「所有资源」测试期望的在途改动(@@ -9562,37 +9607,83 @@),提交描述与实际内容不符,且对方那份文件随即显示为干净、容易被误判为已收口;内容未丢
- 写清正确护栏:对可能被他人同时编辑的文件,把 git add <path> + git diff --cached(看内容、不只看文件名)+ git commit 压进同一条命令,并逐 hunk 确认每一个都是自己这次要写的;发现混入他人 hunk 后保持现状并立即上报,不在共享树里用 --amend/reset/checkout --/revert 做手术
- 写清状态信号:M (已暂存且工作区与索引一致)/ MM(add 后又改过)/  M(仅工作区),并点明「M 只能说明 add 之后没人再动过,不能说明这份文件里只有我的改动」
- 写清同类隐患:pre-commit 的 lint-staged 会对它格式化过的文件做 git add(prettier --write 之后 Applying modifications from tasks),与 npx lint-staged --diff=… 属同类反向 git add
- 与前三类索引事故(git add -A 卷走别人的文件 / stash pop 弹出别人的 stash / lint-staged --diff 反向 add)并列,指明本类正是「按文件名校验」这道护栏的失效点
- 门禁:npm run check:encoding exit 0(4399 file(s))
2026-09-11 23:08:57 +08:00
suzmii 3a850ee6e7 补两条踩坑:app-server 会 1:1 回显注入内容(读侧上限不得小于写侧允许量),以及“掉了就补读”的补扫不是修复
- 新增「Codex app-server 会 1:1 回显注入内容:读侧上限不得小于写侧允许量」:记录隔离探针实测(捆绑 codex-cli 0.147.0)——注入 5 MiB 的 item,app-server 接受并回一条 5 243 245 字节的 rawResponseItem/completed 单行,而读侧上限当时 4 MiB、写侧无上限,于是合法的注入被读行判成连接级故障,报错方向指向 app-server;同时记下 stderrBytes=405 那类摘要不能当证据(正常启动就有 275/452/546/5996 B 的插件/别名警告)、experimentalRawEvents 需要 initialize.capabilities.experimentalApi、以及探针必须用管道 stdin 与按 CommandLine 精确清理
- 新增「「掉了就补读」的补扫不是修复:自激回路会一路耗到内存」:明确标注**由并行工作线报告、本线未独立复核**,并给出取证据前不得引用的警示与“去掉补扫症状是否回来”的判据
- 门禁:npm run check:encoding exit 0(4399 files)
2026-09-11 22:51:24 +08:00
suzmii 28781a420c 补资源总览网格的声明级断言:钉住 width:100% 与 max-width 并存,防止退回单列
- 新增用例「keeps the resource overview grid off fit-content so its columns stay responsive」:断言 .game-resource-book-main-grid 同时声明 width: 100% / max-width: 1120px / margin: 0 auto / repeat(auto-fit …),并同款断言 .game-resource-book-main-heading 的 width: 100%
- 取声明体前先剥掉 CSS 注释:本次修复的注释里就写着 width: 100% 等字样,不剥掉会让断言匹配到注释而假绿(删掉真正的声明也照样通过)
- 注释里写明这条断言**验不到布局**:AGC 的 vitest 没开 css: true、styles.css 不会被加载、jsdom 也没有布局引擎,gridTemplateColumns 的解析结果与缩略卡列数只能在真机量;真机判据(3 个非 0 轨道 / grid 宽 432→1120 / 8 张缩略卡 top 只有 3 个取值,且需在总览态取样)写在用例注释里
- 变异验证:临时删掉 .game-resource-book-main-grid 的 width: 100% → 用例变红且失败信息打印的是真实声明体(证明注释剥离生效);还原后 styles.css 逐字节哈希与已提交版本一致,复跑绿灯
- 门禁:npm run typecheck exit 0;npm run check:encoding exit 0(4399 files);npx vitest run apps/ai-game-creator-shell/tests/appSurface.test.ts 本用例 1 passed
2026-09-11 22:50:01 +08:00
suzmii 3db6afa052 对齐 Codex app-server 读写单行上限:写侧守卫 + DirectProject 历史注入前置校验,并把注入超限改判为不可重试
- 隔离探针实测(codex-cli 0.147.0,AGC 捆绑版本):experimentalRawEvents 线程收到 thread/inject_items 后会逐条原样回显 rawResponseItem/completed —— 注入一个 5 MiB 的 item,stdout 就回一条 5 243 245 字节的单行;而读侧上限原为 4 MiB,于是「我们注入得进去」却「我们读不回来」,报错方向指向 app-server,实际是我方读行判死
- codex_app_server.rs 的 GAME_CREATOR_CODEX_APP_SERVER_LINE_MAX_BYTES 由 4 MiB 提到 32 MiB,注明它同时充当 stdout 读侧上限与 stdin 写侧守卫;取值依据写进注释(单张图 base64 上限 10 MiB、单次图片总量 16 MiB 折 base64 约 21.3 MiB,再加 JSON 信封),再大就失去内存/DoS 边界的意义
- 新增写侧守卫 game_creator_codex_app_server_message_oversize_error:write_message 超限即失败关闭并给出字节数,绝不写出自己读不回来的行(两侧共用同一常量即这条不变量)
- 新增 DirectProject 历史注入前置校验 direct_project_history_injection_oversize_error:单条 item(扣掉 rawResponseItem/completed 回显信封余量)与整份载荷都必须落在上限内;超限指名 itemId/type/字节数并失败关闭,且不截断、不摘要、不改写历史(技术方案末节口径)
- 新增专属前缀 DIRECT_PROJECT_HISTORY_INJECTION_OVERSIZE_PREFIX,供 direct_runtime 判可重试性与给专属提示
- direct_runtime.rs:direct_codex_failure_is_retryable 把注入超限改为不可重试(同一份历史每次读结论相同,且本轮用户消息已先追加进同一文件、载荷只会更大);direct_codex_failure_recovery_hint 与 direct_codex_failure_public_summary 各补一条专属文案,用户不再看到笼统的「Codex 未完成本轮代码修改,请检查运行时配置后重试(可直接重试)」
- 新增 2 条用例:写侧守卫与读侧共用同一上限的边界(等于上限必须放行、上限+1 必须拒绝)、注入侧整份载荷与单条 item 双超限必须拒绝
- 变异验证:把写侧守卫改成 >=、把单条上限放成 usize::MAX,两条用例各在自己那一处变红;还原后逐字节哈希一致并复跑绿灯
- 门禁:npm run check:rustfmt exit 0;npm run typecheck exit 0;cargo test 定向 2 passed(2257 filtered out)
2026-09-11 22:24:30 +08:00
suzmii 1b7261685c 登记素材上传配对的踩坑与覆盖边界:断言打在生产函数上,端到端 UI 未覆盖
- `pitfalls.md` 新增「素材上传不要把『上传前读到的 revision』贴到『上传后读到的清单』上」:现象(每次上传都先吃一条拒收提示)、根因(上传本身推进 revision,错配必然发生而非竞态)、处理(上传动作与配对读收进同一个函数,调用方拿不到中间那个 revision,也就没机会贴错)、同类文案坑(`unresolved` 三条进入路径里有两条重读是**成功**的,写成"读取磁盘清单失败"就是误报)、验证与变异,并记录关联文件与提交。
- 覆盖边界(按当下口径写清,防止后人误读):断言打在生产函数 `uploadProjectAssetFilesAndReadSnapshot` 上 —— 它同时拥有上传与配对读,所以配对语义是真测的;但 AGC 侧**没有**「资源面板上传 → 拒收提示条」的端到端 UI 用例(现有 harness 只有聊天入口的 `/asset.upload`,资源面板 file input 无用例),因此「界面上不再出现拒收提示」是推理结论而非端到端断言,禁止当成端到端覆盖引用。
- 同一条边界同时写进 `projectResourceLiveUpdateModel.test.ts` 用例组头部,随断言一起走,避免只看测试文件的人误读。
- 纯文档 + 测试注释,无功能改动。
2026-09-11 22:19:14 +08:00
suzmii c3ccae75af 按实测收回 cfd89ba57 顺手加宽的一处测试超时,并写明另一处保留的理由
- `prefetches only cards that are actually in view, and still fills the first screen` 的 `}, 30000)` 收回(回到 vitest 默认 5 s):实测 93 ms,约 54 倍余量,而且真正的时间门是内层 `waitFor` 自带的 1 s。cfd89ba57 那次只是给负载留余量,没有对应的结构性成本 —— 对照 `releases Blob URLs while continuously browsing beyond the cache limit`(79 轮串行读盘、实测 6120 ms、超默认 5 s)那条的预算属结构性必需,保持不动。
- `keeps a visible card cached when later loads push the cache over its limit` 的 `}, 60000)` **保留**并写明理由:它的职责是挂起护栏,必须大于内层 `waitFor` 的 20 s 断言预算,否则内层断言自己的失败信息永远不出现(vitest 会先在测试超时处中止,只剩笼统的 "test timed out")。用例实测 120 ms,20 s 与 60 s 都不是耗时预期;硬收会让内层预算变成不可达配置,是更差的诊断而不是更紧的门禁。
- 无功能改动。
2026-09-11 22:18:40 +08:00
suzmii fef366a66b 文档跟进:资源画布的引用入口按当前状态描述(图标 @ + 文案「引用」)
- 「当前已完成」清单里「资源画布素材卡提供 `@引用`」既不准确也不再是当前形态:入口已从卡片挪到选中工具条,且可见文案与 title 都不再自带 `@`(`@` 由 `AtSign` 图标承担,文案再写一次会渲染成「@ @引用」)。
- 按「四不写」把该条直接改写成当前状态:入口位置(选中工具条)、图标与文案口径(图标 @ + 文案「引用」)、以及插入对话里仍然是 `@素材名` 芯片。
- 纯文档,无代码改动;单独提交便于 cherry-pick。
2026-09-11 22:18:09 +08:00
suzmii a88034dab1 修复资源总览网格退化成单列:父链宽度不定让 auto-fit 只解析 1 个轨道
- styles.css 的 .game-resource-book-main-grid 增加 width: 100%:父级 .game-resource-book-main-world 是列向 flex 容器,交叉轴 auto 外边距(margin: 0 auto)会吃掉自由空间并取消 stretch,使该网格按 fit-content 定宽;fit-content 定宽时网格 inline size 在内在尺寸阶段不定,按 CSS Grid 规范 auto-fit 的重复次数解析为 1,于是 2560x910 窗口下 grid 宽只有 432px、getComputedStyle(grid).gridTemplateColumns 只有 1 个轨道、8 张栏目缩略卡竖向排成一列(top 步距 230)
- 同款修 .game-resource-book-main-heading:它在同一个 flex 列里带 margin:auto,同样退化成 fit-content,标题栏只占 <h2> 宽度且 space-between 无从展开
- 两处都补注释写明「width:100% 是 auto-fit 按 max-width 解析列数的前提」,防止后人当作冗余声明删掉
- 修复后 auto-fit 按 max-width:1120px 解析重复次数:3x280+2x18=876 <= 1120、4x280+3x18=1174 > 1120,即 3 列
- 真机判据:gridTemplateColumns 应给出 3 个非 0 轨道(约 361.33px)、grid 宽 432 -> 1120、8 张缩略卡 top 只有 3 个取值;且必须在总览态核对(栏目态该层带 is-background,opacity 为 0,是背景层)
- 未改动任何既有断言;AGC vitest 不加载 styles.css(无 css: true),声明级断言随下一笔提交补进 appSurface 用例
2026-09-11 22:10:43 +08:00
suzmii e98066ee7e 驱逐后不再补扫:删掉那条会把可见卡反复重读的自激回路,并如实标注超时预算
- 删掉 publishPreview 里"驱逐后立刻补一次可见性扫描"(原 useProjectResourceCardPreviews.ts 的 `if (evictedCount > 0) sweepVisiblePreviews()`)。它原先的注释宣称"递归深度恒为 1、不自激",该不变量是假的,判据有三条:(1) 触发条件是"本轮驱逐了任意条目",而第一轮只淘汰视口外条目,那种情况下根本没有可见卡丢状态,补扫却照样把空闲且可见的卡重新入队;(2) 真正驱逐到可见卡时(全表都在视口内且仍超预算,第二轮全表 LRU 回退)补扫把刚被驱逐的卡重新入队 → 读回来又超预算 → 再驱逐 → 再补扫,每个周期跨一次异步读取,是不收敛的回路;(3) 那种回读换不来稳定结果,读回的卡立刻被下一轮 LRU 淘汰(第二轮回退是全表 LRU,刚读回的卡恰好最新),只在可见集合里轮转。因此正确反应是接受这次有界淘汰(由「全可见且超预算时仍必须淘汰」钉住),而不是反复重读;代码注释按这三条重写,不留误导后人的陈述。
- 顺带删掉只为那段补扫存在的 `evictedCount`(否则是死变量)。
- 断言:新增 useProjectResourceCardPreviews.test.ts「settles instead of re-reading evicted visible cards forever」——全部登记且都在视口内(第一轮无从下手),断言读取次数在观察窗口内不再增长、且缓存稳定在上限。
- 变异验证(提交前已跑):把补扫加回去 → 该用例**不是断言变红而是 worker 被撑爆**(`ERR_WORKER_OUT_OF_MEMORY`,约 74 秒后终止),即回路真的不收敛;还原后复跑 32 passed,该用例 2.1s 通过。
- 超时预算改为"标注并给出实测依据",不再用放宽时间门吸收负载代价:`releases Blob URLs while continuously browsing beyond the cache limit` 的 `}, 30000)` 保留,并在用例前写明理由 —— 它逐个 waitFor 等 79 张卡串行读完,轮数由条目上限 48→72 机械地从 55 涨到 79(+44%),实测 6120 ms,已超 vitest 默认 5s,属结构性必需。同一提交里的 `waitFor({ timeout: 30000 })`(由 20000 顺手放大)按实测**回到 20000** 并写明依据(该用例实测 124 ms,约 160 倍余量)。
- 门禁:useProjectResourceCardPreviews 32 passed。
2026-09-11 22:02:31 +08:00
suzmii b12b0998d0 分类改动不再丢弃已落盘的画布坐标:分区不一致时归并到资源当前分类并保留 x / y
- 原缺陷(3082c3e85 的连带副作用):`kind:"UI"` 的资产投影分类由「待归类」修到「UI 交互」后,它们落盘的坐标 section 仍是现行栏目值 `unclassified`,于是 resolveResourceCanvasSection 走「现行栏目值 != 资源当前分类 ⇒ null」直接判丢弃,8 条用户手摆的坐标没能跟过来、被自动排布重算。
- resourceCanvasSectionMapping.ts:现行栏目值改为返回**资源当前分类**("这张卡属于哪一栏"的唯一真源本来就是 resource.category,协调时也按它分组),与旧栏目归并支路同一条语义 —— 只改写 section,x / y / manuallyPlaced 原样保留。判据:坐标是栏目内局部坐标(reconcileResourceCanvasLayout 先按 section 分组再算栏目带原点),所以换栏目保留 x / y 得到的正是"这张卡在新栏目里的同一个槽位";而"丢弃 + 自动重排"会连 manuallyPlaced 一起抹掉,是用户可见的损失。
- 判断与理由:**保坐标重写 section 可行**。同一函数对更难的旧栏目 case(旧 `art` 可能归并到 4 个不同栏目)已经在做同样的事,对 `unclassified → ui-interaction` 反而更窄;拒绝它是自相矛盾。
- 丢弃口径收窄:resolveResourceCanvasSection 只在**确实无从归并**时返回 null —— 无法识别的分区值,以及旧栏目归并目标集合不含资源当前分类(如旧 `code` 遇到已归为 `document` 的资源)。useProjectResourceCanvasLayout.ts 的 droppedSectionMismatch 文档与读盘统计注释按此更新(用户可见文案未变,它对新口径仍然成立)。
- 断言:resourceCanvasSectionMapping.test.ts 把原「沿用既有行为丢弃该坐标」用例改写为守新契约(归并 + changed=true),并新增 x / y / manuallyPlaced 逐项不变用例;resourceCanvasLayoutModel.test.ts 原「drops a persisted position…」改写为「rewrites … keeping the coordinates」;useProjectResourceCanvasLayout.test.ts 的读盘统计由 normalized 1 / droppedSectionMismatch 1 改为 normalized 2 / droppedSectionMismatch 0,并新增"归并必须连坐标一起保住"的断言,另补一条用例钉住 droppedSectionMismatch 只剩"无从归并"两类来源;appSurface/project-development.suite.ts 的提示文案与 data-* 计数同步,并新增写盘坐标断言。
- 变异验证(提交前已跑):把 resolveResourceCanvasSection 还原成 `section === resourceCategory ? section : null` → 6 条用例变红(映射 2 条、布局模型 1 条、读盘统计 2 条、appSurface 1 条),还原后复跑 83 passed + appSurface 该条 passed。
- 门禁:resourceCanvasSectionMapping 9 passed、resourceCanvasLayoutModel 47 passed、useProjectResourceCanvasLayout 27 passed。
2026-09-11 21:48:43 +08:00
suzmii 006e9cc2f3 素材上传不再每次触发一次拒收提示:上传与清单配对读放进同一个函数
Project CI / Repository checks (pull_request) Failing after 1m31s
Project CI / Frontend tests (pull_request) Successful in 3m46s
Project CI / Backend tests (pull_request) Successful in 8m21s
Project CI / Native shell tests (pull_request) Successful in 19m10s
- 原缺陷:上传路径在上传**前**读 get_local_game_project_revision,上传完把那个**旧 revision** 交给 reloadManifestAfterAssetCommand;后者重新读盘拿**新清单**再打上旧 revision。上传本身会推进项目 revision(Rust advance_agent_runtime_project_revision_locked),于是这一对是撕裂的,「同 revision 不同指纹」被 projectResourceLiveUpdateModel 判成 revision-conflict —— 用户每上传一次素材就先看到一条「资源清单更新被拒收…」。
- projectResourceLiveUpdateModel.ts 新增 uploadProjectAssetFilesAndReadSnapshot:上传动作与"配对读"由同一个函数拥有,配对读复用既有 rereadAuthoritativeProjectManifestSnapshot(同一次读里配对 revision + 清单,并再读一次 revision 确认没被写盘插队)。调用方拿不到中间那个 revision,也就没有机会把它贴错。
- index.tsx 的 uploadResourcePanelFiles 改用它,删掉上传前那次 revision 读取与旧 revision 的传递。
- 附带文案误报:describeProjectManifestMergeRejection 的 unresolved 文案由「重新读取磁盘清单失败,请重新打开项目」改为「未能按磁盘清单重新对齐,请重新打开项目」。unresolved 有三条进入路径,其中两条**重读是成功的**(读到的一对比手上旧、或读到的是撕裂的一对),写成"读盘失败"是误报;新文案对所有路径都成立。WorkspaceLauncher.tsx 在"读到了但不采用"那一支补注释说明 stage 不是读失败。
- 断言:projectResourceLiveUpdateModel.test.ts 新增「素材上传后的清单快照必须配对」——假后端在上传时推进 revision,断言 merge 判 accepted 且 projectManifestMergeRejectionDecision 为 null(即"上传一次不产生拒收提示"),并附一条对照钉子显式写出旧形状、断言它必被判 revision-conflict;另新增 unresolved 文案不得包含"读取磁盘清单失败"的用例。
- 变异验证(提交前已跑):把 uploadProjectAssetFilesAndReadSnapshot 还原成"上传前读 revision + 上传后读清单"→ 配对用例变红(并给出 revision-conflict 的失败信息),还原后复跑 15 passed。
- 门禁:projectResourceLiveUpdateModel 15 passed、workspaceLauncherManifestMerge 5 passed。
2026-09-11 21:33:54 +08:00
suzmii 09ed7b099b 视图切换取消排队预取在生产路径上真正生效:视图标识改为必填
- 原缺陷:预览 Hook 的预取作用域 key 缺省退化为"被喂进来的资源集合签名",而宿主按设计要求恒传**全量投影**(传全量才不会让总览里其它栏目只剩栏目标题栏),换栏目/换视图一字不改 ⇒ key 不变 ⇒ 取消上一视图排队预取的 effect 永不执行,"同级裁决"一并退化为 no-op。已有用例用"改 resources"模拟切视图,测的是生产不存在的调用方形状。
- resourceCanvasController.ts 新增 createResourceCanvasPrefetchScopeKey({ view, target, mode }):由视图身份(main / child:<栏目>)加排序模式算出标识。
- index.tsx 在资源画布处显式算出该标识并传给预览 Hook(栏目取 resourceBookState.view / resourceBookState.category,排序取 sortMode)。
- useProjectResourceCardPreviews.ts 把 prefetchScopeKey 由可选改为**必填**并删掉那段兜底:它能"看起来还在工作",正是它掩盖了漏接线;现在漏传是类型错误而不是静默少取消一次。
- 断言:resourceCanvasController.test.ts 新增标识派生两组用例(换栏目/换视图/换排序模式必须变,同一视图必须稳定);useProjectResourceCardPreviews.test.ts 改写两条既有切视图用例为生产形状(resources 恒为全量、只改视图标识与 eagerResources),并在用例内前置断言"换栏目必须换标识";其余既有用例经用例侧包装补齐标识,形状与生产一致,断言一条未放宽。
- 变异验证(提交前已跑):把 createResourceCanvasPrefetchScopeKey 的栏目分量去掉(只留 view + mode)→ 3 条用例变红(标识派生 / 取消上一视图排队预取 / 取消后重新可见再入队),还原后复跑 34 passed。
- 门禁:`npx vitest run` 上述两文件 34 passed;typecheck 见随后整轮。
2026-09-11 21:31:30 +08:00
suzmii 4175d82fa1 工具条「引用」不再渲染成「@ @引用」:@ 只留图标,文案去掉重复的 @
- index.tsx 选中工具条的引用按钮:title 由「@引用」改为「引用」,可见文案 <span>@引用</span> 改为 <span>引用</span>。图标 AtSign 本身就是 `@`,文案再写一次就渲染成「@ @引用」。
- label={`引用资源 ${selectedResource.label}`} 保持不变:无障碍名仍要能读出「引用资源 <素材名>」,本次只去掉可见文案与 title 里的 `@`。
- 断言(appSurface/project-development.suite.ts):静态形状用例改判 title="引用" / <span>引用</span>,并新增 not.toContain('@引用') 反向守卫;键盘通路用例改判 title === '引用'、可见标签 .genarrative-image-canvas__chrome-button-label 文本为「引用」,并新增 aria-label === '引用资源 hero.png',钉住无障碍名没有被一起去掉。
- 变异验证(提交前已跑):把 index.tsx 的 title 与可见文案一起改回「@引用」→ appSurface 两条用例变红(资源卡的 @ 引用入口只留在选中工具条里 / 工具条里的「引用」用键盘也能插进聊天输入框),还原后复跑 412 passed。
- 门禁:`npx vitest run apps/ai-game-creator-shell/tests/appSurface.test.ts` 412 passed(改动前后同一时刻对照,均为 412 passed)。
2026-09-11 21:25:14 +08:00
suzmii dd7cf401a9 补 lint-staged 的 Rust 守卫:pre-commit 也跑 check:rustfmt
- 新增 scripts/lint-staged-rustfmt.mjs:对 server-rs 与 apps/ai-game-creator-shell/src-tauri 两个 workspace 跑 cargo fmt --all --manifest-path <m> -- --check,只查不改
- 为什么需要包装脚本:lint-staged 会把命中的暂存文件路径追加到命令末尾,而 cargo fmt 只按 workspace 粒度格式化、不接受文件参数,直接写成 npm run check:rustfmt 会被多余参数打断;脚本因此忽略 argv,并按 workspace 逐个检查
- package.json 的 lint-staged 配置新增 "*.rs": ["node scripts/lint-staged-rustfmt.mjs"]
- 成因:原配置只覆盖 *.{js,mjs,cjs,ts,tsx},Rust 格式在本地完全没有守卫,唯一防线是 CI 的 check:rustfmt,本 PR 已因此红过一次(见 15660a98b)
- 已实测:把包装脚本直接跑一遍 exit 0;lint-staged 分派层面确认 "*.rs" 任务会被真的触发(对 6 个 .rs 文件跑通);脚本本身过 eslint 与 prettier --check
2026-09-11 21:24:43 +08:00
suzmii 5fff7a134c ④ 资源分类写入补一条 agent.db 审计记录
- update_manifest_asset_classification_at 在 manifest 写成功后追加 recordType=asset.classification.update 的审计,复用既有 append_agent_db_record 惯例(照 assets.rs 的 asset.register / asset.update:manifest 写成功后追加)
- 字段:assetId(沿用 assets.rs 的 assetId)、expectedProjectRevision(本次写入实际校验的 CAS 基准 revision,沿用分类输入契约里的同名参数)、previousCategory / previousTags(变更前值)、category / tags(变更后值)
- 不记 localPath / kind / mediaType / source:本记录针对的是已有 assetId 的分类变更,这些字段并未改变,且能从 manifest 或既有 asset.register 记录追到,多记一份会在改名后产生互相矛盾的审计
- 不用 projectRevision 这个名字:既有 agent.runtime.action_receipt 里 projectRevision 的语义是「动作完成后的 revision」,本条审计追加在 revision 推进之前,沿用同名会指代不一致;审计是持久化数据,故在新记录类型里另起不冲突的名字,不动既有记录格式约定
- 位置放在推进 revision 之前是刻意的:分类已经真实落盘,审计不能因为紧随其后的 revision 推进失败而缺失,否则「改过但查不到」正是这条缺陷;推进失败仍然照旧报错
- 审计追加失败按既有惯例映射为可见错误「资源分类已写入,但审计记录失败:{error}」,不静默吞掉
- project/manifest/classification_tests.rs 补 3 条断言:成功写入恰好一条且前后值正确(含第二次写入的 previous 必须取第一次的落盘值)、四类被拒写入不产生任何审计、审计写失败时错误可见且不留假审计(同时确认分类本身已落盘、不回滚)
2026-09-11 21:18:55 +08:00
suzmii 4161888219 ③ 资源画布布局的读/写命令补项目权限门
- read_local_project_resource_canvas_layout 补 enforce_project_auto_permission_policy(root, "asset.list"),与紧邻的 read_local_project_resource_graph 完全同口径
- update_local_project_resource_canvas_layout 补 enforce_project_permission_policy(root, "asset.register"),与本文件 update_local_project_resource_classification / delete_local_project_asset / rename_local_project_asset 同口径
- 权限位依据:读路径是同一块资源画布的渲染读路径,邻居用 asset.list;写路径是「改动项目内资源相关持久化数据」,manifest 侧的分类写入本身就用 acquire_project_write_lock(root, "asset.register"),故沿用同一权限位,不新造权限名
- 读用 auto、写用普通门是刻意的:asset.list 是 Auto,auto 口径默认放行,只有策略显式 deny/confirm 才拒绝(读路径没有可插入的确认交互,要求确认等同拒绝);asset.register 默认是 Confirm,若写路径也用 auto 会直接打死默认路径
- tests/project.rs 补断言:默认策略下读与写都放行;deny asset.list 读失败、confirm asset.list 也读失败(钉住 auto 口径这一选择);deny asset.register 时读仍放行、写被拒且被拒的写不留任何落盘副作用(断言 sidecar revision 未变);恢复默认策略后写入照旧成功
2026-09-11 21:18:41 +08:00
suzmii 6cc73b07ac ② GameCreationAppManifest 加 deny_unknown_fields:未知顶层字段失败关闭
- shared-contracts 的 GameCreationAppManifest 加 #[serde(deny_unknown_fields)],并写明取向与取舍
- 选「失败关闭」而不是「保留未知字段 round-trip」的理由:AGC 读写 manifest 是「整结构体反序列化 + 整结构体重新序列化覆盖落盘」,放行未知顶层字段就等于让「读一次 + 任意一次写」静默抹掉未来版本新增的字段;而 flatten catch-all 只能覆盖加了它的那一层,tasks / assets / versions / preview / commandRuns 内部的未来新增字段照样被抹掉,且写侧的 skip_serializing_if 会同时把已知字段归一化,落盘结果是「新字段原样 + 旧字段被规范化」的混合体,比直接报错更难排查
- 与既有取向同口径:本批新增的资源布局 sidecar 对未知 schema 就是失败关闭,UpdateLocalProjectResourceClassificationInput 也已 deny_unknown_fields
- 影响面已核实:server-rs 内 game_creation_app 模块只被自身引用,不进 /api/external/v1、不进 SpacetimeDB;全仓 GameCreationAppManifest 的反序列化点只有 src-tauri 的 read_manifest 一处;仓库自带 smoke 脚本写的 manifest 只有 schemaVersion/projectId/name/assets 四个已知键
- 断言:shared-contracts 补契约级用例(未知顶层字段反序列化失败且报出字段名、已知字段含可选字段照旧往返);project/manifest/import_tests.rs 补消费侧用例(纯读报错、读+写入口 mutate_manifest_at 也报错、两次失败后磁盘文件逐字节未变即未知字段没被抹掉)
2026-09-11 21:18:29 +08:00
suzmii 5eaf9faefd ① manifest 的 schemaVersion 补读/写失败关闭门
- 新增 validate_manifest_schema_version:只接受 GAME_CREATION_APP_MANIFEST_SCHEMA_VERSION,未知版本报「manifest schemaVersion 不受支持:{实际值}(当前支持 {当前值})」
- read_manifest 在解析后立即校验 schemaVersion,与既有的 godotProjectRoot / versions 校验同级;读到未知版本直接拒绝打开,不再被当成已知版本继续使用
- write_manifest_locked 落盘前同样校验,保证本客户端永远不会把未知 schemaVersion 写进项目(该函数是 write_manifest 与 mutate_manifest_at 共用的唯一落盘入口)
- 前向兼容取舍:刻意不做「接受未来版本 + 读时就地升级」——当前并不存在 v2 定义,凭空写一个升级只能把未知数据改写成当前版本的形状,正是本次要修掉的「静默接受」;代价是未来发 v2 时旧客户端明确报错要求升级,而不是把项目按旧结构写回
- project/manifest/import_tests.rs 补 3 条断言:当前版本必须被接受且全字段回读相等(正向断言,挡住「无条件拒绝」这种改法)、未知版本读失败且磁盘文件逐字节未变、未知版本写失败且不落盘
2026-09-11 21:18:15 +08:00
suzmii 15660a98b2 修 CI:补跑 AGC src-tauri 的 rustfmt,修正本批遗留的 8 处格式偏差
Project CI / Repository checks (pull_request) Successful in 2m52s
Project CI / Frontend tests (pull_request) Successful in 3m30s
Project CI / Backend tests (pull_request) Successful in 5m44s
Project CI / Native shell tests (pull_request) Successful in 19m31s
- 只做格式重排(应合成长行/应拆多行),无语义改动
- 命中 agent/direct_project_history.rs、agent/direct_runtime.rs、main.rs、project/conversation.rs、project/conversation/tests.rs
- 分别来自 bfb928295 / 2b44b6259 / 458a65cb5 / f0d5687ab 四笔提交的新增行
- 成因:pre-commit 的 lint-staged 只覆盖 *.{js,mjs,cjs,ts,tsx},Rust 格式本地无守卫
2026-09-11 21:09:09 +08:00
suzmii 47c88809b6 decision-log:记录冷启动首屏同卡重复读为后续项(本轮不修)
- 新增一条 2026-09-11 决策记录:冷启动首屏同一张卡被读两次的现象、两条对照证据(eagerPreviewLimit 置 0 同形;hook 整份换回 A/B 之前的 HEAD 版本同样重复)、怀疑方向(observePreview 的注册回声扫描 + 取消 visible 预取后的立刻重扫 + 0/250/1000ms 多延迟点叠加)、未修原因(赶 DDL、非用户可见故障、属门禁与队列时序这条更脆的通路)、影响(首屏请求量比理论最小值多约 20%)与建议的修复断言;
- 记录里写明 A+B 只改「取哪些、按什么顺序」,与这条重复读无关;
- prettier 顺带把该文件整份规范化(改动 2 行缩进 + 2 个空行,属既有旧条目,纯格式无语义)。该文件在 HEAD 上本就不是 prettier-clean(已用 HEAD 副本 prettier --check 实测为 warn),本次一并收口。
2026-09-11 21:00:36 +08:00
suzmii 87968e3be3 布局落盘 / 读盘失败不再吞掉原因:脱敏后留一行日志
- 两处 .catch(() => {…}) 没有 message 参数,失败原因被整个吞掉:用户只看到「布局保存失败」「布局读取失败」,排障端也拿不到为什么(revision 越界 / IPC 拒绝 / 权限);
- 写回失败(update_local_project_resource_canvas_layout 的 .catch)与读盘失败(read_local_project_resource_canvas_layout 的 .catch)都补上 error 参数,并落一行 console.warn;
- 新增 describeResourceCanvasLayoutFailure:把 error.message 压成一行、截断 400 字,并复用 errorReporting 的 normalizeDiagnosticText 脱敏(路径 / token / URL / 长 id 打码);
- errorReporting 的 normalizeDiagnosticText 改为导出:脱敏口径只留一份,业务文件不再各写一套正则;
- 用户可见文案一字不改(LayoutNotice 联合类型与既有四条用例的断言原样保留),原因只进日志;日志由客户端已装的 webview→application.log 桥带进诊断日志;
- 新增两条用例:写回失败必须留下原因(并断言可见文案仍是「布局保存失败,已恢复上次布局」)、IPC 拒绝且原因里带本机绝对路径与 token 时必须脱敏成 <path> 且压成一行;
- 变异验证:把写回那处改回吞掉原因 → 写回用例红灯;把读盘那处改回吞掉原因 → 脱敏用例红灯(各自只红对应一条,既有 24 条全绿)。
2026-09-11 21:00:16 +08:00
suzmii 3a4f95d7ca Merge remote-tracking branch 'origin/master' into feat/agc-canvas-resource-workbench-v3
Project CI / Repository checks (pull_request) Failing after 1m23s
Project CI / Frontend tests (pull_request) Successful in 5m4s
Project CI / Backend tests (pull_request) Successful in 6m33s
Project CI / Native shell tests (pull_request) Successful in 19m45s
# Conflicts:
#	apps/ai-game-creator-shell/src/App.tsx
2026-09-11 20:56:14 +08:00
suzmii 2ea3bcbb07 驱逐改为可见性优先,条目上限 48→72:图片不再自己消失又回来
Project CI / Repository checks (pull_request) Failing after 13s
Project CI / Backend tests (pull_request) Failing after 12s
Project CI / Frontend tests (pull_request) Successful in 3m14s
Project CI / Native shell tests (pull_request) Successful in 19m24s
用户报「图片会自己消失然后重新加载」。根因是**淘汰完全按 LRU 插入顺序、且完全不看"是否仍然可见"**:缓存条目顺序是「最近一次被请求」的顺序,而停在屏幕上不动的卡片不会产生新的请求 —— 于是恰恰是用户正看着的那几张排在队首被首选淘汰,`disposeCachedPreview` 释放 Blob 后图片凭空消失,再被兜底扫描重新读回来,表现为闪烁。上一轮「驱逐后补一次可见性复核」只是把"掉了不回来"变成"掉了再加载",是治标。

- `projectResourceCardPreviewEvictionIdentities` 新增 `visibleIdentities` 参数,淘汰改两轮:**第一轮只淘汰视口外的条目**(可见卡一律跳过);**第二轮才回退** —— 只有"剩余条目全部仍在视口内且依旧超预算"时才按全表 LRU 淘汰。回退不可省略,否则"可见即永不淘汰"会造成无界内存,该回退由用例钉住。
- `useProjectResourceCardPreviews` 在淘汰前按几何算出可见集合(复用既有的视口档位判据 `viewportBandOfElement <= 1`,经 ref 转发生效,避免定义顺序耦合)。
- `PROJECT_RESOURCE_CARD_PREVIEW_CACHE_LIMIT` 由 `48` 提到 `72`。依据是真机实测:该项目「UI 交互」栏目有 **51 张**可预览卡,而原上限 48 **小于一栏的规模** ⇒ 滚满该栏目必然驱逐;取 72 覆盖 51 张并留约 40% 余量,按真机单张均值 591 KB 外推 ≈ **43 MiB**,**仍在既有 64 MiB 字节预算之内**(真机 52 张 blob 合计 29.32 MiB,仅用掉 45.8%)。**字节预算未动,也不是把上限放大到任意大。**

断言(`tests/useProjectResourceCardPreviews.test.ts`):
1. 「可见卡不得成为首选淘汰对象」:最老的 3 张都在屏幕上时,淘汰必须跳过它们、改淘汰视口外的第 4 张;对照用例同时钉住"不给可见信息时退化为纯 LRU";
2. 「全可见且超预算时仍必须淘汰」(防无界内存),条目上限与字节上限两侧各一条;
3. 「51 张整栏零淘汰」:真机栏目规模下 `projectResourceCardPreviewEvictionIdentities` 必须返回空数组,且断言字节侧余量。
另把原先守旧行为的用例改写为守新契约:可见卡被后续加载挤出缓存上限时**必须仍保持 `loaded` 且不产生第二次读取**(不再依赖"掉了再补读")。既有用例一条未放宽。

变异验证:
- 去掉可见性过滤(第一轮不再跳过可见卡)→ 断言 1 所属用例立即失败(`expected [ [ …(2) ], [ …(2) ] ] to have a length of 1 but got 2`,即目标卡被驱逐并重读);
- 去掉"全可见时回退全表 LRU"→ 断言 2 立即失败(`expected [] to deeply equal [ 'item-0' ]`,即缓存无界)。

验证:定向 `useProjectResourceCardPreviews` 31 passed;typecheck exit 0;prettier 干净。(全量 AGC 子集的前后对照见随后的回报。)
2026-09-11 20:39:59 +08:00
suzmii fcaf1a8e91 补坑位记录:新增文件后必须重跑完整 typecheck
- pitfalls.md 新增 2026-09-11 条目,记 lint-staged 只做 eslint 与 prettier、不做 tsc,因此 pre-commit 绿不等于类型正确
- 按体例写现象(第 113 行 TS2322、ref 元素类型不匹配)、原因(typecheck 是在新增该文件之前跑的)、处理(每新增或改名源文件后重跑完整 typecheck,门禁顺序固定为改完 → 全量 typecheck → 定向测试 → 其余门禁)
- 验证写明:修复后 exit 0、修复前 exit 2,而定向测试修复前后都是 22 passed,说明测试通过推不出类型正确;并推广到未使用 import、类型层 props 不匹配、只有 tsc 才发现的 .tsx 泛型推断失败
- 该文件原本结尾没有换行符,追加后上一行必须重写为带换行,因此 diff 记为 11 增 1 删;已逐字核对被删行原样出现在新增行中,实际无内容丢失
2026-09-11 20:39:19 +08:00
suzmii 0ef1a4ec15 接线资源筛选浮层并把资源卡旁两个浮层改成互斥
Project CI / Repository checks (pull_request) Failing after 9s
Project CI / Backend tests (pull_request) Failing after 10s
Project CI / Frontend tests (pull_request) Successful in 3m16s
Project CI / Native shell tests (pull_request) Successful in 19m58s
- index.tsx:新增 resourceFilterOpen 与 resourceFilterTags 两个 state,区域不新增 state(唯一新增的筛选相关 state 是已选标签)
- index.tsx:可见资源由内联关键词过滤改为调用 filterCanvasResources,维度为区域 / 标签 / 关键词;区域由 resourceBookState 派生(不在分页栏目里时为「全部区域」),因此用户用滚轮 / 总览卡片 / 下一页换栏目时区域自动跟随,无需双向同步
- index.tsx:标签库按「区域 + 关键词」派生并故意不含已选标签——若拿已按标签收窄的集合派生,选了一个标签后列表只剩该标签,用户再也选不出第二个标签,AND 多选会形同废掉
- index.tsx:Dock 上紧邻搜索按钮新增筛选按钮,复用 game-resource-book-zoom-button 与 is-active 口径;两个浮层共用同一个右下角锚点,因此开筛选即收起搜索、开搜索即收起筛选
- index.tsx:挂载 ResourceFilterPanel,区域变更转交既有 openResourceBookChild(与栏目切换同一条路径),关键词与搜索浮层共用同一份 searchText
- resourceCanvasFilterModel.ts:resourceFilterIsActive 只看关键词与标签、不含区域——区域就是画布当前栏目,用户换栏时它照样在变,算进高亮会让按钮在「只是换了一栏看」时也常亮
- 新增 resourceFloatingPanelModel.ts 与其测试:资源卡旁「快速编辑」与「生成动画」的互斥规则收在一处并被两个开启入口共用,修掉「先开生成动画再点快速编辑两个浮层并存」的缺口;关闭仍只关自己,生成收尾不会吞掉用户刚打开的另一个面板
- 断言:新增 5 条互斥用例(含两个方向各自一条与来回点击序列),加上既有筛选用例;变异验证实测——把互斥模型的 closed 写成恒为 quick-edit(等价只修一个方向)→ 4 failed,还原后 5 passed
2026-09-11 20:29:45 +08:00
suzmii cfd89ba570 ③ 驱逐用例抗并发负载:一次灌满替代 48 轮逐张等待
- 现象:驱逐用例逐张 waitFor 48 张(3 个读取槽 + 48 轮 act/轮询)在同树多个写者并发跑测试时,会把用例 5s 超时耗光(Test timed out in 5000ms),红的是超时不是断言;
- 改法:48 张一次性入队,只等"读取都已发出"(read 次数 ≥ 49),不再逐张断言 48 个卡的状态;
- 原因说明:驱逐本来就会连锁(目标卡被驱逐 → 补扫描重读 → 再挤掉一张未被观察的填充卡),逐张断言会在链条行进中读到 undefined,红的是时序不是契约;
- 核心断言一条不动:目标卡必须被重新请求(读次数 1 → 2)且最终回到 loaded;本用例超时放宽到 60s、内部 waitFor 20s;
- 验证:改动前后各连跑 6 轮 + 4 路并发同时跑两个用例文件,全绿。
2026-09-11 20:23:10 +08:00
suzmii a0d1fca30a 拒收提示不再被"进入项目"那一次清除吃掉:只在真的换项目时清
- 现象:打开项目这一提交里刚落下的拒收提示,会被同一次 [projectPath] 变化的清除 effect 清掉 —— 资产命令在项目刚打开后马上推送清单快照正是主场景,提示于是时有时无;
- 改法:新增 manifestMergeNoticeScopeRef 记录上一次观察到的项目路径,undefined → 路径(首次进入)时不清;只有真正的 A→B 换项目或关掉项目才清提示与在飞标记;
- 断言:新增"换项目必须清掉上一个项目的拒收提示"用例(确定性反向钉子,防止把清除整个删掉);
- 用例加固:拒收提示的三处断言改为每轮重新查询 DOM(阶段切换时 React 可能替换节点,抓着旧引用会在负载下瞬时红),异步恢复等待放宽到 4s;
- 说明:这次加固的起因是一次偶发红(expected undefined to be 'revision-conflict')。事后在改动前后各跑 18/28 轮未能复现"去掉护栏必红",所以此处不声称护栏就是那次偶发的成因;护栏本身修的是同一提交窗口被清除这一确定性缺陷,长时压测(4 路并发同时跑两个用例文件)全绿。
2026-09-11 20:22:52 +08:00
suzmii d3b341948a 文档收口:工具条动作放行口径、生成动画接通与「改造 = 快速编辑」
- pitfalls.md:把「资源画布工具条的『改造』『角色动画』按钮点了没反应」按四不写改成已收口状态(不再写"尚未修"):生成动画已接真实链路 + 参数入口为什么关、改造由快速编辑承担、其余 5 个动作逐项不做的证据;补记两条连带经验(复用共享面板时要逐个确认"可选参数在该宿主是否影响请求";面板的泥点价就是提交按钮本身,档位常量必须与后端固定值一致)。
- decision-log.md:新增 2026-09-11 条目,记录四条决策(接通生成动画的门禁与链路、复用共享动画面板并关参数入口、改造认定为快速编辑、其余动作不接的逐项证据)与三条变异验证结果。
- AGC 技术方案「操作边界」:补 4 条当前口径(动作集合按"放进来就必须真能跑通"放行、生成动画的面板与固定档位、改造=快速编辑、未接通动作各自缺什么)。
- Issue #309 与 PR #316 正文未改:远程写操作需用户确认,decision-log 里已记录这两处需要怎么改。
2026-09-11 20:09:51 +08:00
suzmii e765c9f8a6 资源卡工具条接通「生成动画」:只放行真能跑通的角色图派生
- resourceCanvasToolbarModel:只有在「已登记的栅格角色图」上放行 character-animation
  (非角色 / 未登记为正式素材 / 非栅格三类都不放行;Rust 侧角色动画要解码源图取宽高,SVG 必失败);其余动作仍按 opt-in 不渲染,并写明不做它们各自的阻塞证据。
- index.tsx:把源解析与 revision 缓存抽成 resolveResourceDeriveSource,快速编辑与生成动画共用,避免同一个源在两条链路上得到两份 manifest 视图;新增生成动画的源/浮层状态、请求身份(与快速编辑同一套「提示词变了才重铸」口径)与打开/提交/关闭时机。
- 面板复用共享 ImageCanvasCharacterAnimationPanelView,参数入口显式关闭(比例/时长/清晰度与模型由客户端 Rust 固定为 same/720p/32 帧/4 秒/seedance2.0-fast,改它不改变请求);档位常量与 Rust 常量同值,泥点价按同一算法算,避免按钮上出现与实际计费不符的数字。
- 「改造」(redraw) 不再另起入口:其语义由既有「快速编辑」承担,接独立按钮只是同链路重复入口。
- 断言改写(四不写):角色资源那条由「只放行快速编辑与下载」改写为「放行生成动画」,并新增三条反向守卫(非角色图片 / 角色图未登记 / 非栅格角色图均不放行)。
- 端到端新增「从角色资源卡生成动画」:断言 editKind/generationMode/prompt/assetName/sourceAssetId 与泥点价档位,并断言参数入口不存在。
2026-09-11 20:08:22 +08:00
suzmii 4be88f177f 角色动画面板新增可选参数入口开关:AGC 固定档位不再渲染假控件
- 新增可选 prop showParameters(默认 true):为 false 时不渲染「比例 / 时长 / 清晰度」
  入口按钮与对应浮层菜单,默认值等于网页端美术画布今天的渲染,行为逐字不变。
- 关闭理由写进注释:宿主若不能把这三个值送进请求,渲染出来就是点了不生效的假控件。
- 单独成一个提交,便于共享面板被其它改动覆盖时单独摘取或回退。
2026-09-11 20:07:38 +08:00
suzmii 5f03f050ad 修正筛选浮层 panelRef 的元素类型,补回漏跑的类型检查
- ResourceFilterPanel.tsx:panelRef 由 HTMLElement 改为 HTMLDivElement。该 ref 挂在外层 div 上,React 的 Ref<HTMLDivElement> 不接受 RefObject<HTMLElement>,此前 typecheck 报 TS2322
- 漏跑原因:提交浮层组件那一笔时,我是在新增该文件之前跑的 typecheck,之后只跑了定向测试与 pre-commit 的 lint-staged(它对暂存文件只做 eslint 与 prettier,不做 tsc),因此这个类型错误当时没有被拦住;补跑 ai-game-creator-shell:typecheck 后修复
- 行为不变:只收窄 ref 的静态类型,关闭口径与渲染结构都不动;定向测试复跑 22 passed
2026-09-11 20:07:04 +08:00
suzmii 86d55a1aba 补资源筛选浮层的 PRD 条款与共享决策记录
- docs/prd/...PRD-2026-07-20.md:§5.2.4 新增资源筛选条款——按需弹出的独立浮层取代常驻筛选条(常驻条已移除、不回归)、固定三个字段(查找素材 / 所在区域 / 自定义标签)与各自口径、不提供「状态」字段及其理由、区域既是筛选维度也是画布当前栏目且必须是同一份状态、两种区域语义共用一份过滤实现、浮层与搜索浮层同锚点不得同时展开、关闭与高亮口径、筛选后空态要求
- docs/project-memory/shared-memory/decision-log.md:新增 2026-09-11 决策条目,按体例写背景 / 决策 / 影响范围 / 验证方式 / 关联文档
- decision-log 如实记录:已落地的部分是共享外壳、纯函数与 AGC 侧组件样式(各自独立提交),而 index.tsx 的 3 处接线因该文件被另一条线占用尚未提交,并写明接线完成后应补记——不把未落地行为写成已实现
- decision-log 记录「状态」字段不做的三条证据链(manifest 资产无 status、资源投影无 status、唯二候选任务状态与附件导入状态在投影里已被上游过滤故恒单值),以及区域用派生而非新增第二份 state、「全部区域」复用既有所有资源分组网格的取舍
- decision-log 记录 PlatformResourceFilterBar 不碰不扩的理由(横向常驻条且被主站参考图弹窗在役使用)与既有断言零放宽、三条变异验证实测结果
2026-09-11 20:04:33 +08:00
suzmii 149c0b591a 冷启动首屏:热预取过可见性判据,相交卡按视口内 / 余量圈两档放行
- A 热预取不再按投影顺序盲取前 N:eager effect 现在只预取已登记且几何上可见(档 0 / 档 1)的卡,按两档顺序入队,eagerPreviewLimit 仍是硬上限;
- A 保留首屏兜底:一张可见卡都判不出来时(卡片尚未注册 / 容器还没布局出尺寸)退回原顺序预取,绝不把首屏热预取削成 0;
- B 新增 viewportBandOfElement:0 = 视口内、1 = 只落在 rootMargin(160px) 圈里、2 = 放行范围外(含量不出尺寸);门禁与放行顺序共用同一套几何口径;
- B 新增 requestPreviewCardsByViewportBand:先档 0 再档 1,只改入队顺序、不改总量(档 1 在同一次调用里一样放行),3 槽 / 48 项 / 64 MiB 合同不动;
- B 兜底扫描改用两档放行(门禁语义不变:只有档 0 / 档 1 才放行),不会退化成"只补第一档";
- B observer 回调一次报来十几二十张相交卡时,只把"视口内"的插到前面,其余一张不丢,既有"相交即放行"语义不变;
- 新增用例:视口外卡片不得进入热预取(改前首屏 distinct 读 20 张、含 8 张视口外;改后 12 张全部可见);相交一次性放行时先视口内后余量圈且总量不变(6 张全放行);
- 变异验证:整份还原成改前版本 → 两条用例分别红(20 vs 12、放行顺序反了);只把 observer 回调改回单趟 → 只有两档那条红。
2026-09-11 19:58:09 +08:00
suzmii 1c43f2c2b2 新增资源筛选浮层组件与右下角锚点样式
- ResourceFilterPanel.tsx:AGC 侧筛选浮层,领域规则留在这一层——区域取值就是画布当前栏目(只把选择交回宿主,面板自己不改栏目也不另存区域),关键词与画布搜索浮层共用同一份状态,标签走 AND 语义;共享层只提供浮层外壳与表单原语
- ResourceFilterPanel.tsx:不渲染「状态」字段,并按注释写明理由——manifest 资产与资源投影都没有状态事实源,唯二候选(任务状态、附件导入状态)在投影里已被上游过滤,硬用即恒单值假筛选
- ResourceFilterPanel.tsx:「点外部 / Esc 关闭」按既有搜索浮层口径在 document 上同时接管 click 与 Escape,点触发按钮不被判成外部;不复用美术画布那个 dismiss hook,因为它判定焦点是否在浮层里用的是自己的 portal-menu 类,本浮层不匹配会导致 Esc 关闭后焦点不回触发按钮
- ResourceFilterPanel.tsx:区域下拉由 RESOURCE_FILTER_REGION_OPTIONS 派生,显示名与 @ 面板同一份口径;标签 chip 用既有 platform-category-chip 类,选中态不另造样式
- resourceCanvasFilterModel.ts:筛选生效判据 resourceFilterIsActive 落在模型层,供宿主给 Dock 按钮打高亮(条件不随浮层收起一起消失);纯空白关键词不计入,避免按钮为一次空输入长期高亮
- styles.css:新增 .game-resource-filter-panel 右下角锚点(与搜索浮层同一 bottom / z-index 口径,向上展开),定位留在宿主,共享浮层外壳保持不自带 position
- tests/resourceFilterPanel.test.tsx 与 tests/resourceCanvasFilterModel.test.ts:覆盖三字段渲染且无「状态」占位、区域选项取值与中文显示名逐个钉住、关键词回显与回调、区域切换只回调、标签受控多选与 aria-pressed、空标签库隐藏该字段、关闭键 / Escape / 点外部 / 点触发按钮四种关闭口径、Escape 后焦点回触发按钮、筛选生效判据
2026-09-11 19:55:22 +08:00
suzmii 70256c7585 新增资源画布筛选纯函数与三条变异验证用例
- resourceCanvasFilterModel.ts:把画布筛选收成一处纯函数——区域判据做恒等比较表达「只筛当前区域」,「全部区域」放行全量;标签复用共享 assetTagsMatchSelection(AND 语义);关键词沿用既有搜索的名称 / 路径 / 媒体类型 / 任务名四字段与大小写不敏感口径,使搜索浮层与筛选面板对同一关键词给出同一结果
- resourceCanvasFilterModel.ts:区域选项由 PROJECT_RESOURCE_CANVAS_SECTIONS 派生,中文显示名复用 @ 面板那份唯一权威口径 resourceReferenceCategoryLabel,不为「项目版本」以外的栏目另写译名表
- resourceCanvasFilterModel.ts:标签库只统计当前区域内的资源,切换区域后不残留只属于别的区域的标签
- tests/resourceCanvasFilterModel.test.ts:覆盖区域选项顺序与显示名、空筛选不重排、区域恒等比较、关键词四字段与空白归一、标签 AND 语义、无标签事实源资源的口径、三维度叠加、标签库计数与排序
- 三条变异验证实测均变红后还原:把区域判据改成恒 true → 4 failed;去掉关键词判据 → 3 failed;去掉标签判据 → 4 failed;还原后 13 passed
2026-09-11 19:50:19 +08:00
suzmii 1be5218aeb kind 别名表查表加 Object.hasOwn 守卫:原型键不再被误当成别名
- `canonicalGameCreationAppAssetKind`(TS)此前用对象字面量直接下标查别名表:`kind` 是外部输入,`constructor` / `__proto__` / `toString` / `valueOf` / `hasOwnProperty` 会命中 `Object.prototype` 上的成员,被当成 canonical kind 返回(`__proto__` 返回原型对象本身);Rust 侧是 `match` 字面量,只会落 `image` 兜底,同一输入两侧分叉
- 改用 `Object.hasOwn(GAME_CREATION_APP_LEGACY_ASSET_KINDS, normalized)` 先确认命中的是表自己的键,再取值;`noUncheckedIndexedAccess` 下仍保留一次取值后的 truthy 判定
- 补断言(无断言的边界修法下次会被改回去):`gameCreationApp.test.ts` 对 `constructor` / `__proto__` / `toString` / `valueOf` / `hasOwnProperty` 逐条断言 `canonicalGameCreationAppAssetKind` 落 `image`、`gameCreationAppAssetCategoryForKind` 落 `unclassified`
- 跨语言对照:`assetKindCanonicalMapping.test.ts` 的 `decided` 表新增 `constructor` 与 `__proto__` 两行(`image` / `unclassified`),与 TS 同一份口径对照
- Rust 侧同步断言:`game_creation_app.rs` 的 `asset_category_mapping_covers_every_canonical_kind` 补 `constructor` / `__proto__` / `toString` / `valueOf` 必须落 `image` 与 `unclassified`,把「两侧一致」这件事写进两侧各自的用例
- 边界范围:只收口「原型成员被当成别名」这一种极端输入;`kind` 的正常词表、大小写口径与读时自愈规则不变,manifest 字段构成与顺序不变
2026-09-11 19:44:04 +08:00
suzmii eb6f72ab9d 新增共享筛选浮层外壳 PlatformFilterPanel
- packages/shared/src/components/PlatformFilterPanel.tsx:新增按需弹出的筛选浮层外壳,只承载通用表现——竖向字段容器、右上角关闭键与 role=dialog 语义;字段名与控件全部由宿主经 children 传入
- 字段排布约定收敛到 PlatformFilterPanelField:字段名在上、控件在下,可用 controlId 把字段名与控件关联;字段名复用既有 PlatformFieldLabel,不另写标签样式
- 不下沉任何领域规则:外壳不知道区域 / 标签 / 状态等概念,不接后端,不算过滤;筛选取值与判据都由宿主持有,因此同一外壳可在不同页面各装一套字段
- 不自带 position:定位与层级由宿主经 className 注入,避免共享层猜宿主布局
- packages/shared/src/components/PlatformFilterPanel.test.tsx:覆盖 dialog 语义与关闭转发、字段逐个渲染且不出现外壳自带的「状态」占位字段、controlId 字段名关联、以及宿主定位类注入且组件不自带 absolute / fixed
2026-09-11 19:43:51 +08:00
suzmii d4e7b8bbaf 队列满时被吞掉的按需请求留痕:点播放没反应不再是静默
- requestPreview 在队列达上限且没有可顶掉的可见性预取时,按需请求(detail/play)此前直接 return,用户在可见卡上点播放没有任何请求与痕迹;
- 新增 droppedRequestsRef:单调计数 + 最近一条明细(identity / reason / queueLength / at),并补一条 [preview-queue] console.warn;
- previewQueueSnapshot 暴露 droppedRequestCount 与 lastDroppedRequest,排障与用例可直接观测;
- 可见性预取撞上限属于设计内背压(下一轮兜底扫描会补),不计入丢弃口径;
- 队列上限、3 个并发槽、优先级顺序(play > detail > visible)全部不动;
- 新增用例:压满 96 条 detail 后 play 被丢弃必须留下标记,且 visible 背压不计数。
2026-09-11 19:35:25 +08:00
suzmii 56a8c2c32c 驱逐仍可见的卡之后补一次可见性复核:图片不再凭空消失
- publishPreview 记录本轮驱逐条数,驱逐发生后在 previewsRef 落盘之后补一次 sweepVisiblePreviews;
- 被驱逐的 identity 状态正好回到 undefined,而 sweepVisiblePreviews 只对 undefined 的卡重新入队,此前只差不这一次调用;
- 调用点必须在 previewsRef.current = next 之后,否则扫描看到被驱逐卡仍是 loaded 而不做任何事;
- 注释写明重入有界:重新入队走 publishPreview({status:'loading'}),不进 loaded/failed 驱逐分支,递归深度恒为 1;
- 只在真的发生驱逐时补扫,普通 loading/loaded 发布不增加扫描开销;
- 新增用例:灌满 48 张缓存把仍可见的目标卡挤出(LRU 不看可见性),断言该卡被重新请求读成 loaded。
2026-09-11 19:28:47 +08:00
suzmii f88e6aa5fe 清单快照被拒收不再静默:可见提示 + 强制重读磁盘真相
- WorkspaceLauncher:applyManifestSnapshot 拒收分支此前直接 return,改为必须先留痕(console.warn 记 decision/source/两个 revision)再走恢复;
- 新增 manifestMergeNotice 状态与提示条,DOM 暴露 data-manifest-merge-decision / -stage / -source / -held-revision / -snapshot-revision 作为排障观察点;
- 新增 recoverRejectedManifestSnapshot:重读磁盘 revision + 清单并重建 merge 状态,把被拒快照漏掉的新资源真正带进项目上下文;
- 同一项目只允许一条恢复在飞,避免拒收成串到达时打满读取槽;
- 重读期间若有更新的快照已被接受,绝不回退 revision(宁可报未解决,不放宽 CAS);
- scope-mismatch 仍只记日志不弹提示:串项目的快照对用户没有可执行语义;
- projectResourceLiveUpdateModel:新增拒收判定、三阶段文案与 rereadAuthoritativeProjectManifestSnapshot(两次读 revision 夹一次读清单,撕裂就放弃);
- styles.css:新增 .launcher-manifest-merge-notices 绝对定位提示位,容器不吃点击,与 .game-resource-book-notices 同口径;
- 新增 apps/ai-game-creator-shell/tests/workspaceLauncherManifestMerge.test.tsx:拒收可见 + 重读采纳 + 重读失败报未解决 + 不回退 revision + 串项目快照保持静默;
- projectResourceLiveUpdateModel.test.ts 追加拒收可见性/重读三读/撕裂放弃/跨项目拒绝/revision 非法/三阶段文案可区分六条断言。
2026-09-11 19:23:25 +08:00
suzmii ee8e12783e 校准读时重写的文档口径:写回一次 + 丢弃可见,并补一条口径变更决策
- decision-log 2026-09-10 那条把「不写迁移脚本、不删除、不重置」改成当前事实:读时会把 legacy 分区(art / code)坐标归并到新分区并写回一次 sidecar(revision +1);不写迁移脚本、不重置坐标位置;但无法归并的坐标会被跳过(跳过条数在画布提示条上可见)。

- 新增 2026-09-11 decision-log 条目:背景(审计查实「读时原地重写」112 条 + 27%(66 + 16)丢弃与旧文档字面冲突)、决策(用户选 A:保留重写、不静默、改文档)、影响范围、验证方式(含两处变异验证)、关联文档。

- 技术方案【GameAgent资源自由画板与快速编辑】:同一句改成读路径写回一次 + 无法归并的坐标被跳过且可在提示条 / data-resource-canvas-layout-* 上读到。

- PRD【项目开发工作台】第 325 行同一句同样校准(避免权威文档里留着「不写迁移脚本、不删除、不重置」这句会误导排障)。

- 测试用例【AGC资源工作台V3端到端验收】S21 台账那格补上「存量项目第一次打开时读路径也会写回一次、并给一次性提示条」,避免把 revision 从 1 开始误判成异常。

- 行为、sidecar schema(v1)与跨端契约均未改动。
2026-09-11 19:19:51 +08:00
suzmii e15456ea14 读时丢弃的坐标不再静默:提示条带条数并在 DOM 上分开三类计数
- readReport 增加 droppedMissingResource(资源已不在 manifest)/ droppedSectionMismatch(分区与资源分类不匹配)/ dropped(两者之和),判据仍是 normalizeResourceCanvasPosition,未改 resourceCanvasLayoutModel 的丢弃语义。

- 文案补上跳过条数与两类原因:归并与丢弃同时发生时用「另有 N 条坐标无法对齐已跳过(…)」;只有丢弃时用「打开项目时有 N 条坐标无法对齐已跳过(…)」。

- 提示条暴露 data-resource-canvas-layout-normalized / -dropped / -dropped-missing-resource / -dropped-section-mismatch,供排障直接读 DOM;完全没丢时四个值都是 0。

- 读盘触发条件放宽为「有归并或有丢弃」,避免只有丢弃时什么都不提示。

- 断言:读盘统计用例覆盖三种情况分离;文案用例覆盖四形态(含真机 112 / 66 / 16 口径);appSurface 用例断言四个 data-* 与完整文案。

- 变异验证:把 dropped 计数改成恒 0 → 统计用例、读盘报告用例、提示条用例三处变红;去掉 data-resource-canvas-layout-dropped → 提示条用例变红。
2026-09-11 19:18:12 +08:00
suzmii 18361f8585 读时归并不再静默:打开项目归并旧分区坐标后给一次性提示
- useProjectResourceCanvasLayout 新增读盘只读统计 inspectProjectResourceCanvasLayoutRead 与文案 describeProjectResourceCanvasLayoutRead。

- 读盘判定 changed 时写入 readReport(项目作用域身份 + 排序模式 + 旧分区归并条数),判据复用 normalizeResourceCanvasPosition,只统计、不改写回与丢弃语义。

- index.tsx 在既有 game-resource-book-notices 提示层内渲染一次性提示条,复用 .game-resource-live-notice 样式与「知道了」关闭按钮;每个项目只提示一次,切换项目清空。

- 断言:useProjectResourceCanvasLayout.test.ts 覆盖归并时 readReport 与文案、无需归并且不写盘、只补新资源落位的写回不报读时统计;appSurface/project-development.suite.ts 覆盖提示条出现(data-resource-canvas-layout-normalized、可关闭)与无需归并时不出现。

- 变异验证:去掉 setReadReport → 上述读盘统计与提示条用例变红。
2026-09-11 19:14:44 +08:00
suzmii 27be57bf68 修正上一条提交里错位的别名表用例行
- 现象:提交 3082c3e85 把两条新增的 `decided` 表行(`'UI'`、`'font'`)插到了 `test.each(decided)` 的实参位置,`test.each` 拿到的是对象而不是回调,原来 7 条 `decided` 用例全部报 `fn2 is not a function`
- 成因:pre-commit 的 lint-staged 对**部分暂存**文件做「隐藏未暂存改动 → 跑 prettier → 还原未暂存改动」时会重新落位 hunk,本次两条新增行落错了位置;该文件当时是部分暂存(提交 ① 只暂存了其中 3 个 hunk),因此被波及
- 修法:把两条行移回 `decided` 数组内的正确位置,`test.each(decided)` 恢复成「表 + 单回调」形态
- 本提交只暂存这一个文件(不部分暂存),避开同一成因
2026-09-11 18:57:42 +08:00
suzmii d9822eca05 同步资源分类口径的文档:读显示 / 写回两个口径、别名表大小写不敏感
- PRD 的分类取值段落改为「读显示 / 写回两个口径」:写清跨端同构的两个函数名、Agent 投影不许再透传落盘 `category`、面板保存回传落盘原值、别名表大小写不敏感(`"UI"`、`font`)
- 技术方案(AI游戏创作智能体App实施计划)的 manifest `category` 段落同步:`"UI" → ui-interaction`、`font → document`,其余映射不到的 kind 才落 `unclassified`;补上两侧口径函数名
- `pitfalls.md`:把「补别名只影响新登记,不重算 `category`」这条坑更新为已修形态,并写明「同 kind 重登记禁止动 `category`」这条必须保留的不变量;「读时重派生」条目补上两个口径不能混用与跨端必须同构两条
- `pitfalls.md` 新增条目「大写 `UI` / `font` 不在 alias 表 → 8 条真机 UI 资产永远落「待归类」,且自愈救不回」,记录成因链与三层守卫
- `decision-log.md` 新增决策条目:口径拆两半、自愈不下沉到反序列化、别名表大小写不敏感、重登记重派生但不抹掉显式分类,含变异验证与影响范围
- 端到端验收用例文档的 S5 栏目归属补一句:判定「编辑标签后分类有没有被改」必须比 manifest 文件,不能拿自愈后的显示值当落盘值
2026-09-11 18:49:32 +08:00
suzmii f476eb89e5 同路径重登记:kind 变化时重派生 category,同 kind 时保留落盘分类
- `register_local_asset_entry`(`assets.rs`)命中同 `localPath` 的既有资产时,旧实现只覆盖 `kind` / `media_type` / `source`,**从不重算 `category`**:`kind` 变了而 `category` 停在旧值,且陈旧的非 `unclassified` 值会被读侧无条件信任(自愈只在落盘值是 `unclassified` 时才触发),该资产就永远停在错误栏目
- 修法:只在 `existing.kind != kind` 时重派生 `category`;`kind` 未变时刻意不动 `category`——落盘分类是权威值,同 kind 重登记不得抹掉它
- `register_local_asset_records_existing_asset_with_canvas_source` 补上 `category` 断言:首次登记 `kind:"character"` 落 `character`,重登记改成 `kind:"ui"` 后必须变 `ui-interaction`(旧用例只断言 `kind` 与 `source.kind`,正好走更新分支却漏掉 `category`,所以这条错位一直没被抓住)
- 新增用例 `register_local_asset_keeps_explicit_category_when_kind_is_unchanged`:显式设成 `audio` 后同 kind 重登记,`category` 与 `tags` 必须原样保留,把「不能无条件重派生」这条不变量钉死
- 新增用例 `register_local_asset_derives_category_from_real_write_side_kinds`:用与现役写入侧逐字一致的字面量(`"UI"` / `"font"`)走真实 `register_local_asset_at`,断言落到 `ui-interaction` / `document`;它属于同一套 `register_local_asset_*` 写 API 的派生行为,因此与上面的更新分支断言放在同一个提交
2026-09-11 18:48:03 +08:00
suzmii 3082c3e854 kind 别名表大小写不敏感并收口 font:堵住 8 条 UI 资产永远落「待归类」
- `canonical_game_creation_app_asset_kind`(Rust)与 `canonicalGameCreationAppAssetKind`(TS)改为对 trim 后的小写值查别名表与 canonical 目录,修正「UI 设计资产的现役写入侧写的是大写 `"UI"`」这条真机事实
- 别名表补 `font → document`:字体上传(`ttf / otf / woff / woff2`)登记的 manifest `kind` 就是 `font`
- 为什么必须在别名表收口、读时自愈救不回来:`"UI"` 与 `"font"` 的派生结果本身就是 `unclassified`,自愈规则「派生值不是 unclassified 才触发」永不成立;真机 8 条 `kind:"UI"` + `mediaType:"application/json"` + `localPath:"ui/UI 设计 N.json"` 与写入路径逐字段吻合
- 改写 Rust 断言 `game_creation_app_asset_category_for_kind("UI") == Unclassified` → `UiInteraction`,并补 `canonical_...("UI") == "ui-design"` 与 `font == Document`:旧断言把这条 bug 钉成了「期望行为」,新形态才是唯一真源——写入侧真实写出的 kind 必须能落进明确栏目,否则真机资产永远归不了类
- 改写 TS 断言 `gameCreationAppAssetCategoryForKind('UI') == 'unclassified'` → `'ui-interaction'`,理由同上
- `game_creation_app.rs` 里「现役写入侧仍会写出这些非 canonical 值,必须在这里收口」的注释此前只收口了小写 `ui`,与大写写入侧矛盾,现按注释本意收口
- 新增写侧→分类的端到端契约用例:`resource_bridge.rs` 的 `bridge_is_idempotent_and_installs_source_image` 走真实生产函数 → 真实 `register_local_asset_at(..., "UI", ...)` → 断言落盘 `category` 为 `ui-interaction`
- 新增 `assetKindCanonicalMapping.test.ts` 的「写侧 kind 字面量 → 分类」用例组:直接解析 UI 编辑器写侧源码第 3 个实参,写点换个新字面量就会红,防下次再漏
2026-09-11 18:46:58 +08:00
suzmii 576786324f 编辑标签面板回传落盘原值:堵住「只改标签」静默改分类
- 新增 `gameCreationAppAssetPersistedCategory`:写回口径,只做「缺失或非法 → 按 kind 派生」兜底,不套用读时自愈;它必然等于 Rust 反序列化后的落盘 `category`
- `ResourceClassificationPanel` 的分类初值从 `gameCreationAppAssetCategory`(读显示口径、会把落盘 `unclassified` 自愈成派生值)改为 `gameCreationAppAssetPersistedCategory`,保存时回传落盘原值
- 修掉真机上「同一 `kind:"ui"` 资产同时存在 55 条 `unclassified` 与 2 条 `ui-interaction`」的漂移签名——后者正是自愈值被回写持久化的结果
- 面板注释更正:原注释写「保证『只改标签』不会顺带改动分类」,但那行读的是自愈后的值,这个保证是假的
- `gameCreationAppAssetCategory` 的文档补上「这是读显示口径,回写必须用 `gameCreationAppAssetPersistedCategory`」,避免下次再混用
- 新增面板用例「保存标签不改动落盘 category:自愈值不得被回写」:夹具 `{kind:'ui', category:'unclassified'}`,断言写入命令收到的 `category` 仍是 `unclassified`
- 既有面板夹具 `{kind:'character', category:'character'}` 是自愈 no-op,永远测不出漂移,因此保留它作为对照、另加自愈夹具
- 新增契约用例「写回口径保留落盘 unclassified,读显示口径才自愈」,把两个口径的差集钉死
2026-09-11 18:45:38 +08:00
suzmii 458a65cb5e 资源分类读显示口径下沉到 Rust:Agent 投影与 UI 同构
- 新增 `game_creation_app_asset_effective_category`:读显示口径的唯一实现——落盘 `category` 权威,仅在落盘值为 `unclassified` 且该资产 `kind` 能派生出明确的非 `unclassified` 分类时采用派生值,其余信任落盘值
- `GameCreationAppAssetManifestEntry` 的反序列化刻意不做自愈:反序列化结果就是落盘原值,「编辑标签」面板要靠它原样回写;自愈只属于读显示口径
- Agent 资源投影 `bridge_registered_resource` 不再直接透传 `asset.category`,改走有效分类,修掉真机 122 条资产里 55 条「UI 显示 UI 交互、Agent 读到待归类」的跨端口径分歧
- 新增 Rust 决策矩阵 `EFFECTIVE_CATEGORY_CONTRACT` 与用例 `asset_effective_category_follows_the_shared_contract_matrix`,矩阵是「落盘值 / kind 派生 / 读时自愈」三个口径的唯一真源
- 新增跨语言契约:`assetKindCanonicalMapping.test.ts` 解析 Rust 源码里的决策矩阵与 canonical kind→栏目表,逐条喂给 TS `gameCreationAppAssetCategory` 对照,任一侧改口径都会红
- `direct_tool_bridge` 新增 Agent 侧回归用例:`kind:"ui" + category:"unclassified"` 必须投影成 `ui-interaction`
- 既有用例 `..._projects_manifest_classification_verbatim` 更名为 `..._keeps_explicit_manifest_classification`:逐字透传只在落盘值不是 `unclassified` 时成立,旧名字会与新的有效分类语义矛盾
2026-09-11 18:43:39 +08:00
suzmii c527c19bf8 校准 DirectProject 历史兼容口径的技术方案与共享决策记录
- 技术方案:把「不迁移旧 {role,content} 行 / 不会对旧格式做迁移或兼容」改成当前状态——读侧白名单兼容 legacy 行(含 tool 行只识别不注入)、写侧 DirectProject 自己的旧行写点已收口
- 技术方案:修正「Rust 是该历史文件的唯一写入方」,写清 project.jsonl 同时被通用对话链(agent_id=None 的项目主对话与 Runtime 公开状态消息)读写,并记录两条链互读兼容的规则
- 技术方案:说明为什么故意不给通用对话写入器加「Direct-owned 就拒写」的硬报错(会把旧行噪声换成任务起不来),以及这类失败为何按不可重试处理
- 技术方案:更新时间改 2026-09-11
- decision-log:新增 2026-09-11 条目,记录格式切换当年「唯一写入方 + 测试手动清理」的前提在存量用户项目上不成立、现改为「读侧白名单兼容 + 写侧统一」,以及为什么不做数据迁移
2026-09-11 18:36:29 +08:00
suzmii 2b44b62599 DirectProject 历史格式类失败给出专门提示并按不可重试处理
- direct_runtime.rs:新增 direct_project_history_shape_failure 白名单(历史记录类型无效 / 缺少 payload / 解析历史失败)
- direct_codex_failure_recovery_hint 增加专门 hint,指向真实现象与动作:该类旧格式已被读侧兼容,仍失败说明记录不在白名单内,请检查项目诊断后修复该历史文件
- direct_codex_failure_is_retryable 对该类失败返回 false,前端不再显示「可直接重试」(同一份历史每次读得到同一结论)
- 打开/读取类 IO 失败不在白名单内,仍按可重试处理
- 补断言:hint 文案、IO 失败边界、诊断 message 里的 retryable=false 与 sidecar 的 "retryable": false
2026-09-11 18:36:19 +08:00
suzmii 5ac26b99c9 显式 Codex 返回不再往项目主对话投影旧格式行
- direct_tools_mcp.rs:删除 conversation.record_codex_response 末尾往 project.jsonl 追加 legacy 行的投影,它写的是 {schemaVersion:game-creator-conversation.v1,...} 旧行,会毒化共用该文件的 DirectProject 历史
- 顺带删掉只为那条投影存在的 key/message_id 计算与描述它的注释,不留墓碑
- 该工具的事实来源仍是自己的只读 journal .agent/conversations/codex-responses.jsonl,返回内容与 conversation.list/read 行为不变
- 补断言:record_codex_response 只写 codex-responses.jsonl,project.jsonl 保持为空
2026-09-11 18:36:16 +08:00
suzmii f0d5687ab7 共享的 project.jsonl 让两条对话链互读对方的行,混合历史不再双向失败关闭
- project/conversation.rs:read_persisted_local_conversation_records_unlocked 跳过 type=response_item 且带 payload 的行(DirectProject 写给同一份「项目主对话」的行),其余解析失败仍按「解析对话记录失败」失败关闭
- 新增 is_direct_project_history_row 做行信封白名单,只认对方那一种明确枚举的形状,不放宽成「什么都跳过」
- 不把 response_item 行二次投影成 LocalConversationMessageRecord:DirectProject 侧已经拥有那份 Responses item → 聊天内容的投影,再造一份平行投影会与它漂移
- 不加「文件已属于 DirectProject 就拒绝追加旧行」的硬报错:runtime_driver/task_start.rs 在 ensure_..._accepted_public_status_at 返回 Err 时会中止后台任务(「后台任务启动确认落盘失败,任务未执行」),runtime_protocol/steering.rs:572/604/1300 也用 ? 上抛,加硬报错会把旧行噪声换成任务起不来;毒化本身已由读侧白名单堵住
- 补断言:混合文件里通用链只读自己的 legacy 行且坏行仍失败关闭、混合文件从两条链都能读(DirectProject 把两种行都读出来)、纯旧格式文件的写入行为不变
2026-09-11 18:36:07 +08:00
suzmii bfb928295e DirectProject 读侧白名单兼容格式切换前的旧行,存量项目不再全部读不动
- direct_project_history.rs:两处行信封判定收敛到 direct_project_history_item_from_parsed_line,response_item 与白名单化的 legacy 行走同一条判定路径
- 新增 direct_project_legacy_row:只认 schemaVersion=game-creator-conversation.v1、无 type、role 落在该写入器自己的角色集合(user/assistant/tool)内、content 为非空字符串的行,其余形状继续按损坏失败关闭
- user/assistant 旧行投影成与 direct_project_local_message_item 同形状的 message item:role 与 content 逐字节保留、不 trim、不改写、未知字段忽略,有 messageId 才带 id
- tool 旧行已识别但不进 Codex 上下文:它不是 Responses item,无法还原成真正的工具 item,混入会造出假的工具消息;聊天投影本来也只展示 user/assistant
- 抽出 direct_project_message_item 供本地补写与 legacy 投影共用,保证两种来源的 item 形状一致
- 补断言:旧行投影(role/content 逐字节、无 messageId 时无 id)、旧行与新行交替的混合文件按行顺序读取、tool 行被识别且不进上下文、非白名单异常行(role=system/developer、content 非字符串或为空、缺 role、缺 payload、坏 JSON)仍失败关闭、通用对话写入器产出的旧行形状落在白名单内
- 变异验证:去掉 legacy 兼容分支→投影/混合文件/通用写入器形状 3 个用例变红;把白名单放宽成「任意行都接受」→失败关闭用例变红
2026-09-11 18:35:26 +08:00
suzmii f5b381dbd8 视图切换时只取消上一视图排队中的可见性预取
用户离开某个视图后,继续为它排队读图没有任何收益,而这些排队的预取会**排在新视图按需请求的前面**(3 槽跨视图共享),把"进入总览后等图"变成纯等待。这是"进总览要等图"的主因,单张读取本身只有 5–15ms。

- `cancelQueuedVisiblePrefetches()`:只下掉**还在队列里**的 `visible` 理由任务。语义边界刻意收窄 ——
  - `detail` / `play` 理由的排队**保留**;
  - **在途请求不打断**(只占 3 槽中的 1 个,打断它拿不回已花的读盘成本,且切回来要重读);
  - **缓存与身份不失效**(不触发 `disposeAllCachedPreviews`),切回原视图不重读已拿到的图。
- 修法 4 一并收口:视图切换 effect 在取消后**立即按几何复核一次**(`sweepVisiblePreviews`),仍然可见的卡重新入队、不可见的自然不再请求 —— 因此不存在"取消后永不重试"的死角。
- 依赖使用**派生后的** `prefetchScopeKey`(不是可能为 `undefined` 的 `input.prefetchScopeKey`)。这修掉了本轮自查发现的一个真 bug:早期写法在调用方未传该 prop 时直接 `return`,整段取消逻辑成了**永不执行的死代码**;由下面的断言暴露。

断言(`tests/useProjectResourceCardPreviews.test.ts` 新增 2 条):
1. 「切视图只取消上一作用域排队中的可见性预取」:登记 6 张(3 在途占满槽 + 3 排队,其中 1 张为 `detail`),切视图后断言队列从 3 → 1、**留下的正是 `detail`**、且 `activeReadCount` 仍为 3(在途不被取消)、新视图 identity 立刻可用;
2. 「取消后再次可见会重新入队」:切走再切回,卡片重新注册且几何可见后,断言该卡**重新进入队列** —— 直接守住"不留永不重试死角"这条硬要求。

变异验证:把取消那一步去掉(effect 内只保留复核)→ 第 1 条断言立即失败(`expected [ …(3) ] to have a length of 1 but got 3`);恢复后 24/24 通过。

验证(同一时刻、同一命令的前后对照;当时树上有并发改动:`src-tauri/.../direct_project_history.rs`):
- 改动前(只 stash 我的两个文件):`86 files / 1236 passed / 4 skipped / 0 failed`
- 改动后:`86 files / 1238 passed / 4 skipped / 0 failed`(多出的 2 条为本提交新增用例)
- `src/components/image-editor` 1387 passed;typecheck exit 0;check:encoding 4388 文件;prettier 与 eslint 干净;`git diff --check` 干净。
2026-09-11 17:55:22 +08:00
suzmii 8edee9eef4 预览队列改为同级裁决:当前预取作用域优先,但不越过 play
背景:全局 3 槽跨视图共享,而 hook 的 scopeKey 只含 `projectPath + projectId`(不含视图)。用户在栏目页滚一遍会按可见性入队最多 51 个预览 job;点「回到资源总览」时它们仍排在同一个队列里,总览自己那几张「该出图」的卡只能排在后面 —— 这就是「进总览要等图」的真实来源。

- `nextPreviewJob` 改为两级裁决:**理由优先级固定 `play > detail > visible` 不变**,只在**同一理由内部**让当前预取作用域的请求优先。
- ⚠️ 早期写法用「作用域匹配则权值 +3」,会让当前视图的 `visible` 压过上一视图的 `play` —— 直接破坏 PRD §3.3.2 的固定调度优先级。这个错误是被本提交新增的契约用例抓到的,已改为同级裁决,并把该约束写进函数注释。
- 新增 `prefetchScopeKey`:优先取调用方显式传入的值;未传时退化为**被喂进 hook 的资源集合签名**,因此调用方(`index.tsx`)无需改动即可生效。
- 队列任务记录入队时的 `prefetchScopeKey`,供同级裁决使用。
- 新增只读 `previewQueueSnapshot()`:暴露「谁在排队、属于哪个预取作用域、当前活动读取数」。队列病理此前只能靠猜,有了它「当前视图的卡是否真的进了队列」可直接断言。不参与渲染、无副作用。

断言(`tests/useProjectResourceCardPreviews.test.ts`):新增「当前预取作用域越过上一作用域的排队预取」契约用例,三条:
1. 当前作用域的 `visible` 越过上一作用域排队的 `visible`;
2. **但不得越过 PRD 优先级** —— 当前作用域的 `visible` 仍排在 `play` 之后;
3. 同一作用域内仍是 `detail > visible`。

变异验证:把作用域偏好关掉(`&& false`)→ 第 1 条断言立即失败(`expected 'previous' to be 'current'`);恢复后 22/22 通过。断言不是恒真假守卫。

验证:typecheck exit 0;该测试文件 22 passed;check:encoding 4388 文件;`git diff --check` 干净。(AGC 全量与本条无关的并发改动混跑,故此处只报本文件的定向结果;全量对照见后续提交的回报。)
2026-09-11 17:44:21 +08:00
suzmii a4bec3d018 记下「不再提醒」偏好保持单键、不按入口分域的理由
Project CI / Repository checks (pull_request) Successful in 2m35s
Project CI / Frontend tests (pull_request) Successful in 3m28s
Project CI / Backend tests (pull_request) Successful in 5m48s
Project CI / Native shell tests (pull_request) Successful in 18m25s
- 该键关掉的只有聊天输入区的「发送前提醒」;资源侧生成素材与快速编辑没有可关的提醒,润色是显式按钮动作
- 分域只在「每个入口都有可关的提醒」时才有意义,现在分域等于给不存在的提醒提前留位
- 只补注释,不改行为与断言
2026-09-11 17:06:35 +08:00
suzmii 327a5d38ee 同步验收文档里 #309 C3 的口径与两条入口的润色判据
- S9 行与 7.4 差异表补一句:只读信息浮层已回归(工具条「信息」动作),C3 取消的仍只是可编辑详情面板与全屏编辑路由
- S10 / S11 行补上两处入口的「AI 润色」判据:按钮 aria-label 与 aria-busy、成功回填与「恢复原文」、失败保留原文、超类型上限截断并提示「已按长度上限截断」
- 纯文档改动,不动代码与断言
2026-09-11 17:03:04 +08:00
suzmii c2d3292769 快速编辑浮层接入提示词润色,提示词变了就重铸请求身份
- 选中工具条的快速编辑面板通过共享 composer 的注入位渲染同一个 ResourcePromptPolishSlot,场景约束为「图片素材的快速编辑提示词」
- 润色回填走 applyResourceQuickEditPrompt:与共享 composer 的 resetFailedDialogStatus 同口径,提示词变了就把上一次的失败状态清掉
- 提交侧请求身份按提示词收口:同 sourceLayerId 且提示词未变时复用(失败重试仍命中同一 operation 账本),提示词变了则重铸 operationId / 幂等键——Rust 的 request_fingerprint 含 prompt,复用旧身份会被判「已绑定到不同资源编辑请求」;正规化结果与 revision 属于别的事实,换身份时原样保留
- 打开面板时的初始身份改用统一的 createResourceEditRequestIdentity(绑定面板草稿提示词)
- 测试:live integration 补两条——润色成功回填并换身份(断言 prompt 与 operationId/幂等键同时变化)、润色失败保留原文且身份不变
- 变异验证:提交侧去掉按提示词重铸(同 sourceLayerId 直接复用旧身份)→ 「润色后换身份」用例红灯(两次 operationId 相同,实测)
2026-09-11 17:02:17 +08:00
suzmii d44e1ae8ea 生成素材弹窗接入提示词润色,并让请求身份绑住它铸造时的那句提示词
- 新增 ResourcePromptPolishSlot:素材侧两处入口共用的「AI 润色 / 恢复原文 / 润色中…」动作,取数走 AGC 侧 usePromptPolish,聊天框同款失败态(失败保留原文、可重试文案)
- 场景约束走 polish_local_project_prompt 的 context:「这是素材生成提示词(音效)」+「只润色这一句、不要扩写成需求/方案/验收标准、不要新增事实」(本批不动后端提示词文件),文案落在 resourceCanvasPromptPolishModel
- resourceEditModel 新增 truncateResourceEditPrompt:超上限先按类型截断(背景音乐 140 / 音效 1900 / 视频与角色动画 4000 / 其余 32000)再回填,并在状态行给出「已按长度上限截断」,不静默丢内容;后端上限未改
- resourceEditModel 新增 resolveResourceEditRequestIdentity:请求身份绑定到铸造时的 prompt(Rust request_fingerprint 含 prompt,改了提示词还复用同一 operationId 会被判「已绑定到不同资源编辑请求」);提示词没变时仍复用,失败重试照旧命中同一账本
- 生成素材弹窗用注入位渲染润色动作(共享 composer 只提供插槽,不上 Tauri 调用),提交时按同一口径解析身份;原先只在这一处使用的 createResourceCanvasGenerationRequest 随之下线
- 样式落在 resourceCanvasChrome.css(resource-prompt-polish 一族),弹窗与 composer 共用一行
- 测试:模型层补截断边界与身份复用/重铸;弹窗层补润色回填与上下文、超限截断提示、失败保留原文、失败后提示词与润色一起锁定且重试复用同一请求
- 变异验证:截断改成恒等 → 模型用例与「超限截断」用例双双红灯(实测字段长度 206 ≠ 140);身份去掉重铸 → 模型用例红灯
2026-09-11 16:58:49 +08:00
suzmii 62029fdc23 给共享生成 composer 加一个可选的提示词动作注入位
- ImageCanvasBasicGenerationComposerView 新增 promptActionSlot(可选):不传时整块不渲染,默认 DOM 与行为逐字不变
- 注入位紧跟在提示词输入框之后,节点由宿主提供并自己负责在 composer 网格里的落位;共享组件内不做任何 Tauri 调用
- ImageCanvasQuickEditPanelView 原样透传该 prop,网页端两条调用链不传即不受影响
- 测试:composer 与快速编辑面板各补一条「不传时无占位节点、传入时落在提示词之后」,默认不变由既有用例继续覆盖
2026-09-11 16:54:57 +08:00
suzmii 25c55fcb7f 把提示词润色的状态机抽成 usePromptPolish,聊天输入区先迁过去
- 新增 features/project-workspace/usePromptPolish.ts:失败保留原文、首次成功落原文快照(反复润色只覆盖结果)、可重入保护、可注入的上下文与规范化回调,Tauri 调用固定在 AGC 侧(默认 requestChatPromptPolish)
- 聊天输入区改用它,行为逐字不变:润色按钮 aria-busy/disabled、Loader2/Sparkles、「润色中…」/失败文案、失败保留原文、恢复原文回到最初原文、草稿清空后重置
- 聊天特有的「发送前提醒」留在输入区:提醒偏好、本轮已确认草稿指纹、表单捕获拦截、面板内润色用另一句失败文案
- chatPromptPolish.ts 的 requestChatPromptPolish 及其单测未改动,继续作为这条 LLM 通道的契约测试
- 护栏:chatPromptPolish.test.tsx(16 条)与 resourceReferenceInput.test.tsx(20 条)全绿
2026-09-11 16:53:33 +08:00
suzmii 3de2ec734b 预览门禁去掉单点依赖:observer 必定建出,并加可见性兜底扫描
真机现场:整个「UI 交互」栏(51 项)全部只剩占位图标。根因链是**同一对文件里的两个缺陷叠加**,因此作为一个原子提交收口(拆成两半任一半都不足以独立成立:光修 observer 仍可能被回调时序漏掉,光加扫描则 observer 根本不存在时的判定精度无从恢复)。

一、确认 2a157ea6f 引入的回归:observer 可能永远不建
- 那次改动把「root 为 null」当成「不建 observer」,并指望「卡片注册」作为 root 就绪信号重建。
- 但那个 effect 每个 scope **实际只跑一次**(`requestPreview` 是稳定引用,其余依赖在 scope 内不变),一旦这一轮因 root 为 null 直接返回就再无第二次机会;
- 而「卡片注册」信号只在**后续还有新注册**时才来 —— 初次挂载时 51 张卡一次性注册完,之后再没有新注册,信号永不再来。
- 结果:observer 永不创建、所有卡永不被观察、永远停在 `idle`,整栏只剩占位图标(`categoryIcons['ui-interaction'] = LayoutGrid`)。
- 修复:**绝不拿"root 未就绪"当作"不建 observer"的理由**。拿到容器 root 最好;拿不到就退到 canvas、再退到视口(`root: null`)。视口判定严格优于"没有 observer"——屏幕上真实可见的卡一定落在视口矩形内;代价只是视口外但仍在画布内的卡晚一步由滚动触发。容器 root 迟到时再有界重试(5 次、退避到 512ms)重建一次,恢复按画本容器裁剪的精确判定。

二、系统性防线:可见性兜底扫描(去掉单点依赖)
- 门禁原先只有 `IntersectionObserver` 一条路,observer 没建出来 / 回调没送达 / 注册与创建交错,卡片就再无第二次机会。
- 新增 `sweepVisiblePreviews()`:按**与 observer 对齐的视口几何判据**(含 160px 余量)独立复核登记表,只对 `idle`(从未请求)的卡补一次 `visible`,`loading`/`queued`/`loaded`/`failed` 一律不碰。
- **不是第二条加载通路**:仍经 `requestPreview` 走同一条队列,受同一套去重、优先级、全局 3 槽与 `48` 项 / `64 MiB` 预算约束;**只补"可判定为可见"的卡,不退化成全量预读**(PRD §3.3.2 合同不变)。
- 触发点:scope 变化后 0 / 250 / 1000ms 各扫一次(覆盖晚挂载的卡)、observer 就绪后立即扫一次、每次卡片注册时扫一次、窗口 resize 与页面重新可见时各扫一次。

测试(`tests/useProjectResourceCardPreviews.test.ts`)
- 更新「等真正的 root 就绪」这条契约:它原先断言"root 为 null 时不得建 observer"——那正是本轮回归的语义,改为断言"**仍然必须建出 observer**(`root: null` 视口判定)",并要求容器 root 迟到后重建为容器 root;
- 新增「不在热预取窗口内的可见卡不得停在 idle」(20 张 / 热预取 12);
- 新增「没有任何 observer 回调时,可见卡也必须被放行」——完全不触发回调,只靠几何可见性,专门守住兜底扫描;
- 新增「视口外的已登记卡不得被请求」——守住"不退化成全量预读"。

变异验证:把兜底扫描改成直接 `return 0`(等价于去掉扫描)→ 「没有任何 observer 回调时可见卡也必须被放行」立即失败(`expected undefined to be 'loaded'`);恢复后 21/21 通过。断言不是恒真假守卫。

可观测性:`data-preview-status` / `data-preview-error` 已在位,因此"可见却 idle"这类问题以后读一个 DOM 属性即可判定,不再需要解码图片或猜调度。

验证(同一时刻、同一命令的前后对照;树上另有并发改动):改动前 `86 files / 1225 passed / 4 skipped / 0 failed`;改动后 `86 files / 1227 passed / 4 skipped / 0 failed`(多出的 2 条为本提交新增用例)。`src/components/image-editor` 1385 passed;typecheck exit 0;check:encoding 4384 文件;prettier 与 eslint 干净;`git diff --check` 干净。
2026-09-11 16:49:09 +08:00
suzmii e088ede217 让「当前使用」的资源卡真的发光,并修掉版本高亮的两处既有缺陷
- .game-resource-card.is-current-version 的外向扩散层从 0 0 0 2px /14%(那是描边)改成 0 0 0 3px /32% + 0 0 20px /34% 的真发光,边框口径仍是 1px
- 新增 game-resource-card-current-version-breathe 关键帧与 ::after 光环(只呼吸 opacity,照本页 .ui-editor-status-attention 的既有写法):声明档位 38%,呼吸区间 0.58 ↔ 1,视觉上约 22% ↔ 38%
- prefers-reduced-motion 下关掉动画但保留可见档位(opacity 0.75),收敛动画不收敛可见性
- 缺陷①:.game-resource-card.is-relation-version-binding 原本只写 border-color,而卡片本体 border: 0,规则从未生效(「点击版本卡后高亮绑定资源」这条 PRD 语义一直看不见);改为真实的 1px 边框 + ring,并把该规则移到 .is-current-version 之前,让常驻光环在两者同时命中时仍然赢
- 缺陷②:当前版本规则与 hover/focus/selected 同权重且在后,导致当前版本卡几乎看不出被选中;新增 .is-current-version.is-selected / :hover / :focus-within / .is-dragging 变体,显式提权到 (0,3,0) 并带上选中态自己那层阴影
- 测试:新增 resourceCardCurrentVersionStyle.test.ts(外向扩散层档位、呼吸实现方式与关键帧区间、选中态变体权重更高、reduced-motion 下仍可见、版本聚焦规则不再是死规则)
- 测试:resourceVersionSwitch 补 data-used-by-current-version 的 DOM 断言(命中为 "true"、未命中不含该属性、切版本后跟着换手)
- 测试夹具修正:project-development.suite 的版本夹具把 slotId 从 'player' 改成恒等映射 'asset:asset-player'('historical' → 'asset:asset-removed'),否则「当前版本」判定永远不命中、照它写断言必成假守卫;并补一条自证断言钉住它真的命中
2026-09-11 16:42:58 +08:00
suzmii 082d8d62de 把运行页签的资源信息搬到画布选中工具条,两处共用一份只读字段
- 新增「信息」动作:排在「@引用」之后、「编辑标签」之前,点开非模态信息浮层,再点一次即收起(aria-pressed 反映开合)
- 新增只读字段组件 ResourceInfoFieldsView(ResourceInfoPanelView.tsx),运行页签的「信息展示」与画布浮层共用,字段清单只在 resolveResourceInfoFieldRows 单点定义
- 字段口径:名称 / 路径 / 类型 + 只读分类与标签(分类取画布栏目口径,标签为空时给「暂无标签」),刻意不含来源任务、资产 ID 等 manifest 内部标识
- 浮层 chrome 与快速编辑浮层同族(同一锚点、同档圆角与阴影),样式落在 resourceCanvasChrome.css;关闭入口三处:动作按钮、面板关闭按钮、画布浮层统一的 Esc / 点边界外
- 关闭判据收口:resolveResourceCanvasQuickEditDismissOpen 更名为 resolveResourceCanvasFloatingPanelDismissOpen、参数改为 isFloatingPanelOpen,宿主把「快速编辑开」与「信息开」合成一个布尔
- .game-resource-info-panel 补进 RESOURCE_CANVAS_INTERACTION_SELECTOR,否则点在只读字段值上会被判成点画布空白而清掉选中
- 换选 / 清空选中 / 切项目时信息浮层自动收起:在选中派生处按 selectedResourceId 收口,不在每个重置点各补一行
- 测试:新增 resourceCanvasInfoModel 三条与「信息面板在画布浮层与运行页签里渲染同一份只读字段」(工具条顺序、开合、关闭按钮只收面板、点画布以外收起、Esc 连工具条一起收、运行页签字段逐行相同);resourceCanvasFloatingDismiss 的判据用例与交互选择器夹具改为浮层通用口径
- resourceCanvasChrome.css 的字段排版收在浮层选择器下,不跨上下文改运行页签的字号与列宽
- decision-log 新增一条:记录这次对 Issue #309 C3「取消资源详情面板」的有意局部回退(只回退「看信息」,不恢复可编辑详情面板与全屏编辑路由),并登记需要同步的 C3 引用位置
2026-09-11 16:42:40 +08:00
suzmii d80011656b 把资源卡的 @ 引用入口挪进选中资源工具条
- 删除资源卡右上角的 @ 引用圆钮,连同它的 44×44 热区伪元素与全部样式一起退役
- 选中资源的画布工具条新增「@引用」动作,排在「UI 编辑器」之后,出站负载与来源逐字未变(source: 'resource-card')
- 测试:原「卡片圆钮视觉尺寸」用例改写为「入口只留在工具条里」的源级 + 样式级断言,并补反向断言卡片组件内不再出现插入调用
- 测试:新增键盘通路用例,聚焦后回车必须真正派发 agc-resource-reference-insert,且两条通路负载逐字相同、每次激活只派发一次
- harness 注释同步:资源卡本体只剩「选中资源」与媒体播放钮
2026-09-11 16:28:48 +08:00
suzmii 3bfc3f0849 新增 AGC 资源工作台 V3 端到端验收用例
Project CI / Repository checks (pull_request) Successful in 3m14s
Project CI / Frontend tests (pull_request) Successful in 3m39s
Project CI / Backend tests (pull_request) Failing after 6m3s
Project CI / Native shell tests (pull_request) Failing after 6m22s
- 新增 docs/technical/【测试用例】AGC资源工作台V3端到端验收-2026-09-11.md
- 给出从建项、对话生成素材到运行收尾的一条主线,按 S1–S21 分步列出操作、期望结果、对应 PRD 条款、可观察判据与已知例外
- 落定每步的可观察形态:资源卡 data-preview-kind / data-preview-status / data-preview-error、data-used-by-current-version、data-resource-book-category、data-resource-view-state 与控制台判据脚本
- 记录前置条件与门禁口径:dev 态前端走 Vite HMR、Rust 侧改动需重启客户端、服务端改动需重启 api-server、build-gate 代理假红需清代理变量
- 列出只靠视频或截图表达的 7 处条款清单与补齐方式,并更正 F12 无法观察 Rust 到 api-server 的 HTTP 这一前提
- 列出已知未做与已取消项,以及文档与代码的四处偏差(资源卡打开详情按钮、运行入口提示文案、画布生成入口进度、真机落盘分类分布)
- 附验收记录表与逐步骤失败排查索引
2026-09-11 15:45:01 +08:00
suzmii 5bf348d88a 把误入上一个提交的验收文档移出提交,交回其作者
- 81482b458 原本只应包含 tests/appSurface/project-development.suite.ts 一条断言同步,
  但该验收文档当时已被预置在索引里,随那次提交一起被带走;这里只做「移出提交」,
  不改动文档内容,文档仍完整保留在工作区,由它的作者自行提交
- 未使用 --amend:按纪律不改写已有提交
2026-09-11 15:42:24 +08:00
suzmii 81482b4587 同步「编辑标签」入口文案的既有断言
- tests/appSurface/project-development.suite.ts:跟随 6bdc8bbd9 把资源卡工具条入口由
  「分类与标签」改为「编辑标签」,同步音频资源工具条那条既有断言(否则 HEAD 的树是红的)
- 同一处注释同步说明:该面板只编辑 manifest `assets[].tags`,分类不再有手动入口
2026-09-11 15:41:17 +08:00
suzmii ea9668e4a9 资源总览「所有资源」卡补上资源预览
- 现象与归因:「所有资源」卡的预览区一直是空的,原因是我在 1cc9a2381 里加的闸 `!isResourceBookAllTarget(category)`(当时为避开"同一张资源卡挂两份")让它一张卡片计划都不生成;预览卡从来只由画本场景注入,占位摞本身不含卡片
- 改为真正生成卡片计划:喂进去的仍是全量投影(与标题栏计数徽标同一份口径),但当成一摞交给栏目卡同一个选取函数 `selectResourceBookOverviewCards`——没有视图中心时按投影顺序取前 `RESOURCE_BOOK_OVERVIEW_STACK_LIMIT`(=3) 张,与栏目卡每摞上限同口径;不分类型列,避免 66 项一口气铺出十几列
- 预览用同一个 `renderCard` 与同一套 `resourceBookOverviewCardLayout` 几何渲染,唯一差别是宿主类名换成 `.game-resource-book-preview-card`:转场层按 `.game-resource-book-scene-card` 收集 FLIP 节点并以 `card:${resourceId}` 为键,同一张卡两个宿主会让两条记录互相顶掉,因此预览宿主不进收集范围(`resourceBookController` 一行未改)
- 预览宿主带 `aria-hidden` + `pointer-events: none`:无障碍树与 `getByRole` 里每张资源卡仍然只有栏目卡那一份,不会重复命中;点预览区仍走进「所有资源」页,栏目卡预览卡仍可选中
- 转场淡出集合(`resourceBookPlanKeysRef` 那段)跳过「所有资源」组,避免预览节点被当成"离开视图的卡片"按子画布坐标重新贴到画布别处
- `ResourceBookThumbnail`:该卡正文的占位摞从最多 3 摞改为对齐真实摞数的 1 摞,卡片高度回到与栏目卡同一条口径
- `styles.css`:把 `.game-resource-book-preview-card` 并进 `.game-resource-book-scene-card` 的两条既有规则(宿主几何、主态卡片圆角/阴影)与 `prefers-reduced-motion`;没有新写一套预览样式
- 断言(`project-development.suite.ts`):预览卡的 `data-resource-card-id` 序列恰等于全量投影前 3 个、条数 == `min(3, 计数徽标)`、预览里是完整卡片(视觉体 + 选中按钮)、宿主在 `aria-hidden` 子树里、宿主几何规则与栏目卡共用同一条
- 断言(新增用例):空项目下沿用既有空态「暂无资源」、预览节点 0 个、计数 `0 项`、入口行仍在,卡片不破版
- 单测(`resourceBookLayout.test.ts`):把已被新行为取代的旧断言(`all` 组不出卡片、全计划无重复卡片键)删掉,换成新合同(预览 = 全量投影前 3 张、只铺一列、各栏目摞不变),并补空投影 → 0 张
- 变异验证(两条都跑过并已还原):① 喂进去的资源改回空数组 → `AssertionError: expected +0 to be 4`;② 恢复旧闸(计数仍正确、预览为 0)→ `AssertionError: expected [] to deeply equal [ Array(3) ]`
2026-09-11 15:37:17 +08:00
suzmii 6bdc8bbd93 AGC 资源分类面板改为「编辑素材标签」:只编辑标签、分类不再有手动入口
- ResourceClassificationPanel.tsx:面板改为只编辑 manifest `assets[].tags`,移除分类 6 类
  chip 那一排;标题与 aria-label 改「编辑素材标签」,副标题由资源路径改为素材名
  (`localPath` 的 basename,仓库没有独立显示名字段)
- ResourceClassificationPanel.tsx:标签状态由「整段逗号分隔字符串」改为字符串数组,
  每个已有标签渲染成自带删除按钮的胶囊 pill;保存时把未落成 pill 的输入尾巴一并切分,
  归一化仍走 `normalizeGameCreationAppAssetTags`,回车 / 中英文逗号 / 顿号提交方式不变
- ResourceClassificationPanel.tsx:写入命令 `category` 是必填,读一次当前权威值并
  在保存时原样回传,保证「只改标签」不漂移分类;底部按钮「保存」改「保存标签」,
  删除资源入口按既有保留(截图未画,但它是唯一的资源删除入口)
- resourceClassificationTagPanel.css:新增标签 pill 样式,删除按钮嵌在 pill 内部
  (视觉图标 11px、热区 32×32、负外边距抵消不撑高 pill);若第二处出现带删除按钮的
  标签 pill,抽到 `packages/shared` 做 `PlatformRemovableTagPill`,不要复制这份实现
- index.tsx:资源卡工具条入口 label / title / 文案由「分类与标签」改「编辑标签」,
  与面板标题一致(否则入口写着分类、打开的是纯标签面板)
- tests/resourceClassificationPanel.test.tsx:断言对齐截图文案、逐个 pill 自带删除、
  点某个 × 只删对应标签、aria-label 可区分、保存 payload 的 tags 数组与 category 原样回传、
  取消不写盘、入口 label 与面板标题一致、删除热区不小于 32px
- 文档:PRD §5.3「分类取值优先级」改写为当前状态(分类没有用户手动设置入口、
  该面板只编辑标签),并新增 decision-log 条目记录本次产品决策与「用户手动设
  category 的能力就此移除」这一事实
- 说明:截图文案(编辑素材标签 / 新增标签,多个用逗号分隔 / 保存标签)在全仓检索
  无命中,属仓库之外来源,本次按用户截图对齐,不是 PRD 明文
2026-09-11 15:34:36 +08:00
suzmii 2a157ea6f8 预览可见性门禁等 root 就绪再建 observer,并把登记表补挂齐
真机现场:同一屏里 `idle` 与 `loaded` 交错,12 张相交卡从未入队(`idle` 的语义就是「从未请求」,不是被淘汰回退)。根因在 observer 的**创建时机**:`useProjectResourceCardPreviews` 建 IntersectionObserver 时把 `intersectionRootRef.current ?? canvasRef.current` 直接当 root 传下去,而 root 是资源画本容器、由被观察卡片所在的子树持有。创建那一刻 root 还是 `null` 时,浏览器会**退回按视口判定**,于是被画本容器裁掉的卡片永远报「不可见」,可见性门禁再也不放行它们 —— 卡面只剩占位图标,且没有任何错误提示。

- `useProjectResourceCardPreviews`:root 为 `null` 时**不建 observer**,等 root 就绪后由卡片注册触发的 `rootEpoch` 重建。判据是「卡片一定渲染在画本容器内部」,所以**有卡片注册本身就等价于 root 已就绪**,不需要新增跨组件契约、也不必改 `index.tsx`。
- 新增 `attachObservedCards()`:observer 就绪或重建后,按登记表把**每一张已注册卡片**补挂一遍。此前只在新 observer 创建时补挂一次,注册与创建分属不同 effect 存在时序窗口,错过那次补挂的卡会停在登记表里却从未被观察。
- root 未变且 observer 已在时只补挂、不重建,避免每次渲染重建观察器。
- `observePreview` 在没有 `IntersectionObserver` 的环境(如单测)保持静默,不制造多余渲染。

测试(`tests/useProjectResourceCardPreviews.test.ts` 新增 3 条契约):
- 「不在热预取窗口内的可见卡不得停在 idle」:注册 20 张、`eagerPreviewLimit: 12`,断言注册进 observer 的元素数等于注册数、回调报可见后全部落 `loaded`、且没有一张停留在未请求状态;
- 「等真正的 root 就绪后再观察并加载」:root 首次为 `null` 时**不得建 observer**(否则按视口判定),注册后必须以真正的 root 建出来并完成加载;
- 「建 observer 之前就注册的卡要被补挂」:断言 observer 就绪后登记表里的每一张都在观察集合内,且报可见后全部 `loaded`。

变异验证:把 root 守卫退回旧行为(允许 root 为 `null` 时照建 observer)后,「等真正的 root 就绪」这条立即失败(`expected [] to have a length of 0 but got 1`);恢复后 19/19 通过。断言不是恒真假守卫。

边界说明:只改预览 hook 与它的测试,未动 `index.tsx` / `styles.css` / `resourceBookLayout.ts`(均在他人手上),未放宽任何既有断言、未取消可见性门禁(仍是按需加载)。

验证:`npm run test -- apps/ai-game-creator-shell/tests` 84 files passed / 1216 passed / 4 skipped / 0 failed;`src/components/image-editor` 1385 passed;typecheck exit 0;check:encoding 4379 文件;prettier 与 eslint 干净;`git diff --check` 干净。
2026-09-11 15:31:29 +08:00
suzmii 39a96aa418 模型下拉菜单高度有界:健壮性补强,条目多时不再盖住消息
Project CI / Repository checks (pull_request) Successful in 2m36s
Project CI / Frontend tests (pull_request) Successful in 3m20s
Project CI / Backend tests (pull_request) Successful in 6m29s
Project CI / Native shell tests (pull_request) Successful in 18m34s
- 性质说明:本条是**健壮性补强**,不是用户本轮报的那几条之一;由上一提交(锚点修复)排查时顺手发现的未修观察收尾。
- styles.css:`.conversation-model-menu` 补 `max-height: min(240px, 40vh)` + `overflow: auto` + `overscroll-behavior: contain`。菜单此前没有高度上限,模型条目很多时会长到 composer 顶边以上、盖住消息;240px 与视口 40vh 取小者,矮窗 / 窄屏(移动端优先)更稳。`overscroll-behavior: contain` 与既有 `.resource-reference-menu` 同一口径,菜单内滚动不传染给背后的消息列表。不新增视觉:border / radius / padding 与 token 全部沿用原值。
- 语义零改动:锚点与流式归属不变(`position: absolute; right: 0; bottom: calc(100% + 8px)` 未动),打开 / 选择 / 刷新模型列表的交互不变。
- project-development.suite.ts:新增声明级守卫「bounds the model dropdown height so a long catalog cannot cover the composer」——菜单规则必须同时声明 `max-height: min(240px, 40vh)`、`overflow: auto`、`overscroll-behavior: contain`,并仍是 `position: absolute`(高度有界不改锚点)。
- 变异验证(已实测):把 `max-height` 摘掉 → 用例红灯(expected '…' to match /max-height:\s*min\(\s*240px\s*,\s*40vh\s*\)/u)。
- 门禁:typecheck exit 0;AGC 子集 1206 passed / 4 skipped / 0 failed(基线 1205 + 本次新增 1 条);src/components/image-editor 1385 passed;check:encoding 4379 文件通过;`git diff --check` 全量与本次两个文件范围均 exit 0。
2026-09-11 14:59:42 +08:00
suzmii 4f3516a5d9 修复分类 chip 条叠字:分段页签子项补内容宽度下限
- packages/shared/src/components/PlatformSegmentedTabs.tsx:容器补 `platform-segmented-tabs`
  稳定钩子类,供共享样式表精确命中子项(不依赖 Tailwind 任意值扫描)
- packages/shared/src/components/styles.css:新增 `.platform-segmented-tabs > button`
  的 `min-width: max-content`,给每个子项一个「内容宽度」下限;放不下时由容器横向滚动兜底
- apps/ai-game-creator-shell/src/view/project-development/ResourceClassificationPanel.tsx:
  「资源分类与标签」的 6 类分类条由 `columns="threeToSix"` 改为 `layout="scroll"` +
  `frame="bare"` + `surface="transparent"`,与 `@` 面板和资源画布的分类条同一条既有形态
- packages/shared/src/components/PlatformResourceFilterBar.test.tsx:新增两条声明级断言,
  钉住「子项不换行 + 容器横向可滚 + 子项有不低于内容宽度的下限」
2026-09-11 14:54:27 +08:00
suzmii ff287c19fc 模型下拉弹层改回以触发钮为锚点,输入区高度变化不再牵动它
- styles.css:direct-codex 控制排里的 .conversation-model-select 由 position: static !important 改为 position: relative !important(只改这一个词,另补一段注释说明它同时是弹层锚点)。这颗容器是 .conversation-model-menu(position: absolute; right: 0; bottom: calc(100% + 8px))与模型提示([role='alert'] / [role='status'],position: absolute; bottom: 36px)的包含块;写成 static 时包含块回落到最近的有定位祖先——也就是 position: absolute 的 composer 盒子,菜单于是出现在整个 composer 上方、与触发钮之间隔着整个输入区,输入区一变高(多行、引用 chip、AI 润色)菜单与提示跟着往上飘:这就是"编辑框把元素挤开"的形态。
- styles.css:right/bottom: auto、flex: 0 1 auto、min-width: 0、max-width: 180px 全部保持原样——relative 不会把模型选择钮移出控制排,也不改它被压缩换行的行为;交互语义零改动(打开/选择/刷新模型、发送、@ 引用、快速模式全在原位)。
- project-development.suite.ts:新增一条声明级守卫。① 弹层不参与父容器流式布局(菜单与模型提示都是 absolute,菜单 bottom: calc(100% + …)、right: 0);② 锚点必须是有定位的容器(relative 且不是 static),同时仍在原位(right/bottom auto、flex 0 1 auto、min-width 0);③ 输入区高度变化不得推动同排操作元素(composer 是底边锚定的两行网格:position: absolute + bottom: 12px + grid-template-rows: auto auto;控制排是单行 flex 且不换行,@ 与发送钮 flex: 0 0 30px 不可压缩,窄屏下被压缩的是可收缩的模型选择钮)。
- conversationModelSelect.test.tsx:新增一条 DOM 守卫——菜单必须是 .conversation-model-select 的直接子节点,且不得成为控制排容器 .project-supervisor-composer-controls-right 的流式子节点。
- 变异验证(均已实测红灯):① 锚点改回 static → "expected … to match /position:\s*relative\s*!important/";② 菜单改成 position: static(弹层回流式)→ "expected … to match /position:\s*absolute/";③ 把菜单渲染挪出触发钮容器(变成同排容器的流式子节点)→ DOM 守卫 "expected null not to be null"。
- 门禁:typecheck exit 0;AGC 子集 1205 passed / 4 skipped / 0 failed(基线 1203 + 本次新增 2 条);src/components/image-editor 1385 passed;check:encoding 4379 文件通过;本次三个文件单独 `git diff --check` exit 0(全量 `git diff --check` 目前报的是另一位 Agent 在途修改的 packages/shared/src/components/styles.css:1281 EOF 空行,与本次无关)。
2026-09-11 14:53:11 +08:00
suzmii 9c4778c881 把资源卡右下角的 @ 引用按钮做小:视觉缩到 0.8×,热区保持 44×44
Project CI / Repository checks (pull_request) Successful in 3m31s
Project CI / Frontend tests (pull_request) Successful in 4m24s
Project CI / Backend tests (pull_request) Successful in 6m51s
Project CI / Native shell tests (pull_request) Successful in 18m37s
- styles.css:`.game-resource-card-reference` 视觉尺寸 30×30 → 24×24(0.8×),新加 `.game-resource-card-reference svg` 把内部 `AtSign` 图标同步从 16px 收到 13px,圆角/描边/底色/阴影 token 全部沿用原值。
- styles.css:`bottom` 由 9px 调到 14px,与右侧 34px 播放钮圆心对齐(9 + 34 / 2 = 14 + 24 / 2),避免缩小后两颗圆钮错位。
- styles.css:新增 `.game-resource-card-reference::before` 伪元素撑出 44×44 热区(本仓 window chrome / 素材选择列表同口径的最小可点尺寸),只扩命中范围、不参与布局也不改变绘制;整体向左让 4px,让热区右边缘与播放钮的盒子恰好相邻,不覆盖它。
- 语义未动:仍是「插入素材引用」入口,className、`aria-label={\`引用资源 ${resource.label}\`}`、key 与 `dispatchResourceReferenceInsert` 调用一行未改,本次只改 CSS 声明。
- 测试:新增 CSS 声明级用例,断言按钮与图标确实变小(24 < 30、13 < 16)、热区不小于 44×44、热区右边缘不越过播放钮盒子,并顺带钉住 `index.tsx` 里 @ 入口的 className / aria-label / 插入动作仍在。
- 变异验证:把按钮改回 30px、热区改回 24px、图标改回 16px → 用例红灯;只把热区缩回 24px → 用例同样红灯(均已实测,随后原样还原)。
2026-09-11 14:00:32 +08:00
suzmii 399fcacb8e 同步删除栏目大纲导航的权威文档:PRD 与技术方案改成总览卡片/所有资源/下一页口径
- decision-log.md:新增「删除资源画布左侧的栏目大纲悬浮导航」决策,先记录查证结论(decision-log / pitfalls / PRD / 技术方案里此前只有要求保留该导航的条文,没有任何删除决策,本次是新决策),再写决策、语义变更、不做什么与变异验证结果。
- PRD:§阶段说明与 §7.2 验收两条不再写"大纲展示全部栏目""通过栏目大纲和滚轮访问全部栏目",改为栏目大纲由「资源总览」的栏目缩略卡片承载、切栏目走总览卡片 +「所有资源」入口 + 页内「下一页」。
- 技术方案:目标、操作边界与验收共五处删掉"栏目大纲悬浮在画布左侧/只显示图标和栏目名称""点击大纲切页""分页大纲以悬浮 Dock 展示全部栏目"等条文,改成当前状态(左侧不再有悬浮导航)。
- pitfalls.md:补记一条 jsdom 坑——栏目页 fit key 需要非零可测量尺寸,jsdom 下恒为 0,所以"总览→栏目"往返会重新适配视口;进入栏目后应恢复上次 viewport 的用例在 jsdom 里必须避免这种往返,不要误判成产品回归、也不要去改 fit 逻辑。
2026-09-11 13:54:27 +08:00
suzmii 29b99f213d 删除资源画布左侧的栏目大纲导航:切栏目改由总览卡片、所有资源入口与下一页承担
- index.tsx:整条删除 `ResourceBookOutlineNav` 组件(`.game-resource-outline` / `aria-label="资源栏目大纲"` / 栏目图标 + 栏目文字 + 未读红点)以及栏目页与「所有资源」页两处挂载点;`ResourceBookAllResourcesPage` 不再接收只服务导航的 `unreadCategories` / `onOpen`。
- index.tsx:一并清掉只服务这条导航的「未读红点」状态链(`unreadResourceCategoryState`、`resourceCategorySnapshotRef`、`resourceIdsByCategory`、`viewedResourceCategory` 与对应 effect),不留死代码、不留墓碑注释。
- styles.css:删除全部 `.game-resource-outline*` 规则(悬浮 Dock、常态/悬停态、图标列宽、`.game-resource-outline-label`、`.game-resource-outline-unread`,共 106 行)。
- 保留未动:分区栏目本身、栏目标题栏、资源卡、总览缩略卡片、第 0 张「所有资源」入口、「所有资源」汇总页、搜索、总览、快速编辑与选中工具条。
- 测试:切栏目 helper(appSurface/project-development、appSurface/home、resourceRename、resourceVersionSwitch)改为走「资源总览」缩略卡片(需要时先点「收起资源」回总览),不再点导航;栏目顺序断言改钉在总览缩略卡片的 `aria-label` 序列上(第 0 张「所有资源」+ 固定七栏目),断言内容未放宽。
- 测试:按「四不写」删除只守这条导航的断言——大纲常态可读性的 CSS 声明用例、大纲条目图标/文字的 DOM 用例(该用例保留其中与搜索浮层相关的断言并改名)、未读红点用例(该用例保留"只登记游戏代码的项目照常分页并落在待归类"的部分并改名)、空项目与「所有资源」页里的导航存在性断言。
- 测试:新增反向守卫 + 切栏目链路用例(`has no outline nav and keeps every section reachable through the remaining entries`):断言 `资源栏目大纲` 标签、`.game-resource-outline` 类名与对应 `<nav>` 都不再渲染,并逐条走通总览缩略卡片(含空栏目)→ 下一页 → 标题栏「资源总览」→ 「所有资源」入口往返。
- 测试:`preserves independent art viewports across sort and workbench mode switches` 保留全部原断言,只去掉排序模式切换后那些"点当前栏目"的空操作往返(旧导航点在同栏目上是提前返回,改 helper 后变成真实往返,会在 jsdom 里触发重新 fit)。
2026-09-11 13:54:12 +08:00
suzmii d1a3702062 记录构建版本判据与 loopback HTTP 预览方案的评估结论
本轮「资源卡图片加载不出来」连续两轮按现役缺陷排查,最终发现真机数据在当前 HEAD 上判定全对、现象来自更早的客户端构建。把这次的方法教训与两条廉价判据固化成文档,避免后续重复投入。

- `pitfalls.md` 新增「用真机现象定性之前必须先确认客户端构建版本」:现象与「数据在 HEAD 上的实际判定」不一致时,第一个怀疑对象是构建版本而不是代码;两条廉价判据为真机 manifest 的 `category` 分布(旧构建 `unclassified:58`,新构建 `unclassified:1 / ui-interaction:59 / scene:1`)与控制台读 `[data-preview-kind]` 分布(新构建应为 `raster-image 52 / document 8 / code 1` 且无 `placeholder`)。同时记下方法教训:不要用 PowerShell 或手抄正则去复算 TS 判定表,誊本会引入假结论——本轮第一轮取证正是因此得出了不存在的「角标与预览分支分叉」,要跑就用真实源码跑。
- `decision-log.md` 新增「AGC 资源预览评估过本机 loopback HTTP 资源服务,因收益不匹配代价而暂缓」,并显式注明它与既有的 `asset:` 否决**不是同一条、理由不同**:`asset:` 是「绕过安全门禁」的原则问题,loopback HTTP 是「收益与代价不匹配」的性价比问题,其门禁可在服务端逐条复用。记录内容含 token 化不暴露路径、服务端复用同一套校验、与「不向 WebView 暴露本机文件协议」合同的关系判定(同构替代而非违约,信任边界转移到 token 与审计),以及流式下**必须明确降级的三条**:读取漂移复核、全局 3 permit 并发闸门、有界 tombstone 预算。量化依据为真机 52 张 PNG 的分层实测——Rust + JS 冷开合计约 0.3 s,大头是已由 `decoding="async"` 移出主线程的 37.2 MPx 解码,因此当前量级下 0.3 s 换「不向 WebView 暴露文件系统」这条合同是划算交易;并写明重新评估的触发条件(几百张资源 / 大图量级)。
- 同批一并暂缓的备选也记在案:Tauri 自定义协议(非 `asset:`)+ opaque token 因拿不到 HTTP 缓存语义而比 loopback HTTP 更差;磁盘缩略图缓存与 PRD 的纯内存口径及既有价值观冲突。
- 纯文档改动,无代码改动;`tauri.conf.json`、`Cargo.toml`、Rust 命令与 CSP 均未动。
2026-09-11 13:30:26 +08:00
suzmii 083df0aa2c 类型判定改为内容证据优先:消除 kind 与内容证据同权的脆弱点
**口径更正(后加的)**:本条提交**不是**"修复真机那 8 张卡"。经用真源码复算真机 manifest 确认,当前 HEAD 上这 8 张 `.json` 本就正确落 `document`、角标「文档」,该现象不复现——用户看到的是更早构建的行为。本条的真实价值是**消除 `kind` 与内容证据同权的脆弱点,并把「派发决策由内容证据决定」钉成不变量**,见下。

- 背景:真机「待归类」栏目曾出现 8 张卡——角标写「图片」、卡面却只有占位图标。该现象在当前 HEAD 上不复现(见上),但它对应一个**真实存在且此前只是碰巧没被踩中的脆弱点**:`projectedResourceKind` 里 `kind` 与内容证据**同权**,而 `artKind` 含 `ui`;一旦界面规格类资产丢了 `.json` 扩展名,预览派发就会翻车。原先能通过**完全依赖 `.json` 恰好在那张扩展名表里**,属巧合而非设计。
- 机制(旧行为):`kind:"UI"` 被 `artKind` 命中 → 判定为 `art`;预览调度再按 `art` 走图像分支,又因 `mediaType:"application/json"` 不是图像而兜底成 `placeholder`,而 `requestPreview` 对 `placeholder` 直接返回——**一次 IPC 都不发**,卡片既不报错也没有图。
- `resourceProjectionModel.ts` 的 `projectedResourceKind` 改为两趟判定:第一趟只看 `mediaType` 与路径扩展名(文件内容的可验证证据),第二趟才看 `kind`(登记标签,可能过时或宽泛)。`kind` 不再与内容证据同权,因此 `kind:"ui"` 无法再把 `application/json` 改写成 `art`。
- 不改 `artKind` 的取值(`ui` 仍在表内),只调整判定顺序:最小改动面,`kind:"ui"` + `image/png` 这类正常资产行为不变(仍由 `mediaType` 命中)。
- 界面规格 JSON 现在落 `document` 分支,走 `read_local_project_text_preview`(Rust 侧 `.json` 与 `application/json` 本就在支持范围内且这些条目已登记),卡面渲染 JSON 文本摘要,角标随 `projectResourceTypeLabel` 变为「文档」。预览链条数、缓存、并发与门禁一律未动。
- `tests/resourceCardPreviewRealManifest.test.ts` 中记录旧行为的用例同步跟上新合同:`placeholder: 8` 改为 `document: 8`,用例名与注释改写为「52 张 PNG 走图片分支,8 条 UI 规格走文档分支,无一落占位」,并新增「角标与预览分支必须说同一件事」的逐条断言。旧断言记录的是缺陷本身,按「四不写」直接更新为当前权威行为,不做兼容保留;其余既有断言一条未放宽。
- 验证:`npm run test -- apps/ai-game-creator-shell/tests` 1202 passed / 4 skipped / 0 failed;`src/components/image-editor` 1385 passed;typecheck exit 0;check:encoding 通过;`git diff --check` 干净。
2026-09-11 13:30:14 +08:00
suzmii 5a37ccfd2b 资源卡暴露预览状态,并补「派发决策由内容证据决定」的契约用例
占位分支对「还没读 / 读失败 / 压根不适用」给的是同一个图标,卡面也没有任何文字提示,现场只能看到「没有图」,无法区分是调度没发请求、原生拒绝了读取,还是这类资源本来就没有预览。本轮排查「图片加载不出来」时,这个不可观测性正是反复误判的来源。

- `index.tsx` 的 `ResourceCard` 根节点新增 `data-preview-status`(`idle` / `loading` / `loaded` / `failed`)与 `data-preview-error`(仅失败态给出原因)。排障从此只需读一个 DOM 属性,而不必去猜调度层。纯新增属性,不改变任何渲染分支与调度行为。
- 新增 `tests/resourcePreviewPredicateContract.test.ts`:把「派发决策必须由可验证的内容证据(mediaType,缺失时退路径扩展名)决定,登记标签 `kind` 只能在两者都给不出结论时兜底」钉成不变量。用 7 组「mediaType + 同通道扩展名」× 20 个 `kind` 的全量笛卡尔积断言 `kind` 不能改写预览分支,并断言组合总数等于笛卡尔积(防止将来收窄枚举让断言悄悄变弱)。另附本轮缺陷形状的最小复现(`application/json` + 任意图片类 `kind` 必须落 `document` 而不是 `placeholder`)、mediaType 缺失时由扩展名决定、以及扩展名与 mediaType 冲突时按既有设计由扩展名决定这三组显式合同。
- 该用例在**变异验证**下确实会红:把 `kind` 判定移回第一趟(还原本轮的分叉)后,本文件 4 条用例失败(不变量报出 56 处违规),恢复后全绿。断言不是恒真假守卫。
- 验证:`npm run test -- apps/ai-game-creator-shell/tests` 84 files passed / 1202 passed / 4 skipped / 0 failed;`src/components/image-editor` 1385 passed;typecheck exit 0;prettier 与 eslint 干净;check:encoding 4378 文件;`git diff --check` 干净。
2026-09-11 13:26:17 +08:00
suzmii cd1aceb3d9 按 rustfmt 折行退出收尾用例:纯格式,无逻辑变更
Project CI / Frontend tests (pull_request) Successful in 7m44s
Project CI / Backend tests (pull_request) Successful in 10m11s
Project CI / Repository checks (pull_request) Successful in 23m14s
Project CI / Native shell tests (pull_request) Successful in 38m54s
- tests/project.rs:gui_exit_stops_the_preview_and_clears_the_stale_record 里 init_existing_html_project_at 那行超过 rustfmt 行宽(中文按双宽计),按 `cargo fmt --all --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml` 的结果折成两行。
- 用 cargo fmt(不是裸 rustfmt):裸 rustfmt 会把文件当模块根、跟随 mod 递归格式化整个模块树,本次已有人因此误改 50 个文件。
- 范围核对:`git diff --stat` 只有 apps/ai-game-creator-shell/src-tauri/src/tests/project.rs(1 file changed, 2 insertions(+), 1 deletion(-)),crate 里其余文件本来就是 rustfmt 干净的,未被牵连。
- 门禁:`cargo fmt --all --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -- --check` 与 `... --manifest-path server-rs/Cargo.toml -- --check` 均 exit 0;`cargo check --locked --all-targets` exit 0;AGC 子集 1198 passed / 4 skipped / 0 failed;四条预览定向用例仍各 1 passed。
2026-09-11 12:47:22 +08:00
suzmii 531392e506 退出路径统一收尾本地预览,陈旧预览记录不再留给下次进入
- preview.rs:新增 stop_local_game_preview_on_exit——退出时若 registry 里确有 running 预览,就用 stop_local_game_preview_for_root 把它真正停掉并写 stopped(顺带落 preview.log 与 trace 收尾)。预览服务器是进程内线程,进程一退 URL 就永久失效;不在这里收尾,.agent/manifest.json 会一直写着 running。
- main.rs:handle_game_creator_gui_run_event 的 RunEvent::Exit 分支先做预览收尾,再做既有的 Agent server / runner 关闭;失败按 `preview.gui_exit.stop_failed` 记日志,不阻断退出。
- 语义变更(用户已确认接受):关窗不再保留预览——退出即停止预览并把记录写成 stopped,下次进项目一律落到资源管理。与"进入项目时按 registry 活体核对"配套:退出路径覆盖正常关闭,强杀 / 断电那类不走退出路径的情况由进入时的核对自愈。不引入 stale DTO 字段(判定仍要活体探测,且会把状态扩到跨端契约与全部消费方)。
- tests/project.rs:新增 gui_exit_stops_the_preview_and_clears_the_stale_record——退出收尾后 registry 归零且 manifest 记录为 stopped。
- tests/sessionPreview.test.ts:补两条 Rust 侧结构性守卫(本仓既有做法:直接解析源码)。① RunEvent::Exit 分支必须出现 preview::stop_local_game_preview_on_exit——这条接线没有别的行为测试覆盖(注册全局 registry 的用例会互相打架);② 同项目重启预览的替换分支里,收尾必须排在"旧预览是否属于另一个项目"的判定之后。变异验证:把 main.rs 的退出收尾摘掉 → 守卫①立即红灯(已实测)。
- decision-log.md:新增 2026-09-11 决策条目,逐条写下背景(偶发进运行界面的成因链:进程内预览线程 + 随机临时端口 + 退出零清理 + 进门不核对 + 同项目重启覆盖刚写的 running)、A/B/C 三项决策、关窗不再保留预览这条语义变更及"为什么两者都做而不加 stale 字段"、不做什么与验证方式。同文件被 prettier 补了几处标题前空行(格式规范化,无内容变化)。
- 验证:cargo check --locked --all-targets exit 0;Rust 定向用例 restarting_preview_for_the_same_project / replacing_preview_for_another_project / stopping_without_a_live_preview / gui_exit_stops_the_preview 各 1 passed;AGC 子集 1198 passed / 4 skipped / 0 failed;共享组件 1385 passed;typecheck exit 0;编码 4378 文件;git diff --check 干净。
2026-09-11 12:36:08 +08:00
suzmii 669c71d7e8 补记三条踩坑:rustfmt 作用域、build-gate 代理假红、worktree 与 MSYS bash 不兼容
- 新增「裸 rustfmt 会把一个文件放大成整个模块树」:现象是本想改 1 个文件实际改了 src/agent/ 下 50 个;原因是裸 rustfmt 把传入文件当模块根并跟随 mod 递归;处理口径是改格式前先 git diff --stat 确认范围、先备份,精确到文件用 cargo fmt -p <pkg> -- <文件...>,并行工作树里禁止盲跑 cargo fmt --all,误伤后用 git apply -R 反向补丁还原而不是 git checkout --
- 新增「build-gate 的警告门被代理环境变量误伤」:NODE_USE_ENV_PROXY=1 / HTTP_PROXY 会让 Node 打印 UNDICI EnvHttpProxyAgent experimental 警告,而 build-gate.mjs 只忽略含 ExperimentalWarning 字样的行,于是构建成功却判红;判据是同一 commit 清空这些变量后 npm run build 即 exit 0;CI 无这些变量不会触发,若要让本地带代理也能跑需在忽略规则补 EnvHttpProxyAgent / UNDICI-EHPA(属改门禁规则,本次未改)
- 新增「worktree 里 MSYS bash 跑 git 脚本会失效」:check-repository-ci.sh 这类脚本在 worktree 里第一步就报比较基线不可用,根因是 bash 用 MSYS 的 /usr/bin/git、而 worktree 的 .git 存 Windows 绝对路径,解析成 /mnt/c/.../C:/Users/...;口径是按脚本同序同命令用 Windows git 逐条等价执行
- 只追加,未重排既有条目
2026-09-11 12:28:58 +08:00
suzmii 66bb240115 进项目时按内存 registry 核对预览活体,陈旧记录不再冒充可运行预览
- 新增 features/app-shell/sessionPreview.ts 的 resolveSessionPreviewOnProjectOpen:进入项目时问一次 get_local_game_preview_status(内存 registry 是活体的唯一真相),确实 running 才把它作为本次会话的预览;记录写着 running 而 registry 没有活体时判为陈旧,复用现役 stop_local_game_preview 把落盘记录对齐成 stopped,并同时把会话 manifest 按真相投影成 stopped(否则运行入口会继续拿着打不开的 URL 渲染运行画面)。
- useHomeProjectCreation.ts:enterProjectDevelopment 改为 async,先做这次核对再进工作台,并按核对结果决定会话预览与 manifest 投影;三个调用点(新建项目、创建项目、打开项目)都改为 await。
- preview.rs:同一个项目重启预览时,旧 registry 身份的收尾不再覆盖刚落盘的 running——只有当旧预览属于**另一个项目**时才替它收尾。此前同项目重启会把刚写进去的 running 覆盖成 stopped,下次进项目便丢掉"预览在跑"这条事实(记录反方向失真)。
- preview.rs:stop_local_game_preview_for_root 在没有活体可停、但落盘记录仍是 running 时也把记录收尾成 stopped(stop 的语义变成"真的停干净,包括记录"),因此陈旧记录既可由进入项目触发对齐,也可由用户显式停止对齐。
- tests/project.rs:新增三条 Rust 定向用例——同项目重启后记录仍为 running 且端口是新预览的;换项目启动时旧项目记录写 stopped、新项目写 running;registry 无活体而记录写着 running 时 stop 会把记录对齐成 stopped。
- tests/sessionPreview.test.ts(新增):五条用例覆盖活体优先、陈旧记录对齐并投影、registry 不可读按没有在跑处理、记录本来不是 running 时不发多余停止命令、无 invoke 时零请求。变异验证:把活体核对去掉(直接信落盘记录)→「registry 里没有活体时不给会话预览」与「registry 不可读」两条立即红灯(已实测)。
- project-development.suite.ts:把「打开项目不查预览状态」那条断言改为「恰好核对一次预览活体」,并保留邻近的 start_local_game_preview 必须为 0 的断言——被新行为取代的是"从不核验","开项目不偷偷启动预览"这条原意一字未改。
- PRD §4.1:状态机按当前实现改写(进入项目一律落到 resource-overview;run 的自动进入只认内存 registry 的活体确认;落盘 preview 是记录而非活体,陈旧记录不触发切换也不参与渲染),并把退出收尾一并写进该节说明。
2026-09-11 12:27:31 +08:00
suzmii 1801d2b227 清 AGC src-tauri 既有 rustfmt 欠账:5 个文件纯格式化,无逻辑变更
- `check:rustfmt` 跑 server-rs 与 AGC src-tauri 两条命令,CI 在 server-rs 那条就退出,AGC 侧欠账从未被跑到;本提交只做格式化,不改任何语义
- `src/agent.rs`:`mod` 声明与 `pub(crate) use` 按 rustfmt 排序口径调整 `direct_codex_references` 的位置(声明顺序无语义影响)
- `src/assets.rs`:`infer_canvas_export_asset_kind` 的 `else if` 条件行重排;两处 `assert!` 参数按 use_small_heuristics 折叠;两个返回 `Result<..>` 的函数签名断行
- `src/project/asset_export.rs`:`resolve_export_source_file` 签名折成单行,`File::open`/`File::create` 两行重排,用例里 `SaveLocalProjectAssetFileInput` 实参折叠
- `src/tests/asset_rename.rs`:两处用例断行/折叠
- `src/tests/asset_delete.rs`:一处 `assert!` 实参折叠及连带重排
- 已用 `cargo fmt -p genarrative-ai-game-creator-shell -- --check <这 5 个文件>` 与整 crate `cargo fmt --all -- --check` 双重验证通过
2026-09-11 12:25:49 +08:00
suzmii 775b3792cd 修 rustfmt:resource_editor 新增用例按仓库格式折行
- `editor_api_rejection_reason_from_body` 用例里 3 处 `serde_json::json!(...).to_string().as_str()` 实参按 rustfmt 口径断行
- `editor_api_rejection_reason_from_body("<html>502</html>")` 断言折行
- 连带收掉 `remote-terminal-failed` 的 match 分支:该 arm 因上一提交改宽而超行宽,按 rustfmt 折成块
- `resource_editor.rs` 现在通过 `cargo fmt --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -- --check`
2026-09-11 12:21:04 +08:00
suzmii 8a1917b7e1 同步分类自愈规则的契约用例:改断言并把盲区补成显式用例
- 修正 resolves asset category and tags with legacy and forward compatibility 中 { kind: 'character', category: 'unclassified' } 的期望:由 'unclassified' 改为 'character'
- 该断言是 09-09 引入的旧口径,与 2026-09-10 落地的分类自愈规则冲突;按拍板保留自愈规则(它修复真机 57 条 kind:ui 资产落待归类),因此精确更新断言而非放宽为 not.toBe
- 在断言处补注释说明这是本批有意行为,并写明已知盲区:用户手动把可明确分类的资产设为待归类时会被覆盖
- 新增用例「自愈规则覆盖落盘 unclassified:这是显式接受的盲区」,覆盖 character / ui / art-spritesheet 三条具体映射
- 新增用例「派生结果本身就是 unclassified 的 kind 不受自愈规则影响,保持落盘值」,覆盖 image / video / code / publication-material 与未知 kind
- 新增用例「落盘为明确分类时信任落盘值,不被 kind 派生结果覆盖」,覆盖 image+scene / character+audio / ui+document
- 变异验证:临时移除自愈规则后上述 2 条断言失败(expected 'unclassified' to be 'character'),恢复后全部通过
2026-09-11 12:16:26 +08:00
suzmii caa188873d 删掉快速编辑目标校验里已失效的 generationInputs 形参
- `ensure_editor_image_edit_target_matches_source` 的 `generation_inputs` 形参在来源类型判据改为静态图集合、且 AGC 例外分支删除后再无任何使用,函数体内已无引用
- 同步删除该形参,并更新 1 处生产调用点与 8 处用例调用点传入的多余实参
- 该形参此前只用于把 generationInputs 透传给已删除的 `_for_request` 包装层,删除后判据与请求体字段彻底解耦
2026-09-11 12:14:40 +08:00
suzmii 49a30d51ff 修正 icon 全局放行后的两处判据:删掉死分支并更新旧断言
- 删除 `ensure_editor_image_edit_source_kind_allowed_for_request` 里 `AGC && assetKind=="icon" → Ok` 的例外分支:`icon` 已进静态图类型表被全局放行,该分支恒不改变判定结果
- 连带删除因此不再被引用的 `is_game_creator_resource_editor_generation` 与其定向用例(同一 scope 无人调用);AGC 来源别名口径仍由 `EditorGenerationQueueResultContext::from_job` 的 queue consumer 判定承担并有既有用例覆盖
- 两处调用点改为直接调 `ensure_editor_image_edit_source_kind_allowed`,删掉没有行为的 `_for_request` 包装层与 `generation_inputs` 形参
- 旧用例 `game_creator_resource_editor_bypasses_quick_edit_whitelist_only_for_its_source` 改名 `quick_edit_source_kind_policy_ignores_generation_source`,改为断言来源类型判据与 generation source 无关
- 修正 master 侧旧断言 `editor_image_edit_target_binding_prefers_object_id_and_fails_closed`:反例 effective_asset_kind 从 `icon` 换成仍被拒的 `character-animation`,断言保持 Err,守卫意图(目标图层不能借壳成允许来源)不变
2026-09-11 12:11:37 +08:00
suzmii a5aba86493 修 rustfmt:editor_project 新增行按仓库格式折行
- `ensure_editor_image_edit_source_kind_allowed` 的 `is_none_or(...)` 折叠为仓库 rustfmt 口径的换行
- 白名单用例里 `ensure_editor_image_edit_source_kind_allowed(Some("art-spritesheet"), Some("video"))` 断行
- `is_game_creator_resource_editor_generation(Some(&json!({})))` 断言断行
- `let queued_payload: Value = serde_json::from_str(...)` 折行
- 仅 `server-rs/crates/api-server/src/editor_project.rs` 一个文件,无其他文件被 rustfmt 改动
2026-09-11 12:08:06 +08:00
suzmii 3ed567d99a 运行视图的自动切换只认会话内确认过的预览
- index.tsx:自动切运行的那条 effect 从「合并后的预览 URL」收窄为「会话预览 URL」(resolveEmbeddedPreviewUrl(previewOverride))。manifest.preview 是"上次留下的一条落盘记录":预览服务器是进程内线程、非 Linux 端口还是随机临时端口,客户端退出后记录仍会写着 running,照着它自动切视图就是把用户丢进一个打不开的运行界面。运行入口(运行 tab、播放按钮)与 runAvailable 一行未动,PRD §4.1「运行入口不可用时仍允许点击…但不切换状态」继续成立。
- useHomeProjectCreation.ts:enterProjectDevelopment 不再把 context.manifest.preview 播种成本次会话的预览(改为 null)。会话预览此后只由活体确认(下一提交)与本次会话里 preview.start / preview.status 的返回值经 onPreviewChange 回流两处写入。
- 有意接受的代价:「回到仍在运行的预览」从自动变成用户点一下「运行」;下一提交会把"确实还在跑"这条路径按活体确认接回自动进入。
- project-development.suite.ts:新增两条用例——① 仅 manifest.preview 为 running(不传 preview)时初始仍是「资源管理」、运行 tab 仍可用、点一下仍能进运行视图;② 会话预览为 running 时自动切到运行表现层。
- 变异验证:把这条 effect 还原成读合并后的 URL(即撤掉本次收窄),用例①立即红灯(已实测)。
2026-09-11 12:07:44 +08:00
suzmii 95c5418c0f 合并 master(#318 写锁等待与可诊断)入 V3 分支
Project CI / Repository checks (pull_request) Failing after 1m3s
Project CI / Frontend tests (pull_request) Failing after 2m48s
Project CI / Backend tests (pull_request) Failing after 4m27s
Project CI / Native shell tests (pull_request) Successful in 17m21s
- 三个文档冲突按两边都保留解决:decision-log.md、pitfalls.md、实施计划技术方案;两边追加的是不同主题(资源画布/预览/聊天区 vs 写锁),语义无冲突

- Rust 侧 direct_runtime.rs、direct_tool_bridge.rs、project_gates.rs、project.rs 自动合并成功,无手工介入

- 冲突处仅移除 Git 标记行,未删改任何一方内容(每个文件恰好减少 3 行)
2026-09-11 11:33:12 +08:00
suzmii 28bb5800ea 资源总览去掉大标题旁的副标题
- 删除资源总览标题栏里那颗副标题的**两个分支**:`${canvasResources.length} 项正式与候选资源` 与空项目时的 `从资源栏目开始整理资源`
- 位置:`apps/ai-game-creator-shell/src/view/project-development/index.tsx` 的 `<header className="game-resource-book-main-heading">`(基线 364492de3 的 5867-5871 行那颗 `<span>`,标题栏自 5863 行起)
- 同步删除只服务于它的 `.game-resource-book-main-heading > span` 样式,模块内不再留墓碑规则
- 该文案没有承载 `aria-label` 等语义(区域语义一直在容器的 `aria-label="资源总览"` 上),因此不需要把语义迁移到容器,也没有改成"更短的说明"
- 全量计数没有丢:同一次改动后由总览第 0 张「所有资源」卡的计数徽标承载,仍是同一份画布投影
- 卡片内部的说明/装饰文案(「打开 UI 交互」「打开角色与对象」「1 个直接子版本」等)保持不动
- 断言:资源总览标题栏整段 `textContent` 恰为「资源总览」(精确相等,不放宽成 `not.toContain`),并复核总览第 0 张卡与「文档」栏目卡的内部文案仍在
- 既有用例没有任何一条断言过这段副标题文案(全仓检索只命中源文件),因此没有"按文案已删更新"或"按四不写删除"的对象;本用例是补上的正向合同断言
2026-09-11 11:28:59 +08:00
suzmii 1cc9a23815 资源总览新增「所有资源」汇总卡与全部资源页
- 渲染层新增 `RESOURCE_BOOK_ALL_TARGET = 'all'` 特殊项:跨端契约 `ProjectResourceCanvasCategory` 不加 `'all'`,sidecar schema 不升版,落盘布局的 `section` 也不会出现它
- 资源总览第 0 张卡即「所有资源」:排在全部栏目卡之前,复用同一套缩略图类名/圆角/底色/阴影与栏目标题栏组件,不新增卡片样式
- 该卡的计数徽标取全量资源数,口径等于各栏目计数之和(同一份画布投影,不另算一套)
- 画本场景的栏目顺序与资源分组各多一个渲染层入口;`buildResourceBookScenePlan` 对特殊项只出计数与标题栏,不铺卡片,避免同一张资源卡在总览里挂载两份(`data-resource-card-id` 查询与 FLIP 的 `card:*` 键都会撞车)
- 新增「所有资源」页:栏目大纲 + 收起资源按钮 + 按栏目分组的滚动卡片网格;卡片复用栏目页同一颗渲染器与卡宽高,容器与网格样式补齐在 styles.css
- 该页的卡片显式关掉拖拽(卡片坐标不属于任何栏目画布,写回会污染布局 sidecar),保留选中/播放/菜单交互,并把光标与 `touch-action: pan-y` 交给滚动容器
- 栏目大纲抽成 `ResourceBookOutlineNav`,栏目页与全部资源页共用同一条入口带;渲染出的 DOM 与抽取前一致
- `renderResourceBookCard` 增加可选 `draggable` 参数,`ResourceCard` 的三个移动端指针回调改为可选
- 断言:总览存在「所有资源」卡且为第 0 张、计数等于全量资源数且等于各栏目计数之和、点它进入的页面按栏目分组且每组都挂真实资源卡、场景计划里特殊项不出卡片且整份计划无重复卡片键、全部资源页分组只保留非空栏目且保持总览顺序
- 既有「N 项」徽标断言从全文档文本查找收窄到 character 栏自己的标题栏(新增第 0 张卡后同文案会撞车,收窄而非放宽)
2026-09-11 11:21:03 +08:00
suzmii 364492de37 Revert "文档同步:资源管理页改为栏目分页纵向滚动网格"
This reverts commit c2b851cc36.
2026-09-11 11:07:59 +08:00
suzmii c2b851cc36 文档同步:资源管理页改为栏目分页纵向滚动网格
- PRD §3.3:栏目页改为纵向滚动网格,顺序由 sidecar 记录的 (y, x, resourceId) 读序推出,新增列数控件与栏目内拖拽排序条文
- PRD §3.3.1:三阶段改写为当前形态(本体卡保留;阶段二改为栏目分页网格;阶段三改为未排序栏目的初始顺序派生)
- PRD §3.3.2:可见性预取口径改为以栏目滚动容器为交叉根
- PRD §5.2:实现状态与字段语义改为「x / y 只承载栏目内顺序」,新增顺序阶梯、已排序栏目新资源追加末尾、列数偏好与关系说明条文
- PRD §5.2.4:删除 viewport、wheel、拖动改写坐标等画布条文,改为网格、列数与拖拽排序
- PRD §5.2.5:手动拖动合同改为拖拽排序合同
- PRD §5.2.6:删除资源依赖关系图层整节
- PRD §7.2:验收改为顺序持久化;§7.3 依赖关系图验收整节删除;§7.5 重写为栏目分页纵向网格验收,新增「纵向滚动无横向溢出」与「列数被记住」两条判据
- PRD §6/§8:分阶段范围与非目标同步
- 技术方案:原《GameAgent资源自由画板与快速编辑》改名为《AGC资源管理页纵向网格与排序》,重写为当前形态(页面形态、栏目内顺序、列数偏好、拖拽排序、关系说明),保留权威数据、焦点竞态与派生编辑条文
- 实施计划:资源卡与资源管理页边界改为栏目网格、顺序落盘、列数偏好与滚动容器可见性
- decision-log:新增 2026-09-11 决策(含用户明确判断优于 PRD 的事实依据,以及有意推翻 1da7dc62a)
- pitfalls:整体改写「AGC 资源管理页真正自制的交互面」条目
- docs/README.md 与 document-map.md:更新改名后的专题文档入口
2026-09-11 11:00:31 +08:00
suzmii 6cc929e47d 修复陶泥儿对话外框没把输入区包住:外框底边不再与输入区重合
- 根因:`.project-supervisor-message-list` 在文件末尾还留着一条同选择器、同权重的
  `bottom: 156px`("列表在上、输入区在下"那版两盒布局的残留)。它按"后写胜出"把上一轮
  bdb07c141 改的 `bottom: 0` 悄悄顶掉,外框底边落在输入区腰上、输入区下半截露在框外,
  真机上看到的就是"消息区外框底边与输入区压在一起、外框没把输入区完整包住"。
- styles.css:把那条残留规则换成对话框盒子的最终几何(top/right/bottom/left 全 0,
  `padding-bottom` 与 `scroll-padding-bottom` 都是 256px)。它位置在文件靠后,同权重后写
  胜出,顺带把 `:has(.plan-gdd-surface, .planning-lane-runtime-strip)` 给列表留的
  `padding-bottom: 12px` 压回去,滚到底时最后一条消息不会被输入区盖住。
- styles.css:输入区补上第四条边——`max-height: calc(100% - 24px)`(24px = 上下各 12px)。
  左右下三边仍显式内缩 12px,上边由 max-height 兜底,于是外框完整包住输入区:四条边都不
  与外框描边重合,底边留出 12px 边距(外框底边 0 / 输入区底边 12px)。
- styles.css:窄屏(≤760px)改成一个模型——外框画在列表与输入区共同的父节点
  `.project-supervisor-conversation` 上,列表撤掉自己那只框(框里不套框),输入区回到
  文档流,靠外框 12px 内边距四周留白、随列高增长不溢出。原来那条窄屏输入区规则权重低于
  `.project-supervisor-composer.is-direct-codex` 那条,其实一直是死声明,这次才真正生效。
- tests/chatDialogFrameLayout.test.ts:新增用例,先按真实层叠(媒体查询是否命中 -> 特异性
  -> 源码顺序)求生效值,再断言外框四边归 0、输入区四边内缩同一个值、底边与左右留白严格
  大于 0、留白 256 = 12px 内缩 + 228px 输入区最高高度 + 16px 间距(228 由编辑器
  max-height 140、输入框行距 8、操作排 28px 方钮、输入框下内边距 4、输入区上下内边距 16、
  操作条 2 + 30 方钮逐项从真实声明里算出来),以及窄屏下外框/列表/输入区各自落位、输入区
  仍在外框节点子树里且交互控件原位。
- tests/appSurface/project-development.suite.ts:把那条按"第一条匹配规则"取值、用 230 估
  算的留白断言同步成按声明算出的 228 与实际间距 16,并注明层叠生效值的守卫在新用例里。
- 变异验证:输入区 `bottom: 12px -> 0`(还原"贴死重合")时新断言失败;外框
  `bottom: 0 -> 156px`(还原上一轮残留)时"外框底边必须严格低于输入区底边"失败(-144)。
- 说明:本机无无头浏览器、vitest 未开 `css: true`,像素级观感只能真机复核;这里钉的是
  层叠生效的声明级几何关系与"输入区仍在外框节点的子树里"。
2026-09-11 10:36:45 +08:00
suzmii 1da7dc62a0 fix(资源画布): 拖拽写入不再触发其它自动卡重派生补位
- useProjectResourceCanvasLayout 新增 AutomaticPositionPolicy(rederive / preserve),取代原先裸布尔 rederiveAutomaticPositions 传参
- 拖拽(manual 意图)写链固定用 preserve:不再丢弃其它自动坐标,其余自动卡原地不动,也不再补位到被拖走的空位
- 资源集合与拓扑身份签名变化仍按 PRD 用 rederive 重派生,关系图就绪后的深度修正不受影响
- 新增 automaticCoordinatesMatch(忽略数组顺序的坐标比较):手动写入后只在坐标真的变化或有新资源需要落位时才继续排一次资源同步,避免只由排序归一触发的第二次 CAS 写入再次把其它卡补位
- 写入后的 updated / conflict 分支统一按 writePolicy 计算 reconciledAfterWrite,并据此判定是否需要资源同步
- rebuildOptimisticLayout 改为接收策略参数,手动写入后的会话布局同样保持其它自动卡不动
- 新增用例:拖走一张后其余自动卡在所有写入里坐标逐项不变、视图里也不补位;变异验证(把拖拽路径改回 rederive)后该断言失败
- 副作用(已确认接受):把卡正好丢在另一张卡上时不再自动挪开它,直到下一次资源集合或拓扑变化触发重派生
- 门禁:AGC 1178 passed / 4 skipped / 0 failed,共享组件 1385 passed,typecheck exit 0,编码检查 4374 通过
2026-09-11 10:27:32 +08:00
suzmii 921d86ed44 feat(资源画布): dependency 默认铺排改为层内网格平面,不再退化成一列
- 新增 RESOURCE_CANVAS_DEPENDENCY_LAYER_MAX_COLUMNS = 6 与槽位宽高比常量,作为层内网格列数上限
- 新增 normalizedDependencyDepth:落位、层带与层内网格共用同一深度归一,避免同一深度被当成两层
- 新增 dependencyLayerColumnCount:列数 = clamp(floor(sqrt(卡数 × 槽位宽高比)), 1, 6),是卡片数量的纯函数
- 新增 dependencyLayerGrid:层内网格几何,行高统一取该层最高卡,使层高与层内顺序无关(中位数扫描重排后几何不抖)
- 用 dependencyColumnGeometryByDepth 取代 dependencyColumnXByDepth:x 仍只由 dependencyDepth 决定层带基址(depth 仍是唯一横向层级权威),层带宽度 = 列数 × 列间距;全部层为单列时与旧值逐值一致
- 层内按行优先落位,层内排序(稳定 ID 初序 + 两轮左至右/右至左中位数扫描)与深度层级语义不变;网格之外仍按既有向下找空位回避手动坐标与饱和层带
- 移除 dependencyBucketHeight,簇高、簇行数与层内 rank 改用层内网格行数
- 同步 resourceCanvasLayoutModel 测试:新增层内网格平面(12 张同深度 → 4 列 3 行)、重复计算与输入顺序稳定、层带随深度单调且不与换行层重叠、列数封顶 6、手动坐标在自动网格重派生前后逐项不变;同层 3 张卡的环连续性用例改为行优先网格口径
- 影响面:既有项目里已落盘的自动坐标(manuallyPlaced=false)会在下次协调时按新口径一次性重排成平面;manuallyPlaced=true 的历史手动坐标不删除、不迁移、不被自动排序覆盖
- 不新增持久化字段、sidecar schema 不升版,ProjectResourceCanvasSection/Position 契约与 RESOURCE_CANVAS_INITIAL_FIT_MAX_SCALE 均未改动
2026-09-11 10:16:01 +08:00
suzmii dcb750c318 补记跨层字段名静默忽略的排障口径,并把逐栏目用例提为首选证据
- `pitfalls.md` 新增「manifest 资产分类字段写错名会被静默忽略,跨层字段名相近时尤其危险」:manifest 上的字段名是 `category`,而 `assetCategory` 是前端投影后 `ProjectResource` 的字段名,写错层不报错也不告警,本文档记录现象、原因、处理与通用规则(「设置无效但没有任何报错」时第一优先怀疑字段名或层级错配)。
- 同文件「预览 Hook 的资源入参收窄会连带收窄 identity map」那条的验证行改为以新用例 `mounts a real card body in every non-empty resource section overview` 为首选证据:它逐栏目覆盖五个非空栏目并含变异验证;既有 `uses one full-page canvas per resource section with dependency-only guide lines` 降为补充(只覆盖一两个栏目)。
- 只追加与就地更正该条验证行,未重排任何既有条目。
2026-09-11 02:00:38 +08:00
suzmii e3616a44cc 补逐栏目断言:资源总览每个非空栏目都必须挂载真实卡片本体
上一轮把「总览其它栏目不画卡片」的回归结论建立在推理上(identity map 由全量投影构建 ⇒ 不会有卡片因身份缺失而不挂载)。本轮把它变成实测。

- 新增 `appSurface > mounts a real card body in every non-empty resource section overview`:自带 manifest,把 `category` 显式写死,让 `character` / `audio` / `document` / `unclassified` / `version` 五个栏目同时非空(外加空的 `ui-interaction` / `scene`),再逐栏目断言 `.game-resource-book-scene-card[data-resource-book-category=…]` 内存在 `.game-resource-card` 本体,并用 `.game-resource-card` 的 `data-resource-card-id` 回查投影结果,证明卡片确实落在被断言的栏目(不是靠「有子节点」这种恒真守卫蒙过去)。`audio` 刻意保持 `idle`(音频不接受可见性预取),用来证明卡片挂载不依赖预览是否已就绪。
- 自带 manifest 而不是改共用 fixture:`addSectionResources` 的四个资产被同一测试后续多处断言引用(例如 `选中资源:待归类 section.md`),给它补 `category` 会把 `section.md` 从「待归类」挪进「文档」,连带打挂那些既有断言。新用例因此只新增、不改动既有断言。
- 分类字段名踩坑记录:manifest 资产上的字段名是 `category`,不是 `assetCategory`(`assetCategory` 是前端投影后的 `ProjectResource` 字段)。先写成 `assetCategory` 时它被静默忽略,`gameCreationAppAssetCategory` 只能按 `kind` 派生,`design-document` / `game-code` 都不是 canonical kind,于是 `section.md` 落在「待归类」、`document` 栏目为空。
- 变异验证(本断言的价值所在):把 `resources` 改回 `activePageResources`(即回到回归状态)后,本用例与既有 `uses one full-page canvas per resource section with dependency-only guide lines` **双双失败**(`expected null not to be null`);恢复后两者通过。断言确实守住了这条回归。
- 验证:`npm run test -- apps/ai-game-creator-shell/tests` 81 files passed,1172 passed / 4 skipped / 0 failed;typecheck exit 0;eslint 与 prettier 干净;check:encoding 4374 文件;`git diff --check` 干净。本轮只改测试文件,`src/**` 与 HEAD 逐字节一致(变异验证后已还原并核对)。
2026-09-11 01:59:21 +08:00
suzmii 84b8f5130b 修复资源总览只画当前栏目卡片的问题:拆开身份口径与热预取口径
前一次改动(60d8b8fbb)把 `useProjectResourceCardPreviews` 的 `resources` 从全量投影收窄成当前分页栏目,造成产品回归:该入参不只喂热预取,它同时决定返回的 `identityByResourceId`,而 `renderResourceBookCard` 在身份缺失时 `return null`,于是资源总览(main 态)只有当前分页栏目画出真实卡片,其余栏目只剩栏目标题栏加空的层叠占位。

- 拆成两个入参:`resources` 恢复全量资源投影语义(决定 `identityByResourceId`、缓存与卡片挂载),新增 `eagerResources` 只用于 `eagerPreviewLimit` 计数的热预取,缺省回退 `resources`。两个入参的语义写进 Hook 参数注释,调用方 `index.tsx` 在传参处注明「收窄 `resources` 会让其它栏目只剩标题栏」。
- 纠正前一次提交的说法:**identity map 必须保持全量**,不能跟着热预取一起收窄。热预取可以只喂当前栏目(上一个提交的意图保留、`A → B → 回 A` 会重读 A 可见卡的已知代价也保留),但身份 / 渲染口径收窄会让总览其它栏目不再挂载卡片——技术方案要求「主画布展示各子画布的缩略入口,内部按资源类型显示有限层叠卡片……主画布预览和子画布展开态复用同一套卡片视觉」,这是产品回归而非可接受的行为变更。
- 不使用「身份未知时用 idle 占位兜底渲染」这条备选:它会让总览卡片失去真实预览,观感更差。
- `pitfalls.md` 追加同题排障口径,含通用规则:凡 Hook 入参同时参与「身份 / 缓存 key」与「调度 / 预热」,就不能为优化调度去收窄它,应先确认它还有没有别的消费者。
- 回归用例 `appSurface > uses one full-page canvas per resource section with dependency-only guide lines` 已自行变绿(未改该断言)。验证:`npm run test -- apps/ai-game-creator-shell/tests` 1171 passed / 4 skipped / 0 failed;`appSurface.test.ts` + `useProjectResourceCardPreviews.test.ts` 415 passed;`src/components/image-editor` 1385 passed;typecheck exit 0;check:encoding 4374 文件;`git diff --check` 干净。
2026-09-11 01:47:04 +08:00
suzmii 7fa61231ae AGC 资源画布两条过期断言跟上新合同,并补项目切换的预览缓存清理行为断言
- appSurface > renders one body-first card system:把原先钉「全局只读 1 次」的断言收窄成「同一身份不因重复请求新增物理读取」——热预取之后先让 IntersectionObserver 再报一次可见、再打开一次详情,读取次数必须与快照一致;并先钉住快照非空且读取目标都是 assets/hero.png,避免空集恒真。60d8b8fbb 把热预取改成只喂当前栏目后,用例里每进出一次「角色与对象」都会重读该栏目的可见卡(该 commit 已明确接受这个代价),全局总次数会随进出次数漂移;「同一身份同时只读一次」是 PRD 里真实存在的合同,比总次数更精确,没有放宽。
- appSurface > keeps the resource preview version cache wired and cleared on project switch:60d8b8fbb 删掉了从未被写入的 resourcePreviewVersionByResourceId 死接线(只被 new Map() 初始化与清空,previewVersionByResourceId 恒为空串),断言守的 prop 与其清空语句都已不存在,按「四不写」删除这条形态断言;用例改名 clears the resource preview cache and cancels the old preview scope on project switch。
- 同用例把意图从源码正则换成可执行行为断言(原断言只证明接线存在,不证明缓存真被清):渲染 A 项目并等卡片预览落到 Blob URL 后切到 B 项目,断言 ① 旧卡片预览的 Blob URL 被撤销、② 旧 scope 的在途读取按旧 scopeId 取消、③ 同一资源以新项目身份与新 scopeId 重新读取。
- 变异验证在 %TEMP% 下的独立 worktree 沙箱里做(未改本工作树产品代码):去掉预览 Hook 里 loaded 早退 → 第二条用例失败(同身份读取 6→7);去掉 cancelLocalProjectResourcePreviewScope(previousScopeId) → 第三条 ② 失败;切项目不复用新 scopeId → ③ 失败;拆掉两条撤销 Blob URL 的路径 → ① 失败。
- 未改 uses one full-page canvas per resource section with dependency-only guide lines:沙箱实验证明只把预览 Hook 入参从 activePageResources 换回 canvasResources,该用例即整体通过。它守的「总览逐栏绘制各栏目真实卡片」是被 60d8b8fbb 意外收窄的(renderResourceBookCard 在 identityByResourceId 缺该资源时返回 null,而该 map 从该 commit 起只覆盖当前栏目;其 commit message 只声明了预取口径与重读代价,未声明总览不再绘制其它栏目卡片),属产品回归,已上报,不以改断言掩盖。
- 门禁:ai-game-creator-shell:typecheck exit 0;apps/ai-game-creator-shell/tests 1 failed / 1170 passed / 4 skipped(唯一失败即上面上报的回归;改前 3 failed / 1168 passed);src/components/image-editor 1385 passed;check:encoding 4374 文件;git diff --check 干净。
2026-09-11 01:33:10 +08:00
suzmii 1ac8cc3eca 补记本轮 AGC 修复的排障口径与既有约定
- pitfalls.md 新增「资源搜索栏改成临时叫出的浮层」条目:写清"回到资源总览点不动"的两层成因(横跨整行、z-index 40 的透明工具条带盖在 z-index 30 的栏目标题栏上吃掉命中区域;管理区 padding-top 与场景 inset 只为给搜索框腾带子)、浮层的几何口径(bottom: 58px = Dock 14px + 34px 高 + 10px 间隙,刻意不写 top;提示层 top: 58px = 标题栏 42px + 16px)、必须钉住的两条(提示层 pointer-events: none + 提示条 auto;收起不清搜索条件、由 Dock 按钮 is-active 表达),以及门禁为什么抓不到(jsdom 不做命中判定)。
- pitfalls.md 新增「画布侧分类筛选条已移除」条目:记录用户口径、只移除画布这一处用法而保留共享组件、随它删掉的画布筛选轴,以及"不要顺手改分区、将来恢复要重新设计入口"。
- pitfalls.md 新增「项目对话输入区必须留在对话框内部」条目:记录两只绝对定位盒子上下紧贴的成因、对话框=消息列表盒子 + 输入区三边内缩 12px 的做法,以及列表留白 256px 与编辑器 min/max 高度必须成对维护。
- 本次只补文档,不改代码;编码检查 4374 文件通过,文件保持 LF。
2026-09-11 01:24:17 +08:00
suzmii 60d8b8fbb2 资源卡异步解码、热预取只喂当前栏目,并删掉未接线的预览版本 map
- 资源卡 `<img>` 补 `decoding="async"`,把真机 37.2 MPx 量级的 PNG 解码移出主线程。刻意不加 `loading="lazy"`:卡片已经由资源画本的 `IntersectionObserver`(`rootMargin: 160px`,root 指向真正包含卡片的容器)精确门禁,只对进入可见区及预取边界的卡片发 IPC;再叠一层浏览器视口懒加载会让「预取边界 + 热预取 12 张」读到的视口外卡片被压住不解码,等于白预取。`decoding="async"` 不改变何时加载,与既有调度正交。
- 卡片预览的热预取改为只喂当前分页栏目:原先传的是 61 项全量投影,前 12 张可能落在别的栏目,用户看不到的卡片先占满全局 3 个物理读取槽,看得见的卡片反而要排队。
- 为此把 `projectResourcesByCategory`、`resourcePageCategoryKey`、`resourcePageCategories`、`activePageCategory`、`activePageResources` 整块上移到 `useProjectResourceCardPreviews` 之前并删除原位置定义;`projectResourcesByCategory` 只依赖 `canvasResources` 与 `categoryOrder`,其余只在既有依赖之上派生,上移不引入前向引用。`activePageResources` 合并为单一声明(原先在 `activePageLayoutReady` 之前另有一份同定义局部变量)。
- 已知副作用并接受:Hook 的 `identityByResourceId` 只覆盖当前栏目,因此「A 栏目 → B 栏目 → 回到 A」会重新读取 A 的可见卡。这正是「栏目级热预取」的定义,也符合 PRD §3.3.2「项目 / mode 切换必须清理预览缓存」的口径;代价是来回切换栏目各重读一次可见卡,单张实测 1–7 ms 且仍受全局 3 槽限制。
- 删除 `resourcePreviewVersionByResourceId` 的三处引用与 `useState` 声明。它从未被写入过:只被 `new Map()` 初始化和清空,`previewVersionByResourceId` 恒为空串,属死代码。
- 选择「删」而不是「填真值」:填真 manifest revision 会让每次 manifest 变更把全部缓存 identity 失效并触发全量重读;而现有 identity(`projectPath` / `projectId` / `id` / `category` / `path` / `mediaType`)对不可变素材已经足够——路径变了就是另一个资源。本轮不新增失效判据。
- 遗留:`appSurface > keeps the resource preview version cache wired and cleared on project switch` 断言的是刚被删除的 prop 接线,需要同步删掉该断言;该测试文件当时正被另一个 Agent 在途编辑,按分工不在本次改动内,已上报由主人处理。
2026-09-11 01:04:06 +08:00
suzmii bdb07c141b 聊天输入区收回对话框内部
- 根因:`.project-supervisor-surface.is-direct-codex` 下消息列表与输入区是两只各自带边框、
  各自绝对定位的盒子——列表 `bottom: 104px`、输入区 `bottom: 0; left/right: 0`,两盒上下
  紧贴。输入区自己那只盒子的左右描边与列表的左右描边接成两条贯穿整栏的竖线,加上列表
  只留 24px 底部内边距,最后一条消息还会被不透明的输入区盖住。观感就是"输入框紧挨着
  对话框、两侧有突兀竖条"。
- styles.css:消息列表(direct-codex)改为铺满整个会话区(`bottom: 104px` → `0`),它自己
  那只带描边和圆角的盒子就是"对话框";`padding-bottom` 与 `scroll-padding-bottom` 由 24px
  提到 256px,给框内输入区留出位置(256 ≈ 输入区最高 230px + 12px 内缩 + 约 14px 间距)。
- styles.css:输入区(direct-codex,含同类选择器共两条绝对定位规则)由贴边改为框内内聚
  区块:左右下三边各内缩 12px,圆角 14px 收到与外框同一档 12px;边框、底色、阴影继续用
  既有 token(`--platform-surface-border` / `--platform-input-fill`),不新增配色。
- 不改交互语义:输入区仍是同一棵 DOM(编辑器、@ 引用、AI 润色、模型选择、提交按钮全部
  原位),只改它在对话框里的落点与留白。
- project-development.suite.ts:新增守卫——对话框盒子四边归 0、输入区三边内缩同一个值、
  仍是绝对定位且保留边框与底色、消息列表留白 ≥ 输入区最高高度 + 内缩且与
  `scroll-padding-bottom` 同值、编辑器 min/max 高度就是留白算式的来源;并同步既有那条
  钉 `bottom: 104px` 的断言为 `bottom: 0`。
- 说明:本机无无头浏览器、vitest 未开 `css: true`,像素级观感只能靠真机复核;这里钉的是
  声明级几何关系与"输入区仍在同一棵渲染树里"。
2026-09-11 01:00:10 +08:00
suzmii 3bd4daf5d5 补「回到资源总览」命中区域的守卫断言
- 成因回填:分页态标题栏右端的「资源总览」(回到资源总览)按钮点不动,是常驻工具条带 `.game-resource-book-tools` 造成的——它是横跨整行、`z-index: 40` 的绝对定位层,正好盖在栏目标题栏那条带(`z-index: 30`)上,把按钮的命中区域整段吃掉。上一提交撤掉这条带后问题已消失,本次只补守卫,不改实现。
- project-development.suite.ts:新增声明级守卫,钉住浮层提示层(`.game-resource-book-notices`)与返回按钮所在标题栏的层级关系——提示层 `z-index` 高于标题栏(提示要看得见),因此容器必须 `pointer-events: none`、只有提示条自己 `pointer-events: auto`;提示条顶边 ≥ 标题栏 `min-height`,连视觉重叠都不发生;并反向断言常驻工具条带在 styles.css 与 index.tsx 里都已不存在(变异实测:把容器改回 `pointer-events: auto` 该用例立即红灯)。
- project-development.suite.ts:新增渲染级守卫——附件导入失败条在场(提示层挂载)时进入分页态,断言返回按钮不在提示层容器内、不是 disabled,点击后 `data-resource-book-view` 回到 `main` 且「资源总览」region 在场。
- 说明:jsdom 不做布局命中判定,像素级"是否被盖住"只能由声明级的层级/命中声明 + 真机复核确认;渲染级用例钉的是"提示层在场时返回入口仍然渲染、可点、并真的接在返回总览上"。
2026-09-11 00:57:52 +08:00
suzmii 5e29b1bb57 预览链去掉无消费者的摘要计算,并为预览读取加进程内 manifest 缓存
- 资源卡预览不再计算 SHA-256:`AgentRuntimeInspectionImage.sha256` 改为 `Option<String>`,读取函数新增 `include_sha256` 开关,门禁、分块读取、漂移与重开身份复核、签名与尺寸校验仍只有一份实现,只在末尾按调用方决定是否算摘要。预览路径传 `false`(它的 `LocalProjectImagePreview` 从来不消费摘要),Agent `image.inspect` 路径继续传 `true`。
- 取摘要改为 `sha256_digest() -> Result`,缺摘要失败关闭;`project_gates.rs` 提前算成 `image_sha256` 再进判定闭包,避免退化成「空摘要 == 记录里的空摘要」而静默通过视觉检查复核;`runtime_tools/media.rs` 改用仓库既有的 `match { Err => return failed observation }` 风格,不引入新的 panic 面。
- 新增 `read_manifest_cached_for_preview`,只接到 `read_local_project_image_preview_at`、`read_local_project_text_preview_at`、`read_local_project_media_preview_at` 三个预览命令,消掉一次画布装载中「每张卡片各读并解析一遍同一份 manifest」的重复开销。`read_manifest` 本体、`read_manifest_for_project` 和写入路径的安装后回读一字未动。
- 缓存命中判据每次重新获取当前文件身份:路径复核 + 非符号链接或 reparse point + 普通文件 + 长度 + mtime + 文件身份(Windows `(volume serial, file index)`,Unix `(dev, ino)`);失败一律不入缓存,错误语义与 `read_manifest` 完全一致。为此放开 `metadata_is_windows_reparse_point` 为 `pub(crate)` 并新增跨平台 `open_file_identity_key`。
- 写入侧硬做失效:`write_manifest_with_lock_hook` 成功写入后调用 `forget_preview_manifest`。只靠长度 + mtime + 身份堵不住同长度原地改写,显式失效后正确性不再依赖时间戳粒度,读取侧身份复核保留为兜底。
- 收益如实记录:真机项目实测 52 次 manifest 读加解析约 10 ms 量级,并省下约 3.15 MiB 重复磁盘读取。这是消除重复工作的正确改动,但不是性能救命项;用户感知的加载耗时大头仍在资源卡图片解码。
- 决策记录补「AGC 资源卡预览维持 data URL 加纯内存 LRU,不引入 Tauri asset 协议」:正式否决该候选路径,写明原始合同原文(不向 WebView 暴露任意本机文件协议或绝对路径)、打通所需配置(`assetProtocol` 加 `protocol-asset` feature 加 scope 只能 `**/*`)、会绕过的全部门禁(`file.read` 权限、项目边界、登记复核、敏感路径、父目录链接、硬链接、读取漂移与重开身份、按魔术字类型校验、尺寸上限、取消语义与全局 3 permit),以及收益与代价不成比例;同时否决磁盘缩略图缓存。
- `pitfalls.md` 补同题排障口径,含判据陷阱:把「每次启动重读」当成本瓶颈是错的,真机 52 张 PNG 分层实测 Rust 加 JS 合计仅约 0.3 s。
2026-09-11 00:57:33 +08:00
suzmii f7831348c6 搜索栏改成「临时叫出」的浮层,并撤掉为它引入的工具条带
- index.tsx:搜索框不再常驻。常态只在右下角缩放 Dock 末尾留一颗搜索按钮(复用 `game-resource-book-zoom-button`),`Ctrl/Cmd+F` 或点它把浮层叫出来;关闭走本仓既有浮层口径——直接复用美术画布的 `useImageCanvasFloatingOptionDismiss`(Esc / 点外部,Escape 在 document 上截断,不会连带触发画布清选中)。
- index.tsx:浮层打开后自动聚焦搜索框,`resourceSearchRef` 不再是死 ref(PRD「焦点落到资源搜索框」的入口接上了)。
- index.tsx:搜索语义零改动——`setSearchText`、`visibleResources` 过滤、「清除搜索并定位」原样保留;浮层收起不清条件(`type=search` 的原生 Esc 清空被 preventDefault 挡掉),条件生效时 Dock 搜索按钮带 `is-active` 高亮,用户看得出"当前有搜索"。
- index.tsx:撤掉常驻搜索条连带的那条排布带——删除 `resourceBookToolsRef`、`resourceBookToolsHeight`、`resourceCanvasSceneSize()`、ResizeObserver 测高 effect,以及缩放锚点 / 滚轮锚点里减掉工具条带的两处算式;`resourceBookSceneSize` 重新等于管理区盒子。
- index.tsx:状态提示与附件导入失败条移出排布带,落到新的浮层容器 `game-resource-book-notices`(顶部绝对定位,不占流内高度)。
- styles.css:`.game-resource-manager` 去掉 `padding-top: var(--game-resource-book-tools-height)`,`.game-resource-book-scene` 恢复 `inset: 0`,删除 `.game-resource-book-tools` 规则与窄屏 560px 那条只为常驻搜索框写的宽度规则;搜索框改为贴右下角 Dock 向上展开的浮层(`bottom: 58px` = Dock 的 14px + 34px 高 + 10px 间隙,`z-index: 40`)。
- styles.css:新增 `.game-resource-book-notices` 浮层与 `.game-resource-book-zoom-button.is-active`(复用 Dock 按钮的 hover 色,不新增配色 token)。
- tests/appSurface/harness.ts:新增 `openResourceSearch()`——按产品入口打开浮层再返回输入框,`getByLabelText` 在浮层未打开时直接抛错,堵掉"元素在但点不到"那类假绿。
- tests/appSurface/project-development.suite.ts:重写搜索可见性守卫为「浮层不落在标题栏那条带上」(管理区不再有 padding-top、场景 `inset: 0`、浮层底边 ≥ Dock 底边 + Dock 高度、浮层不带 top、提示层顶边 ≥ 标题栏高度),新增浮层开合用例(按钮 / Ctrl+F 打开、焦点落在输入框、Esc 与点外部收起都不清条件),并把既有 5 处搜索用例改为先叫出浮层。
- tests/projectResourceLiveIntegration.test.tsx:「清除搜索并定位」用例改为重新叫出浮层读条件与清空后的值。
- 验证:新增的两条断言已用变异实测不是恒真守卫——去掉自动聚焦、或让关闭顺手清条件,对应用例都会红灯。
2026-09-11 00:56:52 +08:00
suzmii bb48345206 移除资源画布上的分类筛选条
- index.tsx:删掉画布侧 PlatformResourceFilterBar 的用法(「全部 / UI 交互 / 角色与对象 / 场景与环境 / 音频 / 文档 / 待归类」分类 chip 与画布标签 chip);@ 素材面板与参考图弹窗继续用同一个共享组件,共享组件本身不动。
- index.tsx:随筛选条一起摘掉只为它存在的画布筛选轴——resourceCanvasCategoryFilters / showResourceCategoryFilter / resourceCanvasActiveTags / resourceTagLibrary,并把 visibleResources 里「分类不等于 all 才过滤」「标签必须命中已选标签」两个此后恒真的条件删掉,画布可见资源只由搜索框收窄。
- index.tsx:移除只被筛选条用到的导入(PlatformResourceFilterBar、RESOURCE_REFERENCE_FILTERS、type ResourceReferenceFilter、assetTagsMatchSelection、buildGameCreationAppAssetTagLibrary)与 ResourceCanvasCategoryFilterBySortMode 类型及其默认值构造函数。
- styles.css:删除只服务画布筛选条的 .game-resource-category-filter 规则。
- 不动分区栏目:栏目大纲、栏目标题栏、categoryOrder、projectResourcesByCategory 与「资源总览」入口保持原样。
2026-09-11 00:38:02 +08:00
suzmii 30acd9552c 写入侧产出 canonical kind,读取侧读时重派生自愈存量分类
- 写入侧:infer_canvas_export_asset_kind 直接返回 canonical kind(animation → character-animation、ui → ui-design、asset → image),不再让别名表替写入侧兜底
- 写入侧补防御性归一,并新增 canvas_export_asset_kind_is_always_canonical 逐分支钉死返回值为 canonical、且不再写回 ui / animation / asset
- 读取侧:gameCreationAppAssetCategory 加收窄覆盖规则——落盘 category 为 unclassified 且 kind 能派生出明确非 unclassified 分类时采用派生值
- 该规则不写迁移脚本、永久自愈:真机 57 条 ui 资产待归类从 58 降到 1,UI 交互从 2 升到 59,仅剩 code 类 game-entry 在待归类(分类表设计口径)
- 收窄条件把覆盖窗口压到最小:落盘值本身是明确分类时仍信任落盘值(保留用户在分类与标签面板手动设置的权威性)
- kind 派生结果本身即 unclassified 的 image / video / code / publication-material 不受影响,补断言钉住
- resourceCardPreviewRealManifest 由「记录缺陷」改为「守住修复」:断言归位后的 58 → 1 分布与 57 条 ui 全部落 UI 交互
- PRD 与 pitfalls 补记分类取值优先级、读时自愈规则、唯一盲区(手动把可明确分类的资产设为待归类会被覆盖)及写入侧 canonical 化
2026-09-10 23:15:04 +08:00
suzmii b87120445c Merge remote-tracking branch 'origin/master' into feat/agc-canvas-resource-workbench-v3 2026-09-10 21:24:05 +08:00
suzmii df9c32b5ea 记录 kind 别名只救新登记不救存量 category 的陷阱
- 补记 ui 资产落待归类的完整成因链:kind 兜底 image 再经 image → unclassified 误分
- 写明 register_local_asset_entry 命中同路径时不重算 category,故存量 57 条无法自愈
- 写明读取侧必须信任落盘 category 的原因:存在用户手动改分类的正式链路
- 列出三个根治选项供拍板:读时重派生 / 一次性回填 / 只改写入侧
- 记录别名表在 TS 与 Rust 各有一份实现、必须成对维护,以及交叉守卫位置
2026-09-10 21:19:19 +08:00
suzmii c5df56d855 补齐资源 kind 别名表:ui 归 UI 交互,game-* 归代码,animation 归角色
- TS 侧 GAME_CREATION_APP_LEGACY_ASSET_KINDS 补 5 条别名:ui → ui-design、game-entry/game-script/game-style → code、animation → character-animation、asset → image
- Rust 侧 canonical_game_creation_app_asset_kind 同步补齐同一批别名,两侧必须成对维护
- 按拍板口径不往 GAME_CREATION_APP_CANONICAL_ASSET_KINDS 加新 kind,避免出现 ui 与 ui-design 两个同义 kind
- 保持 art-spritesheet-slice → icon → ui-interaction 不动
- 补注释写明别名表的职责是「线上值 → canonical」而非仅收历史遗留,画板导出等现役写入侧同样要在这里收口
- 新增 assetKindCanonicalMapping.test.ts:解析 Rust 源码交叉钉住两侧 canonical 目录与别名映射逐条一致,并逐条断言已拍板的 kind → canonical → 栏目口径
- 新增 resourceCardPreviewRealManifest.test.ts:按真机 manifest 逐条形状可执行地数出预览分支与栏目分布(raster-image 52 / placeholder 8 / code 1;unclassified 58 / ui-interaction 2 / scene 1)
- 真机取证用例同时钉住「别名只救新登记、不救存量」:57 条 ui 的落盘 category 仍是 unclassified,需读时重派生或一次性回填才能归位
- 修正两条按旧错误栏目断言的既有用例:kind=ui 的上传图归「UI 交互」、animation 视频归「角色与对象」
2026-09-10 21:18:55 +08:00
suzmii 2fefb373d1 修复 AGC 资源搜索框与栏目标题栏挤在同一水平带的重叠
- styles.css:新增 `.game-resource-book-tools` 工具条带,搜索框、筛选条与状态提示在里面按流式排布;`.game-resource-search` 去掉 `margin: 10px 12px 0`,尺寸与间距交给工具条带。
- styles.css:`.game-resource-manager` 加 `padding-top: var(--game-resource-book-tools-height, 0px)`,`.game-resource-book-scene` 的 `inset: 0` 改成同一变量让出顶边;管理区与画本场景共用一条实测高度,两者不再落在同一条排布带上(总览态标题栏与分页态栏目标题栏都整体下移)。
- styles.css:`@media (max-width: 560px)` 下搜索框独占整行,窄屏筛选条换行时工具条带变高、场景按实测高度继续下移,不靠 z-index 硬压。
- styles.css:更新工具条带注释,说明 `z-index: 40` 仍保留(高于场景的 20),作用只剩"工具条内容溢出时不被画本盖住"。
- index.tsx:把画本场景以外的流式工具条包进 `game-resource-book-tools`,搜索框挂 `resourceBookToolsRef`,用 ResizeObserver 实测 `offsetTop + offsetHeight` 写入 `--game-resource-book-tools-height`;搜索、筛选、「清除搜索并定位」与 `resourceSearchRef` 全部零改动。
- index.tsx:新增 `resourceCanvasSceneSize()`,视口居中、「适应内容」、总览入场中心、滚轮与缩放锚点都按"管理区盒子减掉工具条带"计算,不再按"场景一直顶到管理区顶端"排布;`handleResourceBookWheel` 与 `zoomResourceBookBy` 补上新增依赖。
- index.tsx:`measureResourceBookOverview()` 改用画本场景自己的 rect 作为总览栏目矩形原点,场景盒子上移后栏目矩形与世界坐标仍严格同源。
- project-development.suite.ts:在既有 CSS 声明级守卫里补断言——管理区 `padding-top` 与场景 `inset` 必须指向同一个 `--game-resource-book-tools-height` 变量,工具条带是绝对定位的独立排布行,栏目标题栏 z-index 低于工具条带;选择器加行首断言,避免误取 `.game-workbench-stage > .game-resource-manager`。
- project-development.suite.ts:在栏目大纲用例里补真实渲染的 DOM 归属断言——搜索框的父链不得命中画本场景或资源总览层,工具条带是管理区直接子节点;该断言在改动前失败、改动后通过,已用"把工具条带类名改掉"的变异验证它不是恒真假守卫。
2026-09-10 21:12:30 +08:00
suzmii df4168f71f 记录资源预览读取分支与版本时间的排查口径
- PRD 明确队列身份用的 category 是资源分区栏目、只用于前端去重与布局,不作为 IPC 入参
- PRD 明确 read_local_project_media_preview 的线上 category 是原生读取分支,只接受 art / audio,须由卡片预览类型派生
- pitfalls 新增「媒体预览 category 是读取分支而非栏目」:记录阶段二把用例一起改成错误行为导致门禁失守,并写明现有守卫位置与实测失败口径
- pitfalls 新增「versions[].createdAt 是 Unix 秒」:记录夹具与实现一起错导致门禁失真,以及已知秒值断言年月日的验收口径
2026-09-10 21:05:30 +08:00
suzmii 14d1635858 修复游戏版本时间显示 1970 年:createdAt 按 Unix 秒换算成毫秒
- 写入侧真实单位是 Unix 秒:project/manifest.rs 的 GameIterationVersion.created_at 赋值为 unix_timestamp(),该函数返回 duration.as_secs()
- formatIterationVersionLabel 原来把秒值直接交给 new Date(),被当成毫秒解释,真机因此显示 1970/1/22(1788075047 毫秒 = 1970/1/22 00:41:15,与截图逐秒吻合)
- 显示侧改为 new Date(version.createdAt * 1000),与 ResourceAssetDeleteDialog 和待确认资源编辑创建时间标签的既有秒口径一致
- 给 GameIterationVersion.createdAt 补契约注释,写明单位是 Unix 秒及换算要求,避免后续再按毫秒消费
- resourceCanvasVersionBindingModel 夹具由毫秒改为真实秒值,并用真机取值 1788075047 补断言:年月日必须是 2026/8/30 而不是 1970
- resourceVersionSwitch 夹具同步改为真实秒值,避免继续沿用误导性的毫秒夹具
2026-09-10 21:00:54 +08:00
suzmii dde860716e 修复资源卡片媒体预览发不出请求:线上 category 改回原生读取分支
- 新增 projectResourceMediaPreviewCategory:从卡片预览类型派生原生侧线上 category(art / audio),与画布分区栏目轴解耦
- read_local_project_media_preview 调用点不再传 job.resource.category:该字段自 6 类资产分类轴落地后是画布栏目(unclassified / ui-interaction / …),传给原生侧会被 read_local_project_media_preview_at 以「媒体预览类别只支持 art 或 audio」直接拒绝
- 该回归导致所有走媒体分支的卡片(GIF / SVG / AVIF / BMP / MP4 / WebM / MOV 与音频)读不出预览,只显示占位图标
- previewReadErrorMessage 与 resourceReadKindLabel 改用卡片预览类型判定文案,不再依赖已换语义的 category
- 废弃 useProjectResourceCardPreviews 对 projectResourceDisplayKind 的引用,错误文案与读取路由共用同一分类口径
- 预览 Hook 用例补「扩展名图片按 art 分支发出请求」与「音频仅在播放意图后按 audio 分支发出请求」两条断言,直接钉住原生侧线上取值
- 应用层媒体用例补 icon.svg 的 category 必须是 art 的断言,并修正原先描述「媒体读取改传画布栏目」的过期注释
2026-09-10 20:59:22 +08:00
suzmii 2f8753f597 AGC 资源画布切换为 6 类资产分类加项目版本栏目(阶段二:投影轴与栏目表落地)
- 契约层把 ProjectResourceCanvasPosition.section 加宽为 PersistedProjectResourceCanvasSection 并删除旧 ProjectResourceCanvasSection 联合
- 七个分区类型消费点改用 ProjectResourceCanvasCategory:投影、布局模型、布局 Hook、分区高度、依赖图层与画布历史
- RESOURCE_CANVAS_SECTION_ORDER 改为 PROJECT_RESOURCE_CANVAS_SECTIONS,删除 RESOURCE_CANVAS_VISIBLE_SECTION_ORDER
- reconcileResourceCanvasLayout 接入读时归一化:旧栏目值按资源当前分区改写 section,坐标与手动标记原样保留
- 归并后的布局与原布局逐项不同,由既有协调路径判定 changed 并写回一次新分区,不引入迁移脚本
- 投影层五个资源入表点统一走 projectResourceCanvasCategory:项目版本独立成栏、Agent 回执归文档、其余按资产分类
- 新增 projectResourceDisplayKind 收敛 manifest 资产 subtype 即 kind 的显示类型口径,projectResourceTypeLabel 不再依赖旧栏目
- 卡片预览、编辑分流、媒体工具条、预览文案与画布历史快照改用显示类型与新分区,历史快照入栈时收窄到现行分区
- index.tsx 删除可见栏目导入与 visibleCategoryOrder,栏目标签由资源筛选唯一中文口径派生并补齐 7 栏
- index.tsx 补齐 7 栏默认视口与 7 个栏目图标(新增 LayoutGrid、Users、PackageOpen,移除已无用的 Code2)
- index.tsx 删除 canvasResources 的 code 过滤:只登记游戏代码的项目现在落在待归类栏目并正常显示卡片
- 新增只登记游戏代码项目的 UI 回归用例,覆盖栏目大纲、默认落地栏目与代码卡片渲染
- 测试夹具按新分区轴迁移,新增旧栏目 sidecar 读时归并用例与栏目顺序用例
- 共享测试 harness 的资源卡查询正则允许栏目标签自带空格(UI 交互)
- 同步 PRD 与共享记忆决策记录的栏目口径描述
2026-09-10 20:41:19 +08:00
suzmii eda34187fc 修复 AGC 资源栏目大纲常态不可读:恢复 Dock 底板、栏目图标与完整文字
- styles.css:`.game-resource-outline` 常态从 `opacity: 0.68` + `scale(0.86)` + 透明底 + 无阴影,改回完整尺寸(`opacity: 1`、`transform: translateY(-50%)`)并给现有视觉语言的 Dock 底板(`--platform-subpanel-border` 边框 + `--platform-nav-fill` 背景 + 与右下角缩放 Dock 同款阴影 + `blur(6px)`)。
- styles.css:`.game-resource-outline button` 常态列宽由 `0 minmax(0,1fr)` 改为 `18px minmax(0,1fr)`、`gap: 6px`,栏目图标常态 width 14px / opacity 1,不再只在悬停时才出现。
- styles.css:栏目文字 11px → 12px;当前栏目(`.is-active`)常态就带 `--platform-nav-active-*` 的边框、底色和阴影,用户一眼能认出当前分区。
- styles.css:悬停/键盘聚焦保留为同一套 token 的加强(更深的边框、`--platform-nav-active-shadow`、`blur(10px)`),不再靠"常态缩到只剩文字"表达层级。
- 不改交互语义:大纲仍然是 `openResourceBookChild` 的入口,按钮 key、aria-label、aria-current 与未读小圆点全部保持原样。
- 测试:新增 CSS 声明级断言,钉住常态不降透明度、不 scale、有底板,图标常态 14px 且文字 12px(jsdom 不加载 styles.css,可见性只能钉声明)。
- 测试:新增 DOM 用例,断言每条栏目入口同时具备图标与栏目文字,且切换栏目后当前项带 `aria-current="page"`。
- 文档:同步《GameAgent资源自由画板与快速编辑》的「操作边界」与「验收」两条,把"常态缩小、只露栏目文字"改成"常态即可读可点",并同步 pitfalls 里的对应口径。
2026-09-10 20:11:09 +08:00
suzmii bf63766d88 给 SpacetimeDB 冒烟用例的杀进程断言加局部有界超时:只改这一条,不动全局预算
- spacetime-editor-idempotency-smoke.test.ts:terminates a live standalone-shaped child process 补第三参 20_000,并加注释写明这是负载敏感的局部超时。
- 性质说明:这是局部有界超时,不是放宽判据。断言 child.exitCode !== null || child.signalCode !== null 一字未动,全局 testTimeout 与 maxThreads 也未动。
- 为什么不加注入桩:该用例的存在意义就是验真实杀进程路径(spawn 真实常驻 node → stopStandalone → Windows 上 spawn taskkill /PID /T /F 杀进程树并等退出)。加桩等于把被测对象换掉,比放宽断言更隐蔽,因此明确不采用注入桩。
- 为什么需要:这两个真实进程的启动与回收在全并行负载下会明显超过默认 5000ms;单独跑实测 167ms,全仓并行时曾撞满 5000ms 上限。
- 上限仍具约束力:stopStandalone 自身对 SIGTERM → SIGKILL 只有 5s 有界等待,而 runTaskKill 与 SIGKILL 之后的 once(child, 'exit') 都没有内部超时,20s 外层上限仍能抓住真实挂死。
- 已实测第三参在当前 vitest 0.34.6 生效:临时把该值改成 1 时复现 Test timed out in 1ms,改回 20_000 后该文件 4 passed。
2026-09-10 20:00:58 +08:00
suzmii 9196ba3c96 修复 AGC 资源画本框选没有任何颜色:给画本场景根补共享画布根类
- index.tsx:`ResourceBookScene` 的场景根 `.game-resource-book-scene` 补上 `genarrative-image-canvas` 类,让共享 `SelectionOverlay` 的框选 token 有定义它的祖先。
- 根因:共享选择框的 `border: 1px solid var(--genarrative-image-canvas-selection-border)` / `background: var(--genarrative-image-canvas-selection-fill)` 没有 fallback,这两个 token 只声明在 `packages/image-canvas-react/src/styles.css` 的 `.genarrative-image-canvas` 根类里,而该根类由共享 `CanvasViewport` 渲染;AGC 只渲染 `SelectionOverlay`、从不渲染根类,声明在计算值阶段被整条丢弃,用户框选时看不到任何选择框。
- 安全性:该根类只有 3 条自定义属性声明、无布局副作用,加 className 不改布局。
- 测试:新增真实 `pointerdown` / `pointermove` 框选用例,断言选择框存在、内联几何非空,且最近的 `.genarrative-image-canvas` 祖先就是画本场景根;去掉根类后该用例失败(已实测)。
- 测试:新增 CSS 声明级断言,钉住根类的描边/底色 token 存在且非透明,消费端仍按 var() 取值(jsdom 拿不到计算色,这里只能钉声明)。
- pitfalls.md:补记"复用共享画布子组件要自己渲染根类"的排查口径。
2026-09-10 20:00:42 +08:00
suzmii 7e6f7555eb 修复 AGC 资源搜索框被 display: none 误关导致的搜索链路死代码
- styles.css:`.game-resource-search` 的 `display: none` 恢复成 `display: flex`(原始提交 3a8dff140 里就是 flex,PR #193 重排工作台时被改成 none),并补 `position: relative; z-index: 40`、常态 `align-self: center`、与右下角缩放 Dock 同款的浮层阴影。
- styles.css:`.game-resource-live-notice` 同样补 `position: relative; z-index: 40`;「清除搜索并定位」按钮只在这条提示条里,提示条被画本场景盖住时搜索链路依旧是断的。
- 根因(堆叠顺序):资源卡与栏目标题栏都画在 `.game-resource-book-scene`(absolute + z-index 20,子画布态还有一层不透明点阵底)里,而搜索框与提示条是 `.game-resource-book-manager` 的流式子节点;定位元素按绘制顺序整体盖在流内内容之上,所以只放开 display 等于"画了但看不见、点不到"。
- 可见性口径:AGC 的 vitest 没开 `css: true`、不加载 styles.css,`toBeVisible()` 在这里是恒真的假守卫;新增的守卫按本仓既有口径读 CSS 源文件做声明级断言(不是 display:none、z-index 高于画本场景)。
- 行为用例:总览态输入即过滤总览摞上的卡片;分页画布态同一搜索条件继续生效、清空后卡片恢复,证明这条链路确实由搜索框驱动。
- 端到端用例:搜索条件挡掉刚提交的新素材时保留条件并提示,点「清除搜索并定位」后清空搜索并定位选中新素材。
2026-09-10 19:59:52 +08:00
suzmii 6f09bf3a00 AGC 资源画布分区轴改为 6 类资产分类加独立项目版本栏目(阶段一:契约与读时映射)
- 契约层新增 PROJECT_RESOURCE_CANVAS_SECTIONS:6 类资产分类加末尾独立的项目版本栏目,并补齐分区类型
- 契约层新增旧四 / 五栏目取值白名单、Persisted 联合类型与两个类型守卫
- Rust 契约 ProjectResourceCanvasSection 增补 7 个现行 variant 并保留 Code / Art 旧值,新增同名分区常量
- Rust 契约不升 game-creator-resource-layout.v1 版本、不给未知 section 加兜底,损坏 payload 继续失败关闭
- 新增 resourceCanvasSectionMapping.ts:旧栏目到现行分区的映射表、回落栏目与读时归一化
- 读时归一化只改写 section,x / y / manuallyPlaced 原样保留,无法归并时沿用既有丢弃行为
- resourceProjectionModel 把扩展名分类器更名为 projectedResourceKind 并收敛为准入与显示类型
- resourceProjectionModel 新增 projectResourceCanvasCategory:项目版本独立成栏、Agent 回执归文档、其余按资产分类
- Rust resource_layout 测试夹具改用现行分区值,并新增既有旧 section sidecar 仍可读取的用例
- 新增 resourceCanvasSectionMapping 测试:旧值乘目标栏目矩阵覆盖坐标保留、section 改写、changed 判定与回落列不变量
- projectResourceProjectionModel 测试新增 7 栏分区投影、项目版本独立成栏与只登记游戏代码项目落在待归类
- 同步 PRD、GameAgent 资源自由画板技术方案、AGC 实施计划与共享记忆决策记录的分区口径
2026-09-10 19:57:34 +08:00
suzmii 180a157982 同步 pitfalls 中失效的 ACL 桩名称并补注入点说明
- pitfalls.md:把「测试超时补充」里引用的 persistenceOptions 改为当前实际使用的 skippedWindowsAclOptions,避免文档指向已不存在的名称。
- pitfalls.md:补一句 prepareSwarmTestRuntimeConfig 暴露的可选 secureWindowsPath 注入点、默认 null 走真实实现,以及单次调用最多加固 3 次的事实。
2026-09-10 19:56:02 +08:00
suzmii a698c9170c AGC 配置向导与隔离配置用例统一改传 ACL no-op 桩
- agentSwarmTestEntry.test.ts:新增模块级 skippedWindowsAclOptions,仅 win32 下为 { secureWindowsPath: async () => {} },其它平台为空对象。
- agentSwarmTestEntry.test.ts:atomically replaces a private AppData config file 的两次 writeGameCreatorConfigAtomically 传入该桩,3663ms → 24ms,不再只剩 27% 超时余量。
- agentSwarmTestEntry.test.ts:moves the default LLM to primary config and lets later GUI saves win 原本的局部 persistenceOptions 收敛为同一份模块级常量,去掉重复声明。
- agentSwarmTestEntry.test.ts:isolated Swarm runtime config 的三处 prepareSwarmTestRuntimeConfig 传入该桩,privately copies 1948ms → 84ms,refuses to delete 1331ms → 79ms。
- 断言零改动:这几条用例在 win32 下本就不校验 ACL(mode / dev / ino 断言全部在 process.platform !== 'win32' 分支内),非 win32 下该常量为空对象、行为逐字不变。
- 实测:该文件 13.59s → 1.35s,文件内最慢用例从 3663ms 降到 84ms。
2026-09-10 19:55:58 +08:00
suzmii ec6973605b 给隔离测试配置加上可选的 Windows ACL 加固注入点:默认真实实现
- agent-swarm-test-chat.mjs:secureWindowsPrivateRuntimePath 增加可选第三参 secureWindowsPath,传入时直接调用它,不传时完整保留原有的 await import + secureWindowsGameCreatorPathForCurrentUser 调用。
- agent-swarm-test-chat.mjs:copyPrivateRuntimeConfigEntry 增加可选第五参 secureWindowsPath,并透传给上面的加固函数。
- agent-swarm-test-chat.mjs:prepareSwarmTestRuntimeConfig 增加可选第三参 { secureWindowsPath = null } = {},透传给运行配置目录加固与两次配置文件复制。
- 生产路径行为逐字不变:默认值为 null 时 if 分支不进入,执行的原语句与改动前完全一致;唯一生产调用点仍按单参调用(prepareSwarmTestRuntimeConfig(sourceConfigDir))。
- 加这个接缝的原因:Windows 上每次私有路径加固都要拉起一个真实 powershell.exe(本机实测约 0.6 秒 / 次),一次 prepareSwarmTestRuntimeConfig 在双配置文件场景最多加固 3 次,只校验隔离与清理语义的用例不该为此耗掉 vitest 的 5000ms 默认预算。
2026-09-10 19:55:52 +08:00
suzmii fc5adf48aa 记录 AGC 配置向导单测的 Windows DACL 子进程超时坑
- pitfalls.md:在「终端真实测试不能混用配置参数、stdin EOF 和持续预览」下补一条测试超时补充,紧接既有的 Windows PowerShell 参数条目。
- 内容:配置向导读路径与写路径都会加固私有目录与配置文件,没拿到 secureWindowsPath 桩时 Windows 上每次调用各拉起一个真实 powershell.exe(本机实测约 0.6s / 次);只桩写不桩读会累计 9 次加固、约 5.5s,稳定撞上 vitest 默认 5000ms 并报 Test timed out in 5000ms。
- 结论口径:只校验配置分层优先级、不校验 ACL 的用例,读写路径必须带同一份 persistenceOptions;确实要覆盖私有 ACL 的用例才保留真实加固并自行留足超时预算。
2026-09-10 19:34:04 +08:00
suzmii 2570831779 修复 AGC 配置向导持久化用例在 Windows 上稳定超时:读路径补上 ACL 桩
- agentSwarmTestEntry.test.ts:readGameCreatorWizardConfigState 的三处调用补传 persistenceOptions,读路径不再拉起真实 powershell.exe 去加固私有 DACL。
- agentSwarmTestEntry.test.ts:在 persistenceOptions 定义处补注释,说明读路径同样会加固、读写都要带桩,避免后续再漏。
- 失败形态:单跑该文件稳定 1 failed | 124 passed,报错为 Test timed out in 5000ms,不是 expected / actual 断言不符。
- 根因:该用例只校验配置分层优先级(local 覆盖 primary → 向导写入后剥离 local 的 llm → GUI 后写 primary 获胜),但三处读路径都没传桩;每次 readGameCreatorWizardConfigState 会加固目录、主配置、本地配置各一次,三处读累计 9 次 powershell.exe 子进程。逐段实测 5474.9ms / 5494.9ms,正好压过 vitest 默认 5000ms。
- 基线定性:8f96b05f6 上同一用例同样失败(连续两次 1 failed | 124 passed,同样报 5000ms 超时),且该用例与 game-creator-config-wizard.mjs、agent-swarm-test-chat.mjs 在本批前后逐字节一致(blob 相同),故不是本批引入的回归。
- 未放宽任何断言:补桩后该用例 5500ms → 39ms,本文件 13.6s → 8.7s,断言与断言语义均未改动。
2026-09-10 19:33:58 +08:00
suzmii cb76aadb48 AGC 输入区三处观感修复:AI 润色归位、@ 面板筛选条单行、引用 chip 原子化
Project CI / Repository checks (pull_request) Failing after 15s
Project CI / Backend tests (pull_request) Failing after 15s
Project CI / Frontend tests (pull_request) Successful in 3m5s
Project CI / Native shell tests (pull_request) Successful in 18m26s
- styles.css:把 direct-codex composer 下广播式的 `.project-supervisor-composer button` 规则
  收窄到 `.project-supervisor-submit-button`(含 svg 那条与 media query 里那条),
  并给 `.resource-reference-input-actions` 补 `grid-column: 1 / -1` 让输入区操作排独占一行
- styles.css:`.resource-reference-input-actions` / `.resource-reference-input-at` 撤掉绝对定位,
  操作排回到网格文档流,与输入框右下角对齐,不再浮在文本区中间
- styles.css:`.resource-reference-input-polish` 与 `.resource-reference-input-restore` 并入
  `@` 按钮同一条尺寸规则(28px 方钮、8px 圆角、同 hover/disabled 观感)
- styles.css:输入区状态行(润色中/润色失败)改为与应用排同一行,不再单独占一行顶高 22.8px
- styles.css:direct-codex 输入框 padding 收为 `12px 15px 6px`,窄屏 `10px 12px 6px`
- styles.css:`.resource-reference-chip` 的 `×` 删除按钮不再被 broadcast 规则摘出 chip
- PlatformSegmentedTabs.tsx:分段页签 item 加 `whitespace-nowrap`,中文不再逐字竖排
- packages/shared/styles.css:补 `.scrollbar-hide` 实现(该类名一直被 `layout="scroll"` 引用但仓库未定义)
- ResourceReferenceNode.tsx:`getTextContent()` 改为单个 U+FFFC 占位符并显式实现
  `getTextContentSize()`,引用成为真正的原子内联节点(1 个引用 = 1 个字符)
- tests/resourceReferenceInput.test.tsx:新增「1 个引用 = 1 个字符」与「chip 自带删除、一次删净」两条断言
- tests/appSurface/project-development.suite.ts:把 composer 按钮样式断言收窄到提交按钮,
  并新增一条反向断言,钉住「广播到 composer 下所有 button」的规则不再出现
2026-09-10 19:05:57 +08:00
suzmii c20e28bed0 修正 AGC 快速编辑 4xx 失败原因取值:优先 details.message 并附 provider 与素材类型上下文
- `editor_api_rejection_reason` 取值顺序改为 `error.details.message` → `details.message` → `error.message` → `error` 文本 → 顶层 `message`
- 平台把所有 4xx 兜底成「请求参数不合法」,真实原因写在 `details.message`,原实现只读 `error.message` 导致用户永远看不到原因
- `details` 有值时附带 `provider`,并透出 `assetKind` / `mediaType`,非 BAD_REQUEST 的稳定错误码照旧透出
- 抽出同步函数 `editor_api_rejection_reason_from_body` 并补定向用例,钉住取值顺序、截断与兜底行为
2026-09-10 19:01:09 +08:00
suzmii 8c74e5f97c 修正 AGC 图片快速编辑的 400 判据测试:补 art-spritesheet / ui 正向用例并钉住请求级链路
- 服务端白名单用例改为按静态图集合断言,新增 `art-spritesheet` / `ui` / `ui-prototype` / `game-art` / `game-background` / `art-spritesheet-slice` 正向用例
- 保留 `future-kind` / `video` / `audio` / `image-sequence` / `character-animation` / `document` / `code` 负向用例,并补 mediaType AND 门用例
- 新增请求级回归用例 `game_creator_client_quick_edit_accepts_local_manifest_static_image_kinds`,按 AGC 客户端真实请求体(source 已改写为 ai-game-creator-client)覆盖放行与拒绝两侧
- 新增 `game_creator_client_source_is_recognized_as_game_creator_resource_editor`,把 source 别名与队列 consumer 判定钉在同口径
- 修正 icon 相关旧断言:`icon` 已是共享契约静态图类型,改断言 non-static 媒体仍被拒
2026-09-10 19:01:04 +08:00
suzmii 9e59abde53 修复 AGC 图片快速编辑被平台 400 拒绝:来源类型表改为与共享契约对齐的静态图集合
- `ensure_editor_image_edit_source_kind_allowed` 的白名单换成静态图类型表 `EDITOR_IMAGE_EDIT_STATIC_IMAGE_ASSET_KINDS`(canonical 静态图类型 + 合法 legacy 类型)
- 保留 `mediaType == image` 的 AND 门,视频 / 音频 / 序列帧等非静态媒体照旧拒绝
- 覆盖实测被拒的 `art-spritesheet` / `ui`,以及 canonical 映射输入域 `art-spritesheet-slice` / `ui-prototype` / `game-art` / `game-background` / `character-art` / `illustration`
2026-09-10 19:00:52 +08:00
suzmii 6a11032084 修复 AGC 图片快速编辑被平台 400 拒绝:服务端认 ai-game-creator-client 来源别名
- `is_game_creator_resource_editor_generation` 增加 `GAME_CREATOR_CLIENT_GENERATION_SOURCE` 别名,与 `from_job` 的队列 consumer 判定同口径
- AGC 客户端发送前会把 `generationInputs.source` 改写成 `ai-game-creator-client`,原来只认 `game-creator-resource-editor`,导致该分支对真实请求永远不生效
2026-09-10 19:00:44 +08:00
suzmii 7d0f5c68fe 快速编辑浮层样式照搬美术画布:逐条搬真实规则,补回共享组件的 Tailwind 来源
- resourceCanvasChrome.css:删掉手写的近似样式,从网页端 src/index.css 逐条搬入 AGC 实际用到的 image-canvas-editor__* 规则——生成 composer 基座(grid 布局、42rem 宽、1.45rem 圆角、双阴影)、generation-prompt(--auto-grow-editor-* 变量 + 聚焦态)、composer-footer 五列网格(修掉底部一行挤在一起、对齐混乱)、option-cluster / option-popover-anchor 及其 --dimensions / --model 变体、option-popover 与它的 sections / section / title / items / items--card / items--model、option-popover-choice 及 --ratio / --model 与 aria-pressed 选中态、ratio-wireframe 六个比例线框、option-selected-check、mud-point-inline、generation-submit 三态、quick-edit-panel / --quick-edit / quick-edit-footer / quick-edit-prompt / quick-edit-submit 这一组面板专属覆盖。
- resourceCanvasChrome.css:补入提示词输入框依赖的 auto-grow-text-area 一族规则(外层尺寸变量、content 内边距、placeholder、viewport 与 OverlayScrollbars 竖条),原来只给了裸边框和 padding,所以输入框看着很素。
- resourceCanvasChrome.css:保留/新增的宿主规则只有美术画布没有的那几项——工具条与面板的层级、锚点 transform、窄屏宽度;面板的 overflow 由 auto 改成 visible,因为 AGC 的下拉弹层是就地渲染、没有 portal,滚动容器会把它裁掉。
- styles.css:新增 @source 指向 src/components/image-editor 与 src/components/common。这两个目录不在 AGC app 根下,Tailwind 自动探测扫不到,组件自带的工具类(h-3 / rounded-full / inline-flex / text-slate-700 …)在 AGC 里根本没生成,是「样式和美术画布对不齐」的另一半根因。
- 选中工具条上一轮已按真实规则改好的部分原样保留,未回退。
2026-09-10 18:59:33 +08:00
suzmii f49ff61ddc 资源画布浮层接上美术画布的关闭时机:空白/Esc 清焦点,快速编辑点外部即收
- 新增 resourceCanvasFocusModel:把「这次 pointerdown 要不要清画布焦点」「快速编辑浮层是否接管点外部关闭」「宿主 Escape 是否归资源画布」抽成纯判据,逐条对齐美术画布 clearCanvasFocus() / closeTransientEditorPanels() / clearCanvasSelection()。
- index.tsx 画布空白左键 pointerdown 调 clearResourceCanvasFocus():清空选中(选中工具条随之收起)并关闭快速编辑浮层,与美术画布 useImageCanvasStageInteractions 的空白分支一致;中键/空格平移分支不清,避免平移顺手丢选中。
- 快速编辑浮层复用共享 hook useImageCanvasFloatingOptionDismiss:document 上接管 click 与 Escape,点整个资源画布以外即关,点边界内(含模型/比例下拉 .image-canvas-editor__portal-menu)保留;宿主弹窗(资源面板/生成素材/分类与标签/重命名/恢复队列)开着时不接管,避免清掉资源面板共用的多选。
- 选中工具条补 window 级 Escape:与美术画布 clearCanvasSelection() 同口径清空选中;下拉弹层打开时共享 hook 在 document 上截断,浮层优先收内层。
- 生成为中的快速编辑面板不关(status === 'generating'),保留「修改中」状态与失败重试入口。
- 新增定向测试:交互目标判据、生成中不关、宿主弹窗让位、点边界外关闭、Esc 两态关闭、只有工具条时点外部不清选中、模型/比例下拉点外部与 Esc 关闭。
2026-09-10 18:58:15 +08:00
suzmii 8f96b05f6e 资源画布工具条接回下载按钮:走资源面板同一条真链路并收紧放行判据
- index.tsx 把资源面板的保存流程抽成共用 saveProjectResourcesToDisk(原生保存对话框 + save_local_project_asset_file),资源面板改为委派调用,不再有第二份保存实现

- index.tsx 工具条 onDownloadLayer 由空回调改为调用该共用 handler,回执写进 resourceWorkbenchNotice;不可导出的资源仍直接返回

- resourceCanvasToolbarModel.ts 把 download 加回 supportedActions,判据是 isResourceCanvasExportable(拿不到本地文件路径就不渲染),不按媒体类型放行

- resourceCanvasToolbarModel.test.ts 期望值随判据重算为 [quick-edit, download] 或 [download],并补 download 放行与拒绝两侧用例(空 path、虚拟版本条目)

- project-development.suite.ts 音频工具条按钮集合断言更新为 [分类与标签, 重命名, 下载按钮],并新增两条真链路断言:工具条下载打到 save_local_project_asset_file、资源面板下载仍打到同一命令

- appSurface.test.ts 在入口文件 mock 原生对话框,只替代用户选择目标路径这一步;放别处注册会晚于真实模块实例化
2026-09-10 18:05:15 +08:00
suzmii 93ddb841cd 抽出资源落盘判据 isResourceCanvasExportable,资源面板与工具条共用一条口径
- resourceCanvasAssetTransferModel.ts 新增 isResourceCanvasExportable:只有拿得到可落盘本地文件路径的资源(version 为空且 path 非空)才允许保存

- resolveResourceCanvasPanelEntries 的 downloadable 改为调用该判据,去掉内联的同一段表达式

- 纯重构,资源面板行为不变;目的是让画布工具条的下载放行判据与资源面板使用同一个权威口径,不再写第二份
2026-09-10 18:05:02 +08:00
suzmii c9413fae0c 补资源画布工具条动作模型定向测试,把放行判据钉在测试里
- 新增 tests/resourceCanvasToolbarModel.test.ts:直接覆盖 resolveResourceCanvasToolbarActions,此前该模型零直接覆盖

- 逐条钉住当前真实接通的动作集合:栅格图片与角色资源只放行 quick-edit,音频/视频不再放行 redraw,角色资源不再放行 character-animation

- 覆盖派生门禁:未登记 manifest 资产且无已完成任务产物背书不放行、只认已完成任务的栅格产物、非栅格图片不放行

- 判据出处为 docs/project-memory/shared-memory/pitfalls.md 的「渲染出来的动作必须真的能跑通」
2026-09-10 17:53:25 +08:00
suzmii ecca7050b5 音频资源工具条断言改判据:不再要求渲染空回调的改造按钮
- project-development.suite.ts 的音频工具条断言由「必须存在改造按钮」改为「不得存在改造按钮」+「按钮集合精确等于分类与标签、重命名」

- fed17ea08 已按共享记忆 pitfalls.md 的判据(渲染出来的动作必须真的能跑通)把 redraw 与 character-animation 移出 supportedActions,音频分支只剩宿主编排层真实接通的分类与标签、重命名,原断言断言的是被该提交推翻的旧合同

- 新断言比旧断言更严:除钉住音频分支仍在渲染,还钉住整条工具条的按钮集合,避免再出现点了没反应的假按钮
2026-09-10 17:53:15 +08:00
suzmii 7fe1fd9b64 Merge branch 'feat/agc-v3-canvas-generation-entry' into feat/agc-canvas-resource-workbench-v3 2026-09-10 17:40:49 +08:00
suzmii 94fc4c6779 Merge branch 'feat/agc-v3-c7-version-switch' into feat/agc-canvas-resource-workbench-v3 2026-09-10 17:40:46 +08:00
suzmii 994209fc33 Merge branch 'feat/agc-canvas-art-editor-port' into feat/agc-canvas-resource-workbench-v3 2026-09-10 17:40:43 +08:00
suzmii fed17ea08c 工具条只渲染真实接通的动作,去掉两个点了没反应的按钮
- resolveResourceCanvasToolbarActions 不再把 redraw 与 character-animation 放进支持集合:这两个动作在宿主编排层仍是空回调,放进去会让工具条渲染出点了没反应的按钮
- 判据收敛为一条:放进来就必须真能跑通,当前真实接通的只有快速编辑(normalize → derive(editKind='image-reference'))
- 补注释写明接上真实链路后再放回来
2026-09-10 17:39:17 +08:00
suzmii 9ce31f905e 沉淀 AGC 画布现状与取数约束到共享记忆
- pitfalls.md 追加「AGC 走不了网页端 /api/editor 的取数链」:Tauri asset 协议 + CSP connect-src 未放行平台域名,且 dev 的 vite /api 代理会造成「能跑」假象
- pitfalls.md 追加「ImageCanvasStageView 是硬组合容器」:132 props(57 数据 + 75 回调)、无条件渲染 8 个子视图、83/132 需宿主伪造编辑器状态,且不透传 supportedActions
- pitfalls.md 追加「AGC 资源画布真正自制的交互面」:resourceBookViewport.ts、resourceCanvasLayoutModel.ts 的 fit/normalize、index.tsx 3656-4090 指针段,并记录两条平移路径守卫不一致与右键菜单、chrome 样式缺口
- pitfalls.md 追加「资源画布工具条改造/角色动画按钮点了没反应」:空回调仍留在 supportedActions 里,按钮渲染但无人接
2026-09-10 16:50:02 +08:00
suzmii 746b96e71d 资源编辑 400 同时透出平台错误码
- editor_api_rejection_reason 额外读取响应体 error.code,过滤掉等价于通用文案的 BAD_REQUEST 后拼成「原因|错误码 CODE」
- 背景:http_error.rs 把所有 4xx 兜底为同一条「请求参数不合法」,只透 message 会丢掉唯一可用于定位的信息
2026-09-10 16:45:29 +08:00
suzmii 6496601d12 资源画布接入生成素材入口并打通无源新建链路
- index.tsx 只做导入、一个生成入口按钮、一处浮层渲染与提交回调;无 invoke 桥或项目未就绪时入口不渲染
- 提交走 derive_local_project_resource 的 create 模式:sourceResourceId 为 create:<operationId>,sourceAssetId / sourcePath / sourceSubtype / producerTaskId / sourceVersionId 全为 null
- 产出后经 pendingResourceFocusRef 定位并选中新素材卡,并写入生成资源已保存提示
- projectResourceLiveIntegration 补入口可见性、create 入参、重试复用 operationId 与新卡定位 2 条用例
- 实施计划文档补 C3 生成入口口径、图片无源生成未完成项与本轮验证数字
2026-09-10 16:37:52 +08:00
suzmii 0035d16efa AGC 资源画布新增生成素材浮层与生成入口模型
- 新增 resourceCanvasGenerationModel:按本地契约锁定无源生成放行的视频 / 音效 / 背景音乐三类,提供 create:<operationId> 源标识、入口渲染门禁与请求身份工厂
- 新增 ResourceCanvasGenerationPanelView:独立浮层面板承载类型选择、草稿与失败重试,失败后锁定原请求并复用同一 operationId / idempotencyKey
- 浮层只呈现三类真实可用类型,不出现图片专属的模型 / 比例 / 尺寸 / 像素艺术与泥点价
- resourceCanvasChrome.css 补浮层宿主样式
- 新增 resourceCanvasGenerationEntry 定向测试 6 条
2026-09-10 16:37:39 +08:00
suzmii 56c6e0fecc 修复工具条折行与资源编辑 400 的错误盲区
- resourceCanvasChrome.css:移除我自行添加的 max-width 与 flex-wrap: wrap,按网页端美术画布原始规则改回单行不换行(容器 nowrap + 溢出横向兜底),按钮统一 2.25rem 方块与 0.45rem 圆角,文字键 padding 改回 0 0.62rem,分隔线改回 1.125rem,消除动作变多时折叠成多层与按钮高矮不齐
- resource_editor.rs:新增 editor_api_rejection_reason,在 HTTP 400 分支把服务端响应体的可读原因(兼容 error.message / error 字符串 / details.message / 顶层 message,截断 200 字符)拼进用户可见错误,替换原先只有状态码的笼统提示
2026-09-10 16:33:13 +08:00
suzmii f09f5f9e9c 补齐 AGC V3 收口阶段的决策记录
- decision-log 新增两条 2026-09-10 决策:资源画布基础能力收口(撤销重做只作用于卡片布局、独立资源面板、上传走 upload_local_asset、下载改为显式保存链路 save_local_project_asset_file + dialog:allow-save)
- decision-log 新增:运行模块版本切换(版本状态唯一一份放在 WorkspaceLauncherShell、运行模块右上角 GameRunVersionPicker、切换只重载预览不做运行时资源重映射)、参考图弹窗与共享筛选条、标签库纯派生口径、素材重命名前端接线
- 同步修正上文"重命名只落 Rust 侧"的已知中间状态:前端调用方已落地,该中间状态结束
2026-09-10 16:05:01 +08:00
suzmii f2f8a9af72 合并 WP4 资源面板与撤销重做:保留 C7 版本入口与资源面板接线
- project.rs 冲突:同时保留 mod asset_export(WS-B 下载命令)与 mod asset_rename(WS-D 重命名命令),丢弃已在 WS-A 退役的 asset_usage
- index.tsx 冲突:同时保留 GameRunVersionPicker(C7 版本入口)与资源面板/历史模型导入(WP4)
2026-09-10 15:57:42 +08:00
suzmii 422a71d321 资源画布新增独立资源面板:预览/上传/下载/多选(WP4)
新增 features/resource-canvas/resourceCanvasAssetTransferModel:面板网格模型、选中集合解析、默认导出文件名、上传白名单(纯函数)

新增 features/resource-canvas/ResourceCanvasPanelView:独立浮层面板承载预览网格/上传/下载/全选/清空,选中状态直接读写画布同一份 selectedResourceIds,不造第二套选择

下载改为显式保存链路:前端 @tauri-apps/plugin-dialog save() 取目标路径,再调新命令 save_local_project_asset_file 复制素材文件,避免依赖不可靠的 <a download>

Rust 新增 project/asset_export.rs 与命令 save_local_project_asset_file:校验项目根与相对路径、源必须是真实普通文件(拒绝符号链接与目录)、目标路径非空且父目录存在、分块流式复制不进整文件内存,返回目标路径与字节数

Rust 单测覆盖成功复制(内容一致 + byteLen + 源不变)、缺失源拒绝、目录源拒绝、空目标与父目录缺失拒绝、目标为目录拒绝

上传复用现役 upload_local_asset(落 assets/uploads 并登记 manifest 条目)后回读 manifest 刷新,只接受图片/音频/视频

capabilities/main.json 增加 dialog:allow-save;资源画布 chrome 样式补资源面板一套;index.tsx 只做导入/一处按钮/一处渲染/状态透传
2026-09-10 15:56:10 +08:00
agent 00963c5f67 同步 AGC V3 版本切换、参考图弹窗、标签库与素材重命名 UI 的功能文档
- 实施计划追加 2026-09-11 条目:标签库派生口径、共享筛选条、C7 版本切换、C9 参考图弹窗、素材重命名 UI 与 allowlist 收敛

- 写明版本切换不做运行时资源重映射的理由与参考图 ID 快照的落库路径

- 记录本次门禁实际数字与 5 条待 C3 侧更新的既有失败用例
2026-09-10 15:50:54 +08:00
suzmii 09cd47748c Merge branch 'feat/agc-v3-c7-version-switch' into feat/agc-canvas-resource-workbench-v3 2026-09-10 15:49:46 +08:00
agent 3d709ea1db 合并主干 C3 交互改造并接入 C7 版本切换
- 合并 feat/agc-canvas-resource-workbench-v3:资源卡改为选中+浮出工具条、支持多选、删除资源详情面板与 resourceEditorRoute

- index.tsx 冲突以 C3 新结构为底:保留本包的素材重命名回调,丢弃被 C3 取消的 focusedResource 详情面板派生状态与浮层 JSX

- 重命名入口从已删除的详情面板搬到选中工具条 extraActions,与「分类与标签」并列

- check-config.mjs 的 allowlist 保持主干版本(normalize_local_project_raster_resource 一行已删)

- 还原 normalize_local_project_raster_resource Tauri 命令与注册:C3 已在前端接回调用方

- C7:新增 GameRunVersionPicker 运行模块右上角版本入口,无版本不渲染

- C7:版本状态收敛到 WorkspaceLauncherShell 一份,透传给资源画布与 @ 面板;换项目或版本消失时清回最新版本

- C7:currentVersionResourceBindingIds 接入 activeVersionId,复用 resolveActiveIterationVersion 口径,资源卡「当前使用」高亮随版本切换

- C7:切换版本时复用既有 onPlay 预览入口重载画面,不新增运行时资源重映射

- 新增 10 条测试覆盖版本判定口径、版本入口与切换、当前使用高亮、素材重命名链路
2026-09-10 15:46:08 +08:00
suzmii 9121e89798 资源画布新增组织操作撤销重做(WP4)
新增 features/resource-canvas/resourceCanvasHistoryModel:纯函数有界快照栈(capture/push/undo/redo/clear + 只回写差异位置),快照只含资源卡布局坐标,不回滚素材内容

新增 tests/resourceCanvasHistoryModel.test.ts 覆盖排序稳定、相同快照不入栈、栈上限、push 清空重做分支、撤销重做互逆、空栈返回 null、差异回写与已删除资源不重建

资源卡拖动持久化成功前先落快照,撤销/重做复用现役 commitPosition 手动 CAS 写链

画布缩放集群新增撤销/重做按钮(不可用时禁用),并支持 Ctrl/Cmd+Z 与 Ctrl/Cmd+Shift+Z、Ctrl+Y(输入框内不拦截)

项目切换时清空布局历史,避免跨项目回滚
2026-09-10 15:43:03 +08:00
suzmii 9939a1bca0 Merge branch 'feat/agc-v3-c3-canvas-match' into feat/agc-canvas-resource-workbench-v3 2026-09-10 15:29:41 +08:00
suzmii f247a5106a 收敛资源画布交互复刻的测试:清理详情面板遗留断言,门禁回到基线
删除用例 keeps the resource toolbar and canvas interactive while a non-modal detail card is open:该用例断言的「非模态详情卡」已被 C3 合同取消,按四不写删除

新增用例「资源卡 chrome 只保留选中按钮与当前版本边框」,接住被删用例里仍然有效的卡片 chrome 断言(无基础描边、is-current-version 边框、选中按钮样式)

版本卡与 Agent 回执卡没有美术画布动作,断言改为选中反馈(aria-pressed)而不是工具条

blocked.md 的策略失败提示原本渲染在已删除的详情面板里,断言随之移除

快速编辑测试的 fixture 增加栅格美术资产(includeArt 参数),不改动其它用例的 manifest
2026-09-10 15:29:15 +08:00
agent f32445d700 补齐 AGC V3 附二素材重命名前端链路
- 新增 ResourceRenameDialog:独立重命名面板,只输入新文件名,规则交给 Rust 侧强校验

- 资源详情面板新增「重命名」动作,严格按 deny_unknown_fields 只传 projectPath/assetId/newFileName

- 改名成功后复用 reloadManifestAfterAssetCommand 这条 manifest 重载路径刷新资源卡与 @ 面板显示名

- 删除 check-config.mjs 过渡 allowlist 里的 normalize_local_project_raster_resource,并移除已无调用方的同名 Tauri 命令与注册

- 新增 2 条前端测试覆盖严格入参、manifest 重载与改名后资源卡显示名刷新
2026-09-10 15:24:20 +08:00
suzmii a74cd6c8b6 Merge branch 'feat/agc-v3-c3-canvas-match' into feat/agc-canvas-resource-workbench-v3 2026-09-10 15:19:53 +08:00
suzmii bd36587e0a 资源画布交互 1:1 复刻美术画布:点卡选中并浮出工具条,移除资源详情面板与全屏编辑路由
新增 features/resource-canvas:资源卡到美术画布 CanvasLayer 的适配、工具条动作可用性判定、C5 当前版本绑定判定、快速编辑草稿工厂与 AGC 宿主 chrome 样式

资源卡点击改为选中(Shift/Ctrl/Meta 多选),选中后在卡片旁复用 ImageCanvasSelectedLayerToolbarView 浮出工具条

快速编辑复用 ImageCanvasQuickEditPanelView 浮层面板,先调 normalize_local_project_raster_resource 正规化任务产物,再走 derive_local_project_resource(editKind=image-reference) 产出新素材,失败重试复用同一 operationId/idempotencyKey

删除 resourceEditorRoute 全链路(ResourceEditSurface 路由、openResourceEditor、handleResourceCardOpenEditor、cancelResourceEditor、submitResourceEdit)与资源详情面板(focusedResource 状态/引用/浮层 JSX/CSS 断言)

分类与标签入口搬到选中工具条;UI 资源保留「UI 编辑器」入口并从工具条打开

移除 apps/ai-game-creator-shell/scripts/check-config.mjs 中 normalize_local_project_raster_resource 的过渡 allowlist 条目

为共享工具条组件新增 opt-in 的 supportedActions/extraActions(默认 null/undefined,网页端行为不变)

AGC vite-env.d.ts 补 wx/ReactNativeWebView/WeixinJSBridge 跨端全局声明;hostBridge fail 回调补显式 unknown 类型

测试:资源卡查询 helper 由资源详情按钮改为选中按钮,清理详情面板断言,画布空白左键改为框选后平移用例改用中键
2026-09-10 15:19:17 +08:00
agent 3508957eda 补齐 AGC V3 C9 参考图选择弹窗与提交快照
- 新增 ImageCanvasProjectAssetPickerDialog:独立弹窗,网格卡片、多选、筛选+搜索、清空/确认/取消,筛选状态自持

- 新增 projectAssetReferencePickerModel:素材分类与筛选判据、素材 id 快照引用构造、弹窗引用前缀判定

- 生成 composer 参考图来源菜单新增「从项目素材中选择」,由宿主决定是否出现,不改变原有从画布选择与上传两条路径

- 新增 replaceProjectAssetPickerReferences:确认时整组替换素材库来源的参考图,保留画布点选与上传的参考图

- 素材 id 经 sourceAssetId 走既有 createGenerationInputReference 落进 generationInputs.references,不新增后端接口

- 新增 13 条测试覆盖弹窗交互、筛选叠加、快照落库与整组替换语义
2026-09-10 15:13:40 +08:00
agent 3379e6009e 抽出共享资源筛选条并接入画布标签筛选
- 新增 packages/shared 的 PlatformResourceFilterBar:搜索(可选)+ 功能分类 + 标签的受控筛选条,只承载表现与交互

- 资源画布筛选改为复用该组件,去掉画布内联的分类 PlatformSegmentedTabs,搜索框保持原样不动

- 画布新增标签筛选状态与派生标签库,标签与功能分类共存可叠加

- 新增 3 条组件测试覆盖受控渲染、回调转发与缺省行隐藏
2026-09-10 15:02:55 +08:00
suzmii d851aa83ff 补充 AGC 资源工作台 V3 的实施计划条目
- 新增 2026-09-10 条目,写清版本绑定恒等化与撤回运行期资源观察侧车的边界(删 asset_usage 及其 16 条测试、绑定退回恒等映射)
- 写清素材删除三分支语义、deleteReferencedVersions 入参、read_local_project_asset_references 只读命令,以及版本追加唯一例外的放行集合按写入前 manifest 求值
- 写清 agc_list_registered_assets 投影新增 category/tags 且不在投影层重新派生分类
- 写清 rename_local_project_asset 的语义(文件改名 + localPath 更新、id 不变)、校验、写失败回滚与 reconciliation-required 口径,以及"不改游戏源码引用"的已知边界
- 写清 @ 面板两个页签与 activeVersionId/versions 契约、polish_local_project_prompt 的入参与计费口径(不自建计费,走 server-rs 的 LLM 路由)、发送前提醒判据与本机偏好存储
- 记录本次全部门禁数字
2026-09-10 15:02:34 +08:00
suzmii 2c07396ef7 补充 AGC 复用网页端美术画布组件的跨端依赖排障记录
- pitfalls 新增一条:AGC 引 src/components/image-editor 组件会经 ImageCanvasGenerationModel 的 ApiClientError 值导入链到 src/services/apiClient.ts 与 host-bridge/hostBridge.ts,而它读的 wx / ReactNativeWebView / WeixinJSBridge 只在主仓 vite-env.d.ts 声明
- 记录处置口径:在 apps/ai-game-creator-shell/src/vite-env.d.ts 的 Window 上补同名同形字段(纯类型声明零运行时影响),不要关闭 AGC 的 noImplicitAny,也不要另写平行组件
- 记录已核实的运行时结论:hostBridge.ts 模块作用域只有常量与缓存声明、无导入即执行副作用,window.wx 仅在函数体内访问且有 typeof window 守卫,故该依赖链编入 AGC bundle 是惰性的
2026-09-10 14:56:45 +08:00
agent 4d0cd425e1 补齐 AGC 标签库聚合派生纯模块
- 新增 gameCreationAppAssetTagLibrary:从 manifest assets[].tags 派生去重、稳定排序、含使用计数的标签库

- 标签归一化沿用写入路径 normalizeGameCreationAppAssetTags,同素材重复标签只计一次

- 新增 assetTagsMatchSelection / gameCreationAppAssetMatchesTags 标签筛选判据(AND、空选择不过滤)

- 新增 5 条纯模块测试覆盖去重计数、顺序稳定、空标签与筛选判据
2026-09-10 14:54:06 +08:00
suzmii 30e93c6c43 合并 WS-D 阶段一:新增本地项目素材重命名命令
- 解决 main.rs / project.rs / tests/mod.rs 的相邻插入冲突:main.rs 保留 read_local_project_asset_references 与 rename_local_project_asset 两条注册,project.rs 与 tests/mod.rs 只保留 mod asset_rename(asset_usage 已由 WS-A 整体删除)
- 解决 decision-log.md 末尾追加冲突:保留双方条目(WS-A/口径收敛、删除新口径、C3 交互、Agent 投影 + WS-D 素材重命名)
2026-09-10 14:46:15 +08:00
suzmii 26f1a7b19c Merge branch 'feat/agc-v3-c4-mentions' into feat/agc-canvas-resource-workbench-v3 2026-09-10 14:43:58 +08:00
suzmii dbbca47078 同步 C4 引用补全与 C8 润色提醒的功能文档
- 更新【功能说明】AGC聊天素材引用:补充素材面板双页签、activeVersionId 版本回退与空态口径
- 更新【功能说明】AGC聊天素材引用:把「改名刷新显示名」「跨会话恢复光标位置」从未完成移到已完成
- 新增【功能说明】AGC聊天AI润色与发送前提醒:写明润色回填、原文快照、失败保留原文与提醒面板行为
- 新增文档同时记录提醒判据、不再提醒偏好存本机 localStorage、以及计费仍走平台 LLM 路由的边界
2026-09-10 14:42:40 +08:00
suzmii b2477c8ca9 新增本地项目素材重命名命令(Rust 侧)
- 新增 Tauri 命令 rename_local_project_asset(输入 projectPath / assetId / newFileName),并在 main.rs 的 generate_handler! 中注册
- 新增 project/asset_rename.rs:重命名语义为“磁盘文件改名 + manifest localPath 更新”,资产 id / kind / mediaType / source / category / tags 全部不变,imageSequenceFrames 里指向该文件的本地帧一并对齐
- 校验判据:新名非空、不含路径分隔符(同时保证不跨目录)、不含 ..、通过可移植路径组件校验、扩展名与原文件一致、同目录不得已存在同名文件、assetId 与磁盘文件必须存在
- 事务与回滚:持既有项目写锁,manifest 写入走既有 write_manifest 边界(版本数组校验 + 安装后回读一致性);写失败把文件改回原名,回滚也失败时两个错误都报出并标记 reconciliation-required;同名重命名按空操作处理
- 新增 tests/asset_rename.rs 9 条用例:成功改名与身份不变、同名空操作、空名 / 分隔符 / .. / 跨目录 / 扩展名不一致 / 目标同名冲突 / 资产与文件缺失拒绝、manifest 写失败回滚、同目录帧对齐
- decision-log 记录素材重命名语义、命令边界,以及“前端调用方落地前 ai-game-creator-shell:typecheck 必然失败”的已知中间状态
2026-09-10 14:42:11 +08:00
suzmii b0d7121265 补充 C4 引用页签与 C8 润色提醒的定向测试
- 新增 chatPromptPolish.test.tsx:覆盖提醒判据、草稿指纹、本机偏好读写与润色命令调用
- 覆盖主动润色回填、可重复润色覆盖结果、恢复原文回到最初原文
- 覆盖润色失败保留原文并在提醒面板内给出重试提示
- 覆盖长文本提交被提醒面板拦下、使用原文提交、关闭取消发送、AI 润色后发送
- 覆盖不再提醒偏好落本机 localStorage 后不再拦截,以及挂载时读取该偏好
- 覆盖短文本与命令不触发提醒
- resourceReferenceInput.test.tsx 新增:两个页签的独立搜索与筛选状态
- 新增:activeVersionId、版本回退、悬空绑定过滤与空态
- 新增:资源改名后 chip 与候选列表显示名刷新
- 新增:跨会话恢复草稿后光标落在文本末尾,引用追加在文本之后
2026-09-10 14:38:59 +08:00
suzmii 89678173f5 补齐 AGC V3 C4 聊天通用引用与 C8 AI 润色发送前提醒
- @ 面板新增「当前版本素材 / 全部画布素材」两个页签,两个页签各自持有独立的搜索与分类筛选状态
- ResourceReferenceInput 新增可选 prop activeVersionId 与 versions,并把两者透传到 ProjectWorkspaceChatPane、ProjectSupervisorView、SupervisorChatOnlyView 和 App
- 未传 / null 的 activeVersionId 回退到 manifest versions[] 中最新的版本;版本不存在或没有绑定时页签显示空态,不报错也不合成资源卡
- 「当前版本素材」按该版本 resourceBindings 取仍登记在 manifest 的资产,悬空绑定按资源 id 过滤掉
- 资源改名后按资源 id 刷新编辑区已有引用 chip 与候选列表的显示名,并把新显示名同步回父级草稿
- 程序化重建草稿后把光标收回草稿末尾,跨会话恢复草稿时后续引用不再插到已失效的位置
- 新增 Tauri 命令 polish_local_project_prompt(复用短文本生成通道,计费仍由平台 LLM 路由侧完成,未改 server-rs)
- 输入区新增「AI 润色 / 恢复原文」按钮、润色中状态与失败可重试提示,失败、超时或未配置模型时保留原文
- 原文快照只在首次润色成功时落下,反复润色只覆盖结果,「恢复原文」始终回到最初原文
- 新增发送前提醒独立面板:AI 润色先润色再发送、使用原文提交、关闭取消发送、不再提醒
- 提醒判据:纯文本 trim 后不短于 40 字符、不是 / 开头的命令、本轮草稿未确认过、且用户未关闭提醒
- 「不再提醒」偏好写入本机 localStorage,不进 manifest、不进后端
- 补充润色按钮、状态提示、素材页签与提醒面板样式
2026-09-10 14:38:34 +08:00
suzmii b96f5e1476 Merge branch 'feat/agc-v3-docs' into feat/agc-canvas-resource-workbench-v3 2026-09-10 14:27:53 +08:00
suzmii f8aa87c71c Merge branch 'feat/agc-v3-cleanup' into feat/agc-canvas-resource-workbench-v3 2026-09-10 14:27:47 +08:00
suzmii 48700083a3 补齐 AGC V3 资源口径与环境约束的权威文档
- PRD §5.4 版本记录补充"只允许追加"的唯一例外:用户显式确认的连带删除,并写明放行集合按写入前 manifest 求值、被放行版本不得进入追加段
- decision-log 新增三条 2026-09-10 定稿:版本绑定恒等化并撤回运行期资源观察侧车;素材删除新口径(三分支 + 可选连带删除版本);资源画布交互 1:1 复刻美术画布(取消资源详情面板与全屏编辑路由、复用共享组件以可选 prop opt-in、AGC 宿主自备 chrome 样式)
- pitfalls 新增四条:新工作树的 cargo 必须在 npm ci 之后跑;残留 dev stack 会拖死 Rust 门禁;改共享美术画布组件时 AGC 门禁覆盖不到它自己的测试;AGC 侧没有 image-canvas-editor__* chrome 样式
2026-09-10 14:26:57 +08:00
suzmii 1c9b92e791 在 Agent 资源检索投影中暴露分类与自定义标签
- agent/direct_tool_bridge.rs:bridge_registered_resource 投影新增 category 与 tags,位置固定在 mediaType 之后,保持稳定字段顺序
- agent/direct_tool_bridge.rs:category 直接取 manifest 条目已解析的功能分类(显式合法值原样保留,历史缺字段时已按 kind 派生),tags 缺失时为空数组,不改变 manifest 结构与对外契约
- agent/direct_tools_mcp.rs:agc_list_registered_assets 工具描述补上功能分类与自定义标签,与新增输出字段保持一致
- agent/direct_tool_bridge.rs:既有序列帧投影测试补充 category 与 tags 默认值断言
- agent/direct_tool_bridge.rs:新增 bridge_registered_resource_projects_manifest_classification_verbatim,锁定显式分类与标签原样透出且不泄漏 prompt / model
2026-09-10 14:24:26 +08:00
suzmii 33ed85ba54 AGC 素材删除改为独立确认弹窗并支持勾选连带删除版本
- 新增 ResourceAssetDeleteDialog:列出使用该素材的游戏版本(标识 + 创建时间),给出「把相关游戏版本一并删除」勾选,默认不勾
- ResourceClassificationPanel 删除入口改为先读 read_local_project_asset_references,再打开独立确认弹窗,不再用面板内二次确认态
- 确认删除时按勾选状态传 deleteReferencedVersions;未被任何版本引用时不显示连带删除勾选
- styles.css 补删除弹窗、版本列表与勾选行样式
- 重写并扩充 resourceClassificationPanel.test.tsx 的删除用例:确认弹窗才发起删除、未引用时不出现勾选、列出被引用版本且默认不勾、勾选后传 true、客户端拒绝时不报告删除
2026-09-10 14:22:38 +08:00
suzmii 824934df41 收敛 AGC 资源口径:撤掉运行期使用侧车并改为确认式删除
- 整体删除运行期资源使用侧车模块 project/asset_usage.rs 与 tests/asset_usage.rs(含 16 条测试),并清理 project.rs、tests/mod.rs 里的 mod / use
- game_iteration_resource_bindings 收敛为纯恒等映射 slotId=asset:{assetId}、resourceId=assetId,不再读取侧车、不再做槽位优先
- assets.rs 删除 GAME_ITERATION_IDENTITY_BINDING_PREFIX、asset_is_bound_to_runtime_slot 与 remove_asset_usage_references,删除不再判定运行槽位、不再清理侧车引用
- manifest 写入层把“版本数组只允许追加”校验改为显式接收放行删除的版本 ID:新增 mutate_manifest_at_allowing_version_removals,其余写入路径继续传空放行集合
- delete_manifest_asset_at 改为三分支语义:无任何版本引用直接删登记、被引用且未确认时只删登记并原样保留悬空绑定、被引用且确认时素材与相关版本在同一次 manifest 写入里一起删除
- DeleteLocalProjectAssetInput 增加 deleteReferencedVersions 入参,commands.rs 与 main.rs 同步
- 新增只读命令 read_local_project_asset_references,列出引用了该素材的版本标识、revision 与创建时间
- 新增 tests/asset_delete.rs,覆盖恒等绑定、无引用删除、未确认保留悬空绑定、确认连带删除、引用投影、只允许追加校验仍拒绝其它路径与 CAS
2026-09-10 14:22:18 +08:00
suzmii 912254afa0 Merge branch 'fix/agc-test-selector-cleanup' into feat/agc-canvas-resource-workbench-v3
Project CI / Repository checks (pull_request) Failing after 18m28s
Project CI / Native shell tests (pull_request) Successful in 25m45s
Project CI / Frontend tests (pull_request) Successful in 53m14s
Project CI / Backend tests (pull_request) Successful in 57m10s
2026-09-10 11:52:23 +08:00
suzmii 53afc85319 修正 AGC 测试按新的 Lexical 聊天输入区驱动与断言
- appSurface/harness 补齐三处 jsdom 缺口:ClipboardEvent、DragEvent、Range.prototype.getBoundingClientRect;否则 Lexical 的粘贴通路直接抛 ReferenceError / TypeError
- appSurface/harness 的 submitChat 改为 async:全选并让编辑器吸收选区、清空、走产品真实 paste 通路写入,再让出一帧让 React 追平 draft,最后点发送
- 新增 composerText / composerValue / composerDisabled / setComposerText 助手,按原生控件与 Lexical contenteditable 两种 DOM 口径读写输入区
- 15 个测试文件中 116 处 toHaveProperty('value', …) 断言等义改写为 await composerText() / await composerValue();1 处 placeholder 断言改查输入区占位文案;2 处 disabled 断言改用 composerDisabled(同时覆盖 data-disabled 与 contenteditable=false);9 处 fireEvent.change 写入改用 setComposerText
- 328 处 submitChat 调用补 await,9 个非 async 用例补 async
- Godot 输入区键盘语义用例按 Lexical 实际行为改写:Shift+Enter 与组合态 Enter 由编辑器消化并插入换行、不发送、草稿保留
- 未改动 src 下任何产品代码,未放宽或删除断言
2026-09-10 11:46:02 +08:00
suzmii e1044546ad 接入素材删除入口并收敛资源命令后的 manifest 重载
- ResourceClassificationPanel 增加删除动作:首次点击进入确认态,二次点击才执行,避免误触不可逆操作
- 面板标题与按钮明确写出只移除登记、素材文件保留在磁盘
- 删除失败时把客户端的拒绝原因原样呈现(例如已被运行槽位绑定),并退出确认态
- index.tsx 抽出 reloadManifestAfterAssetCommand:资源分类更新与素材删除共用同一条重载路径,提交标识分别用 asset-classification / asset-delete 前缀
- 补 2 条面板测试:两次点击才删除且入参带 expectedProjectRevision、被运行槽位拒绝时不触发 onDeleted
2026-09-10 10:53:13 +08:00
suzmii 16748821e3 Merge branch 'feat/agc-version-switch' into feat/agc-canvas-resource-workbench-v3 2026-09-10 10:42:20 +08:00
suzmii 69741e5e13 新增素材删除能力与运行槽位绑定判定
- project/asset_usage.rs 增加 write_local_project_asset_usage_at:就地改写引用与诊断,保持身份与 revision 不变
- assets.rs 新增 delete_manifest_asset_at:素材不可变,只摘掉 manifest 登记、保留磁盘文件,并清理使用清单里指向它的悬空引用
- 删除判定只承认真实运行槽位绑定:恒等绑定是版本创建时的兼容兜底,若把它也算作被引用,任何在版本创建前登记过的素材都将永远删不掉
- CAS 口径与资源分类更新一致:持项目写锁后校验 expectedProjectId 与当前 revision,失败时 manifest 与 revision 均不变;成功后 revision 推进一格
- 新增 delete_local_project_asset 命令并注册;内联测试换成带 CAS 的公开入口
- 补 4 条删除测试:恒等绑定不阻止删除、真实槽位绑定拒绝删除并报出版本与槽位、删除清理悬空引用、陈旧 revision 与跨项目身份被拒且状态不变
2026-09-10 10:42:02 +08:00
suzmii c1ffb2386b 资源功能分类与自定义标签:筛选口径收敛为 category 并接入写入 UI
- 删除 resourceReferences.ts 中 mediaType 优先的 7 桶筛选口径与 resourceReferenceFilterKind,改由 resourceReferenceCategory / resourceReferenceMatchesCategoryFilter 以 manifest 资产 category 为唯一权威筛选口径
- RESOURCE_REFERENCE_FILTERS 改为 6 类 + 全部(全部 / UI 交互 / 角色与对象 / 场景与环境 / 音频 / 文档 / 待归类),并新增 resourceReferenceCategoryLabel 让中文名只定义一处
- ResourceReference 新增 category / tags,由 resourceReferenceFromAsset 经 gameCreationAppAssetCategory / gameCreationAppAssetTags 从 manifest 资产投影;资源画布 ProjectResource 同步新增 assetCategory / assetTags 投影与 projectResourceAssetCategory 待归类兜底
- 新增 Tauri 命令 update_local_project_resource_classification 与 UpdateLocalProjectResourceClassificationInput/Result,实现落在 project/manifest.rs 的 update_manifest_asset_classification_at,复用既有 mutate_manifest_at manifest 写锁与原子写入
- 命令持项目写锁后按 expectedProjectId / expectedProjectRevision 做 CAS,冲突分别返回 project-identity-conflict 与 project-revision-conflict;category 非 6 个合法值即报错且不回退到 kind 派生;tags 走 normalize_game_creation_app_asset_tags;只改目标条目的 category / tags 并在成功后推进一次项目 revision
- 新增 Rust 定向测试 project/manifest/classification_tests.rs:更新成功、非法分类被拒、tags 归一化、未引用资产更新不影响其它字段、revision/CAS 与既有写入一致、输入拒绝未知字段
- 前端新增 ResourceClassificationPanel(ThemedModal + PlatformSegmentedTabs + PlatformTextField),资源详情面板以「分类与标签」按钮打开独立面板,保存后重读 manifest 并经 onManifestChange 反映到筛选与投影
- @ 面板与资源画布共用 packages/shared 的 PlatformSegmentedTabs 渲染同一组功能分类标签;画布筛选状态与画布视口一样按「按依赖 / 按类型」分别保存,复用既有 sortMode 状态形状
- 更新 resourceReferenceInput.test.tsx 与 projectResourceProjectionModel.test.ts 覆盖新口径,新增 resourceClassificationPanel.test.tsx 覆盖写入命令的输入归一化与失败不回退
- 同步技术方案与 decision-log:记录按 mediaType 分类到按 category 分类的语义变化(图片 / 视频桶消失,image / video / code / publication-material 归待归类)与新写入命令契约
2026-09-10 10:25:22 +08:00
suzmii 691d0a8ce6 修正 AGC 资源卡测试查询的资源详情按钮选择器歧义
- appSurface/harness 新增 getResourceDetailButton / findResourceDetailButton / queryResourceDetailButton,按产品可访问名模板「打开资源详情:<分类标签> <资源文件名>」精确匹配,不再用会同时命中 @ 引用按钮的文件名正则
- project-development.suite 的 23 处资源卡按钮查询改用精确查询助手
- home.suite 的 7 处资源卡按钮查询改用精确查询助手
- projectResourceLiveIntegration.test 的 /rules\.md/ 按钮查询改用精确查询助手
- 未改动 src 下任何产品代码,未放宽任何断言
2026-09-10 10:15:50 +08:00
suzmii d526439508 版本资源绑定升级为真实运行槽位
- 新增 game_iteration_resource_bindings:优先使用使用清单里的 slotId 到 resourceId 绑定,再为未被槽位覆盖的资源补 asset:{id} 恒等绑定
- 恒等绑定保留是有意的:历史版本与既有校验语义不受影响,同一资源被多个槽位使用时只取首个槽位,不产生冲突语义
- ensure_initial_game_iteration_version_at 与 append_agent_game_iteration_version_at 共用该构造函数
- 补测试:无使用清单时保持恒等绑定;登记运行槽位后槽位绑定优先且未覆盖资源仍有兜底
2026-09-10 10:13:46 +08:00
suzmii a571c6e225 新增运行期资源使用归属层(C1 素材使用清单)
- 新增 project/asset_usage.rs:运行槽位到资源身份的稀疏归属清单,落在 .agent/workbench/asset-usage/usage.json
- 只保留 manifest 没有的事实(slotId/loadSite/attribution);已登记资源只存身份引用,不复制 path/sha256/kind/mediaType,避免第二份资源清单
- 引用了不存在的资源 ID、未登记素材、文件缺失、槽位重复注册一律只产出 warning 诊断,不阻断运行
- revision 只允许前进;同 revision 重放幂等;projectId 不匹配按未登记处理
- 写入沿用 resource-layouts 同款 flock 独占锁 + 原子替换
- 补 11 条定向测试:稀疏引用、未登记/未知资源 ID、缺文件、重复槽位、无槽位引用、幂等、revision 回退拒绝、projectId 隔离、落盘不含 manifest 字段
2026-09-10 10:05:28 +08:00
suzmii 3f5d3b43af 合并最新 master 到 AGC V3 资源画布分支
Project CI / Repository checks (pull_request) Failing after 7m37s
Project CI / Frontend tests (pull_request) Failing after 8m52s
Project CI / Backend tests (pull_request) Failing after 10m24s
Project CI / Native shell tests (pull_request) Failing after 10m36s
- 合入 origin/master 6 个提交(#313 项目写锁残留回收与启动诊断、#315 AGC Ctrl+C 残留后端修复)
- 冲突解决:decision-log.md 保留 V3 两条 2026-09-09 决策与 master 的写锁回收条目
- 冲突解决:pitfalls.md 保留 V3 转场经验条目与 master 的 Ctrl+C 后端残留条目
2026-09-09 21:46:09 +08:00
suzmii c936268337 Merge branch 'feat/agc-canvas-real-flip' into feat/agc-canvas-resource-workbench-v3 2026-09-09 21:40:37 +08:00
suzmii 4a5b2ad524 修复返回总览时非活动栏目标题栏从屏幕外飞入
- begin 只记录当时可见的节点:不可见节点(内联 opacity 0)的屏幕矩形不是可信的 First
- 非活动栏目标题栏在子画布态保留总览布局坐标,却被子画布 world 变换推到屏幕外,返回总览时不再以它作为动画起点
- 没有 First 的节点只走淡入,位置保持不动;活动栏目标题栏仍按原有观感收回到总览位置
- 进入栏目方向不受影响:标题栏当时可见,仍钉在原地淡出
- 新增回归用例:不可见标题栏几何首末相等、活动标题栏仍有几何动画、进入方向标题栏原地淡出
- 同步技术方案转场段与 pitfalls 经验条目
2026-09-09 21:39:20 +08:00
suzmii 4582231b78 合并 C3:资源画本转场改为真实元素 FLIP
- 合入 feat/agc-canvas-real-flip(resourceBookController 重写为同节点 FLIP、新增 sync 可中断重基、删除 ResourceBookTransitionLayer)
- 冲突解决:index.tsx 的 resourceBookLayout 导入取 C3 版(buildResourceBookScenePlan 等),去掉已被收进内部的旧 helper 导入避免未使用
- 冲突解决:资源自由画板文档的验证命令合并两侧并去掉已删除的 assetCanvasSurface.test.tsx
2026-09-09 21:24:13 +08:00
suzmii a23728e6c2 合并 C10:删除 asset-canvas 精修草稿链路
- 合入 feat/agc-asset-canvas-removal 5 个提交(前端解耦 / Rust 删除 / 测试 / 文档 / 合同文档退役)
- 冲突解决:asset_canvas.rs、asset_canvas/generation.rs、asset_canvas_tests.rs 按 C10 删除(C2 在其中新增的 category/tags 构造点随之消失)
- 冲突解决:decision-log.md 保留 C2 与 C10 两条 2026-09-09 决策条目
- 该合并吸收 C2 对被删文件的字段改动,无需单独处理
2026-09-09 21:19:52 +08:00
suzmii f84d0ea4d0 AGC 资源功能分类与自定义标签数据层
- manifest 资源条目末尾新增 category(单值 6 类,默认 unclassified)与 tags(字符串数组,默认空数组),两字段始终序列化
- shared-contracts 新增 GameCreationAppAssetCategory 枚举、GAME_CREATION_APP_ASSET_CATEGORIES 与穷举 16 个 canonical kind 的默认映射表 GAME_CREATION_APP_ASSET_CATEGORY_BY_KIND
- 历史 manifest 缺少字段时按 kind 派生分类、tags 取空数组;显式合法分类原样保留,未知分类字符串按前向兼容退回 kind 派生
- packages/shared 同步 TS 契约:分类常量与类型、映射表、gameCreationAppAssetCategory / gameCreationAppAssetTags 取值 helper 与 tags 归一化 helper
- 收编壳内 17 处 Rust 构造点:新建条目写 kind 派生默认值,更新既有条目保留已有 category / tags
- 补契约测试:字段 round-trip、默认值、legacy 兼容、未知分类前向兼容、16 项映射穷举、tags 归一化
- 同步 docs/technical 技术方案与 docs/project-memory decision-log
2026-09-09 21:10:01 +08:00
suzmii 91396f0615 删除已退役的客户端素材创作无限画布阶段一合同文档
- `docs/technical/【技术方案】客户端素材创作无限画布阶段一合同-2026-08-05.md` 整篇退役:该文档描述的中央 asset-canvas 状态、草稿 sidecar、Tauri 草稿/媒体/staging 命令、正式资产提交事务、提交事件、画布状态机与图片生成账本已随机制删除,按四不写不留墓碑文档。
- 仍有效内容已迁往:共享画布包目录与依赖方向、禁止复制的验收门、Host Port 边界、共享 chrome 与主题、图片能力与后续分期迁入 `docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md`;成功投影顺序与焦点守卫迁入 `docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md`;共享源码与资源派生闭环现状迁入 `docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`。
- 全类型现有资源非破坏性编辑的现成权威条款为 `docs/technical/【技术方案】AGC资源派生与非破坏性编辑合同-2026-09-09.md`,它是 `project/resource_editor.rs` 的现行合同,已改写为 derive + editKind='image-reference' 口径。
- decision-log 的 2026-09-09 条目改写为决策文体:写清退役理由与现权威口径所在文档,历史条目保留原样。
- 过渡项删除条件:资源画布重建快速编辑后,`normalize_local_project_raster_resource` 重新获得前端调用方,届时删除 `apps/ai-game-creator-shell/scripts/check-config.mjs` 中该 allowlist 条目。
2026-09-09 20:32:38 +08:00
suzmii 4c935ba219 同步 asset-canvas 退役后的文档现状并迁移仍有效合同
- 新增 docs/technical/【技术方案】AGC资源派生与非破坏性编辑合同-2026-09-09.md,承接阶段一合同第 16 节与第 5 节的现役条款,改写为 derive + editKind='image-reference' 口径
- 图片画布编辑器前端拆分计划新增"共享画布合同"一节,承接共享包目录与依赖方向、禁止复制的验收门、Host Port 边界、共享 chrome 与主题、图片能力与后续分期
- GameAgent资源自由画板与快速编辑删除精修草稿、最终图事务、精修来源规范化、事务恢复对账、批量导入与失败任务归档等退役章节,改写目标与操作边界,新增"编辑结果投影与焦点竞态"一节,并更新验证命令
- App实施计划删除阶段一至五的草稿画布描述,改写为共享画布源码与资源派生闭环现状,修正主视窗状态、适配器与派生提交条款
- 项目开发工作台 PRD 更新主视窗状态机与 ProjectWorkbenchViewState,移除 asset-canvas 状态
- decision-log 追加 2026-09-09 asset-canvas 整体退役决策(含保留项、路由口径、临时白名单与存量数据处置)
- pitfalls 删除草稿 CAS、素材保存区、正式素材提交原子性、生成账本与 upload ticket 五条退役条目,修正仍有效条目的关联文件与 legacyPrefix 描述
2026-09-09 20:28:22 +08:00
suzmii 4fb0de2c94 C3 资源画本转场改为真实元素 FLIP
- resourceBookController 重写为真实元素 FLIP:begin 在提交前记录 First,play 测 Last 后给同一节点加反向 transform,sync 按布局签名做可中断重基
- 转场进行中重基用剩余时长(下限 80ms),空闲布局变化用完整 420ms,预览图尺寸晚到的二次布局变化不再瞬移
- 宿主节点几何基准改存 world 局部坐标,平移和缩放不会让上一次的干净矩形过期
- 不可见节点钉在 First 淡出、挂载节点淡入;搜索/排序导致的卸载保留一帧淡出;返回总览时源栏目未回到摞上的卡片按子画布布局淡出
- 新增 buildResourceBookScenePlan 渲染计划,渲染、转场签名与淡出集合共用同一份数据
- 删除 ResourceBookTransitionLayer 与克隆快照逻辑,清理 styles.css 的转场层和 data-book-motion 隐藏规则
- 重写 resourceBookController.test.ts,补 resourceBookLayout/resourceBookModel 用例与 appSurface 同节点断言
- 同步技术方案转场段与 pitfalls 经验条目
2026-09-09 20:24:10 +08:00
suzmii 05d4d6d1d3 删除 asset-canvas 测试并同步资源派生测试口径
- 删除 tests/assetCanvasSurface.test.tsx(4310 行,63 个用例全部属于草稿画布 Surface)
- projectResourceLiveIntegration.test.tsx 删除 AssetCanvasSurface mock、discover/recover 两个命令 stub、normalize_local_project_raster_resource stub、TaskImageWorkbench 与 4 个草稿精修用例;保留文档资源走资源编辑的用例
- project-development.suite.ts 把 showRunUnavailableHint 源码护栏正则去掉 !assetCanvasRoute,并把"in-place refine 预览失效"用例改写为仍有效的 preview version 装配与项目切换清理
- resourceEditModel.test.ts 把 PNG/WebP 两例从 image-canvas 改为 derive + image-reference,并移除 normalizeSource 断言
2026-09-09 20:05:07 +08:00
suzmii 3ea94b27d4 删除 asset-canvas Rust 草稿存储与生成链路
- 删除 project/asset_canvas.rs(4784 行,草稿 sidecar、媒体 staging、正式提交事务与 journal)
- 删除 project/asset_canvas/generation.rs(9514 行,草稿私有生成账本、参考图上传、服务身份确认、候选落盘与内联 63 个测试)
- 删除 project/asset_canvas_tests.rs(2356 行)
- project.rs 移除 mod asset_canvas 与 pub(crate) use asset_canvas::*
- commands.rs 移除 851-1088 共 18 个 Tauri 命令(含 commit_local_project_asset、commit_local_project_asset_canvas_candidate)
- main.rs 从 generate_handler! 移除对应 18 行注册
- 保留 canvas.asset_generate 命令契约(packages/shared 与 shared-contracts 的正式权限合同,Agent Runtime 仍在使用)
- 保留 server-rs 的 asset-canvas-references 上传票据白名单契约(后续项,本次不动服务端)
2026-09-09 20:04:43 +08:00
suzmii 9fd5b99972 删除 asset-canvas 前端精修草稿入口并重接资源派生
- 删除 apps/ai-game-creator-shell/src/features/asset-canvas 全部四个文件(AssetCanvasSurface.tsx、assetCanvasNaming.ts、assetCanvasSurface.css、tauriImageCanvasHostAdapter.ts)
- index.tsx 删除 assetCanvasRoute 状态与 ref、beginAssetCanvas/openAssetCanvas/normalizeRasterSourceAndOpen、cancelAssetCanvas/handleAssetCanvasSaveAttempt/handleAssetCommitted 与 game-creator-local-asset-committed 事件监听、中央主视窗渲染分支与 resources.asset-canvas 视图状态
- index.tsx 把 assetCanvasNotice 改名为通用 resourceWorkbenchNotice,保留非草稿流程文案与资源定位按钮
- resourceEditModel.ts 取消 image-canvas 路由,PNG/JPEG/WebP 统一走 derive + image-reference(复用 resource_editor.rs 的 /editor/images/edits)
- styles.css 删除 resources.asset-canvas 两条画布选择器,保留 resources.ui-editor 规则
- check-config.mjs 把 normalize_local_project_raster_resource 加入 native-only 白名单(临时项:C3 快速编辑重建后会重新获得前端调用方,届时删除该 allowlist 条目)
- index.tsx 移除 useMemo 依赖数组里模块级常量 visibleCategoryOrder(基线遗留的 react-hooks/exhaustive-deps warning,会阻断 pre-commit,属通过门禁所需的最小修正)
2026-09-09 20:04:24 +08:00
suzmii 1abbbadb07 合并最新 master 到 AGC V3 资源画布分支
- 合入 origin/master 67 个提交(含 #285 无限画布图标模糊修复、#307 客户端常用设置读写卡顿修复等)
- index.tsx 冲突按 V3 画本结构解决:保留 resourceBook 画布场景,未采用 master 的 CanvasWorld 包装
- 原因:game-resource-page-world 现仅承载依赖连线层,CanvasWorld 使用固定 12000 世界尺寸,会破坏按内容自适应的边界
- 保留 AtSign 图标导入(V3 引用输入区使用)
- 合并后 npm run ai-game-creator-shell:typecheck 通过
2026-09-09 18:12:09 +08:00
suzmii b460fea02c 移除资源画布加载转圈提示
转圈的判定与状态联动引入了多处副作用,按要求整体移除
删除 ResourceBookThumbnail 的 loading 属性与框内转圈、resourceBookIsLoading 判定函数、对应样式和用例
资源画布恢复为不显示加载态
2026-09-09 17:29:47 +08:00
suzmii 91f335468a 修复资源画本动画期间布局变化导致的跳变
上一版在布局变化时重启整段动画,导致此前修好的遮挡顺序与加载态全部复现,已回滚该做法
控制器新增 retarget:临时解除场景隐藏后重新测量真实卡片,只把运行中动画的结束帧改成映射到新几何;克隆几何、动画进度与叠加层都不重建
布局变化 effect 在动画期间调用 retarget,空闲时才 settle
补充 resourceBookController 用例:重定向不新建动画,结束帧映射到新目标
2026-09-09 17:24:00 +08:00
suzmii ed50ad122f 修复资源画本动画结束瞬间的位置跳变
动画期间卡片尺寸/位置一变(子画布卡片尺寸要等预览图尺寸到位才算准,通常正好落在动画末尾),原先直接 settle 掉动画,真实场景瞬间接管,表现为结束瞬间跳一下
改为从当前叠加层几何重定向:begin 捕获当前飞行位置,再平滑续到新的目标位置,动画不再被掐断
补充 effect 依赖 resourceBookState.phase
2026-09-09 17:16:38 +08:00
suzmii cca4881e13 修复部分功能画布不显示加载态
原先只在「该栏目有可预览素材且一张都没落地」时转圈;版本、音频这类不请求预览的栏目既不转圈,卡片又要等依赖图就绪才渲染,表现为美术资源转完后项目版本才一起出现
判定改为:有资源且(卡片尚未渲染 或 需要预览但一张都没落地)就转圈,每个栏目各自独立结束
补充 resourceBookModel 用例覆盖卡片未渲染与无需预览两种情况
2026-09-09 17:11:14 +08:00
suzmii 0914703032 修复资源画本飞行期间遮挡沿用错误一侧顺序
卡片真正重叠的是堆叠那一侧:进入时重叠在总览摞(来源),返回时重叠在摞(目标)。原先统一按目标顺序排,进入时就用错了顺序,堆叠里下面两张飞到上面,结束才回到下方
play 增加 stacking 参数:进入用来源顺序,返回用目标顺序;不在该侧的卡片压到最底层
补充 resourceBookController 用例覆盖来源顺序
2026-09-09 16:58:44 +08:00
suzmii a655ee1b1e 修复资源画本转场克隆遮挡顺序的第二次跳变
两个场景的 z-index 数值本身不同(总览 1-3、子画布 25),只搬数值无法保证顺序一致;且只存在于来源里的淡出克隆被排在最后会盖住存活卡片,消失时表现为跳变
改为按目标场景的最终绘制顺序(z-index 升序,同值再按 DOM 顺序)给克隆分配单调层级,淡出项统一压到最底层
快照记录 DOM 序号,补充 resourceBookController 用例断言层级单调与标题栏在卡片之上
2026-09-09 16:49:53 +08:00
suzmii 9a3313fe56 修复资源画本转场克隆的遮挡顺序跳变
层级相同的卡片由 DOM 顺序决定遮挡,而转场层原先按来源 key 在前追加克隆,和动画结束后的目标场景顺序不一致,结束瞬间会跳变
改为先按目标场景 DOM 顺序排列,再补只存在于来源里的 key
补充 resourceBookController 用例覆盖来源与目标顺序不同时的克隆顺序
2026-09-09 16:43:54 +08:00
suzmii 04ab7ee0da 资源画布加载态改为每栏目框内提示
撤销全屏遮罩,改为每个功能画布框内各自转圈:该栏目一张预览都没落地时显示,本栏目加载完立即消失
版本、占位与音频卡片本来不请求卡片预览,不计入等待,避免一直转圈
加载态 pointer-events 为 none,不拦截点击,未加载完也能直接进入栏目
判定函数改为按栏目,补充 resourceBookModel 用例
2026-09-09 16:41:03 +08:00
suzmii bafae0f127 资源画布首屏加载增加转圈提示
进入资源画布到首屏预览落地之间画布是空的,新增居中加载态:Loader2 转圈加文案,role=status 可被读屏播报
加载判定抽到 resourceBookIsLoading:有资源且布局未就绪或首屏预览一张都没落地时显示;预览失败同样视为落地,避免一直转圈
补充 resourceBookModel 加载态用例
2026-09-09 16:31:02 +08:00
suzmii 985db358a7 修复资源画本转场克隆的层级与静态不一致
转场层里的克隆只按自身 DOM 顺序叠放,而静态场景中卡片是 25、标题栏是 30,导致动画期间卡片盖住标题栏、结束后又瞬间跳回下方
快照记录元素自身或最近定位祖先的 z-index,克隆按该值设置层级,保持静态层叠关系
补充 resourceBookController 层级回归用例
2026-09-09 16:22:07 +08:00
suzmii 2c5ad3903e 总览摞按当前视图就近选卡
总览原先按资源数组顺序取前 3 张,和进入子画布后视口内最近的卡片不是同一批,FLIP 找不到对应终点就退化成淡入淡出,返回总览时还会跳成另一张图
新增 selectResourceBookOverviewCards:按卡片中心到该栏目当前视口中心的距离选最近的卡片,仍受筛选可见性约束
每栏目视口中心换算到画布坐标后传入资源画本场景
补充 resourceBookLayout 用例覆盖无视图中心、就近排序与筛选过滤
2026-09-09 16:16:16 +08:00
suzmii 85f0f6a57b 修复资源画本入场动画找不到终点卡片
进入子画布时若视口停在空白处,目标卡片不在画面内,FLIP 快照找不到终点会整段退化成淡入淡出,只剩标题栏有动画
新增 ensureResourceBookContentVisible:视口内没有可见卡片时,把离画面中心最近的卡片平移到画面中心,保持当前缩放不变
openResourceBookChild 计算入场视口后应用该修正,保证动画终点落在真实卡片上
补充 resourceBookViewport 用例覆盖已可见、就近居中与空栏目三种情况
2026-09-09 16:09:39 +08:00
suzmii 79cf932cba 修复资源画本转场快照丢失卡片预览图
快照克隆原先直接复制 blob: 对象 URL,源卡片卸载或预览缓存驱逐后该 URL 被 revoke,克隆里的图片随即失效,表现为进入/退出子画布时卡片框在动而图片不跟着展开和复原
对 blob: 来源且已解码的图片在克隆时栅格化为 data URL 冻结,与既有视频快照处理保持一致
补充 resourceBookController 快照图片冻结回归用例
2026-09-09 15:55:05 +08:00
suzmii 6d3d24df5e 合并 #283 资源画本画布实现
合入 feat/game-agent-canvas-resource-workbench-v2 的完整画布实现:资源画本控制器/模型/视口/布局、转场层、资源分页缓存与自动聚焦修复、资源总览卡片预览懒加载
保留本分支已有的聊天素材引用改动(引用输入区、@ 引用按钮、运行画面点选)
解决 index.tsx 状态声明与 pitfalls.md 文档冲突;styles.css 去重后仅保留一份画本视觉层
2026-09-09 15:43:32 +08:00
suzmii 8ed7e201d1 补齐资源画本迁移遗漏的输入区模型触发器样式
用 postcss 精确比对 #283 与当前分支的 CSS 规则集合,补齐唯一缺失的 .project-supervisor-surface.is-direct-codex .conversation-model-trigger
补齐后 #283 的 1057 条规则在当前分支全部存在,postcss 解析通过
2026-09-09 15:32:09 +08:00
suzmii 30580814d7 修复资源画本样式冲突遗留的缺失闭合括号
解决 #283 迁移与 master 的冲突时,.conversation-model-select [role='status'] 规则丢失了闭合大括号
导致 Tailwind 生成阶段报 Missing closing },Vite 错误覆盖层阻断页面打开
补齐闭合括号后 postcss 解析与 Vite 转换均恢复正常
2026-09-09 15:23:20 +08:00
suzmii 10e28b038c Merge remote-tracking branch 'origin/master' into feat/agc-canvas-resource-workbench-v3 2026-09-09 15:14:54 +08:00
suzmii aba896bf65 迁移资源画本视觉层样式
自 #283(V2.0 资源画本)迁入 .game-resource-book-* 视觉层共 523 行
该层作为 V3 资源画布的样式基础,JSX 待画布重写时接入
不包含 V2 主/子画布的行为代码
2026-09-09 15:04:18 +08:00
suzmii bf03a18676 修复资源依赖本地身份解析
资源依赖图同时索引远端资源身份和 local-asset 本地资源身份

本地 canonical 引用与远端候选去重后再建立依赖连线

补充本地身份解析与候选不歧义回归测试

更新资源自由画板技术方案的依赖身份合同
2026-09-09 15:03:54 +08:00
suzmii 57a5b642fc 建立AGC资源语义类型契约
共享契约新增 canonical 资源类型目录和历史类型读时映射

角色动画派生结果写入 character-animation 主类型

补充角色动画落盘与 legacy 类型映射回归测试

更新资源自由画板技术方案的类型边界说明
2026-09-09 15:03:54 +08:00
suzmii f7374b2101 fix: 调整 AGC 输入区滚动条显示阈值
- 输入编辑器默认高度提高到 96px,超过 140px 后滚动

- 加深并加宽滚动条拇指,避免溢出时不可见

- 同步调整消息列表底部空间
2026-09-09 13:33:13 +08:00
suzmii a0f3cc21a6 fix: 输入区滚动条改为按内容溢出显示
- 移除常驻滚动条和标准 scrollbar 属性

- 保留 WebKit 细滚动条样式,仅在内容超出时出现
2026-09-09 13:33:13 +08:00
suzmii c82a684004 fix: 收紧 AGC 输入区模型选择布局
- 模型选择改为按内容宽度靠右排列

- 取消状态点、名称和箭头之间的异常拉伸

- 拉开模型选择与发送按钮间距
2026-09-09 13:33:12 +08:00
suzmii 08e20dc7c5 fix: 恢复 AGC 输入区模型名称显示
- 模型选择设置稳定最小宽度,避免被右侧 flex 压缩

- 提高模型名称文字对比度

- 固定 @ 和发送按钮宽度,保持控制行布局稳定
2026-09-09 13:33:12 +08:00
suzmii e726dc41b8 fix: 约束 AGC 素材候选弹层到输入框范围
- 类型提示改为固定定位并按输入框宽度约束

- 根据上下可用空间选择展开方向

- 避免弹层越过聊天栏覆盖其它区域
2026-09-09 13:33:12 +08:00
suzmii 5f36ab8081 style: 对齐 AGC 输入区参考样式
- 输入区外框统一承载编辑器和底部控制行

- @ 放左侧,模型选择和发送放右侧

- 恢复输入编辑器滚动条样式

- 消息列表为底部输入框保留滚动条空间
2026-09-09 13:33:12 +08:00
suzmii cc569e7e1e refactor: 统一 AGC 输入区为单框结构
- form 作为完整背景框承载输入区和底部控制行

- 输入编辑器在框内透明显示

- 模型选择、@ 和发送按钮位于同一框内底部行
2026-09-09 13:32:41 +08:00
suzmii 664c8fbaf0 refactor: 统一 AGC 输入区底部控件结构
- 直接 Codex 输入区改为输入框下方单一控制行

- 模型选择、@ 和发送按钮使用同一 flex 布局

- 通用引用编辑器支持外部触发素材面板

- 移除依赖多个绝对定位拼接的对齐方式
2026-09-09 13:32:41 +08:00
suzmii a42452106c fix: 对齐 AGC 输入区 @ 与发送按钮
- 覆盖直接 Codex 输入区的全局按钮绝对定位

- @ 按钮改为 actions 容器内静态网格项,与发送按钮保持同一基线
2026-09-09 13:32:10 +08:00
suzmii b3989b0d5c fix: 修复 AGC 素材面板预览和按钮重叠
- 每个素材缩略图使用独立预览 scope,避免开发模式取消后无法恢复

- 调整模型选择、@ 按钮和发送按钮的底部布局

- 聊天引用候选改为使用完整已登记素材列表

- 补充图片缩略图和 StrictMode 回归测试
2026-09-09 13:32:10 +08:00
suzmii 46f27e2bde fix: 优化 AGC 素材候选弹层样式
- 固定候选弹层宽度并限制高度

- 候选名称和类型标签改为单行省略

- 增加候选弹层层级和滚动条样式
2026-09-09 13:32:10 +08:00
suzmii b7a659cdfe feat: AGC 聊天支持素材与运行画面结构化引用
- 新增通用素材引用编辑器与结构化引用协议

- 三个聊天入口接入 @ 候选和素材选择面板

- 资源卡新增 @引用 入口

- 运行画面新增区域点选与 runtime-region 引用

- Rust 按 manifest 二次校验并生成安全投影

- 补充前端与 Rust 定向测试
2026-09-09 13:32:10 +08:00
suzmii 36747b3ece 修复资源总览摞堆叠旋转与卡片预览懒加载
- 总览每个资源类型最多铺 3 张卡片,其余数量由栏目标题栏的“N 项”表达
- 总览摞旋转角改为固定小角度,素材变多后不再随堆叠下标无限增大
- 总览摞下标小的卡片压在上层,不再被模糊卡遮住最上面那张清晰卡
- 截断前先按搜索可见性过滤,避免搜索结果落在深处时总览显示空摞
- 资源卡片可见性观察改用真正包含卡片的容器,恢复滚动时的预览懒加载
- 补充资源画本布局单测、预览观察根回归用例和总览摞上限用例
2026-09-08 20:23:33 +08:00
suzmii 8a47cdfc1b Merge remote-tracking branch 'origin/master' into feat/game-agent-canvas-resource-workbench-v2
Project CI / Native shell tests (pull_request) Failing after 15m58s
Project CI / Frontend tests (pull_request) Successful in 3m48s
Project CI / Backend tests (pull_request) Successful in 8m0s
Project CI / Repository checks (pull_request) Successful in 11m18s
2026-09-08 19:48:37 +08:00
suzmii 5550a7b28c 修复资源画布文案重命名后的CI失败
删除project-development.suite.ts中未使用的resetButton变量,修复ESLint unused-vars错误

将projectResourceLiveIntegration.test.tsx中过期的复位资源画布断言更新为复位资源视图
2026-09-08 16:38:49 +08:00
suzmii 7bd67f2b3a Merge remote-tracking branch 'origin/master' into feat/game-agent-canvas-resource-workbench-v2 2026-09-08 14:58:58 +08:00
suzmii ea71540413 Merge branch 'feat/game-agent-canvas-resource-workbench-v2' of https://git.genarrative.world/git/GenarrativeAI/Genarrative into feat/game-agent-canvas-resource-workbench-v2 2026-09-08 14:58:29 +08:00
suzmii 4e0ce1902b 建立AGC资源语义类型契约
共享契约新增 canonical 资源类型目录和历史类型读时映射

角色动画派生结果写入 character-animation 主类型

补充角色动画落盘与 legacy 类型映射回归测试

更新资源自由画板技术方案的类型边界说明
2026-09-08 13:41:55 +08:00
suzmii e2a50088c9 修复资源依赖本地身份解析
资源依赖图同时索引远端资源身份和 local-asset 本地资源身份

本地 canonical 引用与远端候选去重后再建立依赖连线

补充本地身份解析与候选不歧义回归测试

更新资源自由画板技术方案的依赖身份合同
2026-09-08 13:29:08 +08:00
suzmii 6de536ecbd Merge branch 'master' into feat/game-agent-canvas-resource-workbench-v2
Project CI / Repository checks (pull_request) Failing after 1m23s
Project CI / Native shell tests (pull_request) Failing after 4m7s
Project CI / Backend tests (pull_request) Successful in 6m28s
Project CI / Frontend tests (pull_request) Failing after 2m7s
2026-09-08 12:24:11 +08:00
suzmii 1347b4a743 去除资源区滚轮切换栏目
Project CI / Frontend tests (pull_request) Failing after 1m49s
Project CI / Native shell tests (pull_request) Failing after 4m32s
Project CI / Repository checks (pull_request) Failing after 9s
Project CI / Backend tests (pull_request) Failing after 9s
删除普通滚轮切页状态、队列与节流控制逻辑

资源区普通滚轮只平移当前视图,缩放继续由 Ctrl 或 Cmd 配合滚轮触发

同步更新 PRD、技术方案与控制器测试
2026-09-08 12:12:29 +08:00
suzmii d5411eae62 去除资源区主子画布概念文案
资源总览、栏目入口、返回与复位文案移除主画布和子画布概念

资源栏目可访问名称改为设计文档、美术资源等名称

同步更新相关界面测试断言
2026-09-08 12:11:09 +08:00
suzmii 0a5080dbd9 Merge remote-tracking branch 'origin/master' into feat/game-agent-canvas-resource-workbench-v2
Project CI / Repository checks (pull_request) Successful in 2m27s
Project CI / Native shell tests (pull_request) Successful in 18m40s
Project CI / Frontend tests (pull_request) Successful in 2m55s
Project CI / Backend tests (pull_request) Successful in 6m32s
2026-09-08 10:45:07 +08:00
suzmii c5bbb864f5 修复资源分页类别缓存回归
资源分页复用完整资源签名以识别同数量类别变化

补充同长度类别变化回归测试
2026-09-07 16:26:08 +08:00
suzmii ccb3a3ab6a 修复资源保存后的自动聚焦
Project CI / Frontend tests (pull_request) Successful in 3m21s
Project CI / Repository checks (pull_request) Successful in 6m18s
Project CI / Backend tests (pull_request) Successful in 6m46s
Project CI / Native shell tests (pull_request) Successful in 17m19s
将保存后资源卡的 DOM 查找范围改为资源画本管理器\n确保新派生资源和恢复资源在布局就绪后能够进入详情聚焦
2026-09-05 13:04:54 +08:00
suzmii 879e732b04 完善资源分页依赖
Project CI / Frontend tests (pull_request) Failing after 2m32s
Project CI / Repository checks (pull_request) Successful in 2m54s
Project CI / Native shell tests (pull_request) Failing after 6m19s
Project CI / Backend tests (pull_request) Successful in 6m39s
按资源类别签名刷新分页类别\n避免资源数量不变时使用过期类别,同时保持稳定引用避免无关编辑面板重置
2026-09-05 12:20:47 +08:00
suzmii cac3e19632 修复资源分页类别缓存
Project CI / Frontend tests (pull_request) Failing after 2m47s
Project CI / Repository checks (pull_request) Successful in 3m4s
Project CI / Native shell tests (pull_request) Failing after 5m49s
Project CI / Backend tests (pull_request) Successful in 7m12s
资源数组变化时重新计算资源分页类别\n避免资源总数不变但类别更新后继续使用旧分页状态
2026-09-05 11:59:23 +08:00
suzmii cd17ae8123 修复资源主画布无限平移与滚轮交互
分离固定交互视口与可平移缩放的内容层
支持主画布滚轮平移并保留缩放及主子画布视口记忆
补充越界拖动和滚轮回归测试并同步技术文档
2026-09-05 11:41:16 +08:00
suzmii 41c9ee7db4 Merge branch 'master' into feat/game-agent-canvas-resource-workbench-v2
Project CI / Frontend tests (pull_request) Failing after 2m23s
Project CI / Repository checks (pull_request) Successful in 2m34s
Project CI / Native shell tests (pull_request) Failing after 4m58s
Project CI / Backend tests (pull_request) Successful in 6m51s
2026-09-05 11:23:18 +08:00
suzmii ba718e3d4b 重构资源画本独立转场与中断处理
Project CI / Repository checks (pull_request) Failing after 15s
Project CI / Backend tests (pull_request) Failing after 15s
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
使用独立FLIP快照层和Web Animations统一动画收尾
统一分类导航并保留滚轮节流和各画布视口
修复快照缩放与悬停导致的终点偏移
补充动画回归测试并更新资源画本技术文档
2026-09-05 11:21:17 +08:00
suzmii 65ffd4cc69 取消资源画本返回主画布的二次平移动画
新增 returning-main 阶段,区分返回主画布与进入子画布的 transform 过渡。

返回期间取消残留分页事件和进行中的子画布切换,避免返回流程被重新改写。

补充状态机与 appSurface 回归测试,并同步技术方案文档。
2026-09-05 09:25:52 +08:00
suzmii 5bcaca7233 重构资源画本画布与转场状态
统一主画布与子画布的可见渲染层和 viewport 状态管理。

将子画布改为固定视口加无限平移 world,并保证首次 fit 的可读比例。

补充转场、滚轮、viewport 和资源画本回归测试,更新技术方案文档。
2026-09-05 09:10:56 +08:00
suzmii 820fbfd988 修复子画布缩放并支持Ctrl滚轮与右下角缩放控件
美术资源初始适配不再放大过头

scene层支持Ctrl滚轮锚点缩放,普通滚轮保留翻页
2026-09-04 16:41:39 +08:00
suzmii 069248b296 子画布展开改用真实坐标与视口承载
资源卡拖拽使用模型坐标换算,消除阻力和回弹

子画布空白区域支持画布平移,保持进入退出动画连贯
2026-09-04 16:31:50 +08:00
suzmii 9c85a9501e 调整资源画本hover高亮仅用于未展开主画布
Project CI / Repository checks (pull_request) Failing after 2m58s
Project CI / Frontend tests (pull_request) Failing after 2m33s
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
子画布展开态不再显示hover高亮线

未展开缩略卡hover时标题栏三边高亮,底边接缝保持默认色
2026-09-04 15:46:42 +08:00
suzmii 816844065b 拆分子画布标题与本体hover高亮
Project CI / Repository checks (pull_request) Failing after 2m52s
Project CI / Frontend tests (pull_request) Failing after 2m17s
Project CI / Native shell tests (pull_request) Failing after 6m15s
Project CI / Backend tests (pull_request) Successful in 6m37s
标题栏与本体分别作为独立对象绑定同一hover源

标题栏底边与本体顶边接缝保持默认色不高亮
2026-09-04 15:42:16 +08:00
suzmii 8323b13fd3 Merge branch 'master' into feat/game-agent-canvas-resource-workbench-v2
Project CI / Frontend tests (pull_request) Failing after 2m29s
Project CI / Repository checks (pull_request) Failing after 2m49s
Project CI / Backend tests (pull_request) Successful in 6m39s
Project CI / Native shell tests (pull_request) Failing after 5m51s
2026-09-04 15:22:34 +08:00
suzmii f883d4bf2e 修复从项目入口首次进入主画布的错位
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Failing after 10s
移除主画布入场缩放动画避免测量和终态不一致

无效缩略卡测量结果不再直接展示共享场景
2026-09-04 15:13:19 +08:00
suzmii 019b1761f4 修复资源画本首次布局错位
Project CI / Repository checks (pull_request) Failing after 9s
Project CI / Backend tests (pull_request) Failing after 11s
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
首帧测量未完成时隐藏共享场景,避免标题和卡片使用默认坐标

在首帧和资源数量变化后重新校准缩略卡布局
2026-09-04 15:03:28 +08:00
suzmii f339b383f5 实现资源画本统一节点转场
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
重构主画布与子画布为共享资源卡和标题栏节点

补充缩略堆叠、进入返回动画、遮罩与定位适配

同步更新资源画本 UI 技术方案文档
2026-09-04 14:06:24 +08:00
202 changed files with 40508 additions and 33846 deletions
@@ -371,7 +371,15 @@ async function runMissingConfigWizard(setActiveChild, explicitConfigDir) {
}
}
async function secureWindowsPrivateRuntimePath(targetPath, options) {
async function secureWindowsPrivateRuntimePath(
targetPath,
options,
secureWindowsPath = null,
) {
if (secureWindowsPath) {
await secureWindowsPath(targetPath, options);
return;
}
const { secureWindowsGameCreatorPathForCurrentUser } = await import(
'./game-creator-config-wizard.mjs'
);
@@ -383,6 +391,7 @@ async function copyPrivateRuntimeConfigEntry(
runtimeConfigDir,
fileName,
required,
secureWindowsPath = null,
) {
const sourcePath = path.join(sourceConfigDir, fileName);
const sourceMetadata = await lstat(sourcePath).catch((error) => {
@@ -402,9 +411,11 @@ async function copyPrivateRuntimeConfigEntry(
const sourceBytes = await readFile(sourcePath);
const destinationFile = await open(destinationPath, 'wx', 0o600);
try {
await secureWindowsPrivateRuntimePath(destinationPath, {
isDirectory: false,
});
await secureWindowsPrivateRuntimePath(
destinationPath,
{ isDirectory: false },
secureWindowsPath,
);
await destinationFile.writeFile(sourceBytes);
await destinationFile.sync();
} finally {
@@ -437,7 +448,11 @@ async function copyPrivateRuntimeConfigEntry(
return true;
}
export async function prepareSwarmTestRuntimeConfig(sourceConfigDir, tempRoot) {
export async function prepareSwarmTestRuntimeConfig(
sourceConfigDir,
tempRoot,
{ secureWindowsPath = null } = {},
) {
if (!path.isAbsolute(sourceConfigDir)) {
throw new Error('配置来源目录必须是绝对路径');
}
@@ -457,9 +472,11 @@ export async function prepareSwarmTestRuntimeConfig(sourceConfigDir, tempRoot) {
);
try {
if (process.platform === 'win32') {
await secureWindowsPrivateRuntimePath(runtimeConfigDir, {
isDirectory: true,
});
await secureWindowsPrivateRuntimePath(
runtimeConfigDir,
{ isDirectory: true },
secureWindowsPath,
);
} else {
await chmod(runtimeConfigDir, 0o700);
}
@@ -477,12 +494,14 @@ export async function prepareSwarmTestRuntimeConfig(sourceConfigDir, tempRoot) {
runtimeConfigDir,
configFileName,
true,
secureWindowsPath,
);
await copyPrivateRuntimeConfigEntry(
canonicalSourceConfigDir,
runtimeConfigDir,
localConfigFileName,
false,
secureWindowsPath,
);
return {
path: await realpath(runtimeConfigDir),
@@ -1,7 +1,7 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "main",
"description": "AI 游戏创作主窗口允许读取系统剪贴板图片,用于粘贴素材附件。",
"description": "AI 游戏创作主窗口允许读取系统剪贴板图片,用于粘贴素材附件;允许弹出原生打开/保存对话框用于素材上传与导出。",
"windows": ["client"],
"permissions": [
"clipboard-manager:allow-read-image",
@@ -21,6 +21,7 @@
]
},
"opener:default",
"dialog:allow-open"
"dialog:allow-open",
"dialog:allow-save"
]
}
@@ -0,0 +1 @@
你是游戏创作需求的润色助手。把用户给创作 Agent 的一段需求改写成更清晰、可执行的中文需求。要求:保留用户的原始意图、玩法、美术方向、数值与限制条件,不得新增或删除需求点,不得替用户做决定,不得写成方案书或任务清单。只输出润色后的需求正文,不要解释、不要引言、不要 Markdown 标记、不要引号包裹、不要重复用户原文;无法润色时原样输出用户输入。
@@ -14,6 +14,7 @@ mod codex_cli;
mod codex_provider_proxy;
mod direct_codex_attachments;
mod direct_codex_audit;
mod direct_codex_references;
mod direct_project_history;
mod direct_project_turn_history;
mod direct_runtime;
@@ -40,6 +41,7 @@ pub(crate) use codex_cli::{
pub(crate) use codex_provider_proxy::*;
pub(crate) use direct_codex_attachments::*;
pub(crate) use direct_codex_audit::*;
pub(crate) use direct_codex_references::*;
pub(crate) use direct_project_history::*;
pub(crate) use direct_project_turn_history::*;
pub(crate) use direct_runtime::*;
@@ -16,7 +16,21 @@ const GAME_CREATOR_CODEX_APP_SERVER_REMOTE_CONTROL_DISABLED_ENV: &str =
"CODEX_INTERNAL_APP_SERVER_REMOTE_CONTROL_DISABLED";
const GAME_CREATOR_CODEX_AUTH_BRIDGE_API_BASE_URL: &str = "https://api.openai.com/v1";
const GAME_CREATOR_CODEX_APP_SERVER_PROTOCOL: &str = "genarrative-codex-app-server.v3";
const GAME_CREATOR_CODEX_APP_SERVER_LINE_MAX_BYTES: usize = 4 * 1024 * 1024;
/// 单条 JSON-RPC 消息(NDJSON 一行)的字节上限:**同时**是 stdout 读侧上限与 stdin 写侧守卫。
///
/// 两侧必须共用这一个常量。2026-09-11 的隔离探针实测(codex-cli 0.147.0AGC 捆绑版本):
/// `experimentalRawEvents: true` 的线程收到 `thread/inject_items` 后,会**逐条原样回显**
/// `rawResponseItem/completed` —— 注入一个 5 MiB 的 itemstdout 就回一条 5 243 245 字节的单行。
/// 因此「我们注入得进去」就必须「我们读得回来」;两侧取不同值(曾为读侧 4 MiB、图片单张 5 MiB/
/// 单次 16 MiB)会让一次**原本合法**的注入在随后的读行里被误判成连接级故障。
///
/// 32 MiB 的依据:单张图 base64 data URL 上限 10 MiB`..._IMAGE_MAX_BYTES * 2`)、单次请求图片
/// 总量上限 16 MiB`..._IMAGE_TOTAL_MAX_BYTES`base64 后约 21.3 MiB+ JSON 信封,取 32 MiB
/// 覆盖现有写侧全部单一消息;再大就失去内存/DoS 边界的意义(每连接单行最多驻留该数量级)。
const GAME_CREATOR_CODEX_APP_SERVER_LINE_MAX_BYTES: usize = 32 * 1024 * 1024;
/// 回显信封余量:item 会被包进 `{"method":"rawResponseItem/completed","params":{…,"item":…}}`
/// 之后再输出,所以**单条 item** 允许占用的字节数必须小于整行上限。
const GAME_CREATOR_CODEX_APP_SERVER_ECHO_ENVELOPE_MARGIN_BYTES: usize = 8 * 1024;
const GAME_CREATOR_CODEX_APP_SERVER_STDERR_RECORD_MAX_BYTES: usize = 256 * 1024;
const GAME_CREATOR_CODEX_APP_SERVER_AUTH_MAX_BYTES: usize = 1024 * 1024;
const GAME_CREATOR_CODEX_APP_SERVER_BACKLOG_TURN_MAX: usize = 128;
@@ -44,6 +58,13 @@ pub(in crate::agent) const GAME_CREATOR_CODEX_APP_SERVER_TERMINAL_UNKNOWN_PREFIX
"codex-app-server-terminal-unknown:";
pub(in crate::agent) const GAME_CREATOR_CODEX_APP_SERVER_ERROR_KIND_PREFIX: &str =
"codex-app-server-error:";
/// DirectProject 历史注入超限:专属前缀,供 `direct_runtime.rs` 判「可重试性」与给专属恢复提示。
///
/// 命中它意味着**同一份历史每次都会得到同一结论**(历史文件不变则载荷不变),所以按不可重试
/// 处理,与 `DIRECT_PROJECT_HISTORY_SHAPE_FAILURE_MARKERS` 同口径(见技术方案
/// `docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md` 末节)。
pub(in crate::agent) const DIRECT_PROJECT_HISTORY_INJECTION_OVERSIZE_PREFIX: &str =
"DirectProject 历史注入载荷超过单行上限";
const CODEX_APP_SERVER_BASE_INSTRUCTIONS_FALLBACK: &str =
"You are Codex working directly in the user's Genarrative game project. Follow the AGC system instructions, inspect and modify files in the current workspace when needed, and report concrete progress and failures. Do not invent completion evidence.";
const DIRECT_CODEX_BASE_INSTRUCTIONS_FALLBACK: &str =
@@ -2296,6 +2317,11 @@ impl CodexAppServerConnection {
let mut bytes = serde_json::to_vec(value)
.map_err(|error| format!("序列化 Codex app-server JSON-RPC 失败:{error}"))?;
bytes.push(b'\n');
// 写侧守卫:绝不写出自己读不回来的行(两侧共用 `..._LINE_MAX_BYTES`)。
// 失败关闭并给出字节数,**不做任何截断**。
if let Some(error) = game_creator_codex_app_server_message_oversize_error(bytes.len()) {
return Err(error);
}
let mut writer = self.inner.writer.lock().await;
writer
.write_all(&bytes)
@@ -2616,13 +2642,18 @@ impl CodexAppServerConnection {
return Err(platform_llm::LlmError::InvalidRequest(error));
}
};
if let Err(error) = self
.request(
"thread/inject_items",
serde_json::json!({"threadId": thread_id, "items": items}),
)
.await
let params = serde_json::json!({"threadId": thread_id.clone(), "items": items});
let payload_bytes = serde_json::to_vec(&params)
.map(|bytes| bytes.len().saturating_add(1))
.unwrap_or(usize::MAX);
// 注入前的前置校验:失败关闭并指名 itemId 与字节数,**不截断、不摘要、不改写**。
if let Some(error) =
direct_project_history_injection_oversize_error(&params, payload_bytes)
{
self.release_thread(snapshot, &thread_id).await;
return Err(platform_llm::LlmError::InvalidRequest(error));
}
if let Err(error) = self.request("thread/inject_items", params).await {
self.release_thread(snapshot, &thread_id).await;
return Err(platform_llm::LlmError::Transport(error));
}
@@ -3429,6 +3460,61 @@ async fn read_game_creator_codex_app_server_stdout(
}
}
/// 写侧守卫的纯函数部分(便于单测):超限时给出带字节数的错误。
///
/// 与读侧共用同一个上限,保证「我们能注入的」一定「我们读得回来」。
fn game_creator_codex_app_server_message_oversize_error(byte_len: usize) -> Option<String> {
let limit = GAME_CREATOR_CODEX_APP_SERVER_LINE_MAX_BYTES;
(byte_len > limit).then(|| {
format!(
"Codex app-server JSON-RPC 单行超过大小上限(写侧):{byte_len} 字节 > 上限 {limit} 字节;未发送、未截断"
)
})
}
/// DirectProject 历史注入的前置校验(纯函数,便于单测)。
///
/// 判据来自实测:app-server 会把注入的 item **逐条原样回显**,所以
/// ① 单条 item 必须 ≤ 上限 − 回显信封余量;② 整份载荷本身也必须 ≤ 上限。
/// 任一不满足即**失败关闭**(指名 `itemId` 与字节数),**不截断、不摘要、不改写**历史。
fn direct_project_history_injection_oversize_error(
params: &serde_json::Value,
payload_bytes: usize,
) -> Option<String> {
let limit = GAME_CREATOR_CODEX_APP_SERVER_LINE_MAX_BYTES;
if payload_bytes > limit {
return Some(format!(
"{DIRECT_PROJECT_HISTORY_INJECTION_OVERSIZE_PREFIX}:整份历史载荷 {payload_bytes} 字节 > 上限 {limit} 字节;未注入、未截断"
));
}
let Some(items) = params.get("items").and_then(serde_json::Value::as_array) else {
return None;
};
let item_limit = limit.saturating_sub(GAME_CREATOR_CODEX_APP_SERVER_ECHO_ENVELOPE_MARGIN_BYTES);
for item in items {
let Ok(bytes) = serde_json::to_vec(item) else {
return Some(format!(
"{DIRECT_PROJECT_HISTORY_INJECTION_OVERSIZE_PREFIX}:历史记录无法序列化;未注入、未截断"
));
};
if bytes.len() > item_limit {
let item_id = item
.get("id")
.and_then(serde_json::Value::as_str)
.unwrap_or("<无 id>");
let item_type = item
.get("type")
.and_then(serde_json::Value::as_str)
.unwrap_or("<无 type>");
return Some(format!(
"{DIRECT_PROJECT_HISTORY_INJECTION_OVERSIZE_PREFIX}:单条记录 {} 字节 > 单条上限 {item_limit} 字节(itemId={item_id}, type={item_type});未注入、未截断",
bytes.len()
));
}
}
None
}
async fn read_bounded_game_creator_codex_app_server_line<R>(
reader: &mut R,
) -> Result<Option<Vec<u8>>, String>
@@ -5437,6 +5523,66 @@ while IFS= read -r line; do :; done
assert!(!diagnostic.contains(std::str::from_utf8(&secret).expect("fixture utf8")));
}
/// 写侧对称守卫:我们绝不写出自己读不回来的行。
///
/// 与上面那条读侧用例共用 `..._LINE_MAX_BYTES`,所以这条断言同时锁住「两侧同界」这个不变量:
/// 上限本身必须放行,上限 + 1 必须失败关闭(而不是被静默截断)。
#[test]
fn codex_app_server_write_guard_matches_the_read_cap() {
assert!(game_creator_codex_app_server_message_oversize_error(
GAME_CREATOR_CODEX_APP_SERVER_LINE_MAX_BYTES
)
.is_none());
let oversized = GAME_CREATOR_CODEX_APP_SERVER_LINE_MAX_BYTES + 1;
let error = game_creator_codex_app_server_message_oversize_error(oversized)
.expect("oversized outbound message must be rejected before it is written");
assert!(error.contains("单行超过大小上限(写侧)"));
assert!(error.contains(&oversized.to_string()));
assert!(error.contains("未发送、未截断"));
}
/// DirectProject 历史注入的前置校验:**单条 item** 与**整份载荷**都必须失败关闭。
///
/// 形状照抄实测(隔离探针,codex-cli 0.147.0):`thread/inject_items` 注入的 item 会被
/// app-server 逐条原样回显成一条 `rawResponseItem/completed`,所以「单条 item 超限」就等于
/// 「回显行超限」——必须在注入前拦下,而不是让它在读行时变成连接级故障。
#[test]
fn direct_project_history_injection_rejects_oversized_items_and_payloads() {
let ok_params = serde_json::json!({
"threadId": "probe-thread",
"items": [{ "type": "message", "id": "msg_1", "role": "user" }],
});
assert!(direct_project_history_injection_oversize_error(&ok_params, 1024).is_none());
let payload_error = direct_project_history_injection_oversize_error(
&ok_params,
GAME_CREATOR_CODEX_APP_SERVER_LINE_MAX_BYTES + 1,
)
.expect("oversized payload must be rejected");
assert!(payload_error.contains(DIRECT_PROJECT_HISTORY_INJECTION_OVERSIZE_PREFIX));
assert!(payload_error.contains("整份历史载荷"));
assert!(payload_error.contains("未注入、未截断"));
let huge = "x".repeat(GAME_CREATOR_CODEX_APP_SERVER_LINE_MAX_BYTES);
let big_params = serde_json::json!({
"threadId": "probe-thread",
"items": [{
"type": "message",
"id": "msg_big",
"role": "assistant",
"content": [{ "type": "output_text", "text": huge }],
}],
});
let item_error = direct_project_history_injection_oversize_error(&big_params, 1024)
.expect("oversized single item must be rejected");
assert!(item_error.contains(DIRECT_PROJECT_HISTORY_INJECTION_OVERSIZE_PREFIX));
assert!(item_error.contains("单条记录"));
assert!(item_error.contains("itemId=msg_big"));
assert!(item_error.contains("type=message"));
assert!(item_error.contains("未注入、未截断"));
}
#[cfg(unix)]
#[tokio::test]
async fn codex_app_server_uses_initialize_thread_and_turn_protocol() {
@@ -0,0 +1,328 @@
use super::*;
pub(crate) const MAX_DIRECT_CODEX_REFERENCES: usize = 32;
const MAX_DIRECT_CODEX_REFERENCE_ID_CHARS: usize = 200;
const MAX_DIRECT_CODEX_REFERENCE_LABEL_CHARS: usize = 160;
const MAX_DIRECT_CODEX_REFERENCE_SOURCE_CHARS: usize = 32;
const MAX_DIRECT_CODEX_REFERENCE_ELEMENT_CHARS: usize = 80;
const MAX_DIRECT_CODEX_REFERENCE_TEXT_CHARS: usize = 240;
const DIRECT_CODEX_REFERENCE_HEADER: &str =
"[本轮用户引用素材:以下均为当前项目已确认的安全引用。请使用稳定资源 ID 和项目相对路径读取,不要读取或输出其它路径。]";
#[derive(Clone, Debug, serde::Deserialize)]
#[serde(rename_all = "camelCase", tag = "type")]
pub(crate) enum DirectCodexTurnReference {
#[serde(rename = "resource")]
Resource(DirectCodexResourceReference),
#[serde(rename = "runtime-region")]
RuntimeRegion(DirectCodexRuntimeRegionReference),
}
#[derive(Clone, Debug, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct DirectCodexResourceReference {
pub(crate) resource_id: String,
#[serde(default)]
pub(crate) label: Option<String>,
#[serde(default)]
pub(crate) source: Option<String>,
}
#[derive(Clone, Debug, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct DirectCodexRuntimeRegionReference {
#[serde(default)]
pub(crate) label: Option<String>,
#[serde(default)]
pub(crate) run_id: Option<String>,
#[serde(default)]
pub(crate) version_id: Option<String>,
#[serde(default)]
pub(crate) element_tag: Option<String>,
#[serde(default)]
pub(crate) element_role: Option<String>,
#[serde(default)]
pub(crate) text: Option<String>,
#[serde(default)]
pub(crate) width: Option<f64>,
#[serde(default)]
pub(crate) height: Option<f64>,
#[serde(default)]
pub(crate) resource_ids: Vec<String>,
}
fn sanitize_reference_text(value: &str, max_chars: usize) -> Option<String> {
let sanitized = value
.trim()
.chars()
.filter(|character| !character.is_control())
.take(max_chars)
.collect::<String>();
if sanitized.is_empty() {
None
} else {
Some(sanitized)
}
}
fn sanitize_reference_label(value: Option<&str>) -> Option<String> {
value.and_then(|value| sanitize_reference_text(value, MAX_DIRECT_CODEX_REFERENCE_LABEL_CHARS))
}
fn sanitize_reference_source(value: Option<&str>) -> Option<String> {
let value = sanitize_reference_text(value?, MAX_DIRECT_CODEX_REFERENCE_SOURCE_CHARS)?;
if value
.chars()
.all(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.'))
{
Some(value)
} else {
None
}
}
fn sanitize_reference_element(value: Option<&str>) -> Option<String> {
let value = sanitize_reference_text(value?, MAX_DIRECT_CODEX_REFERENCE_ELEMENT_CHARS)?;
if value
.chars()
.all(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.'))
{
Some(value)
} else {
None
}
}
fn sanitize_reference_dimension(value: Option<f64>) -> Option<u32> {
value
.filter(|value| value.is_finite() && *value > 0.0)
.map(|value| value.round().clamp(1.0, 100_000.0) as u32)
}
fn asset_display_label(asset: &GameCreationAppAssetManifestEntry) -> String {
let basename = asset
.local_path
.rsplit(['/', '\\'])
.next()
.unwrap_or(&asset.id);
let without_extension = basename
.rsplit_once('.')
.map(|(name, _)| name)
.unwrap_or(basename)
.trim();
if without_extension.is_empty() {
asset.id.clone()
} else {
without_extension
.chars()
.filter(|character| !character.is_control())
.take(MAX_DIRECT_CODEX_REFERENCE_LABEL_CHARS)
.collect()
}
}
fn validate_resource_reference_id(value: &str) -> Result<String, String> {
let resource_id = value.trim();
if resource_id.is_empty()
|| resource_id.chars().count() > MAX_DIRECT_CODEX_REFERENCE_ID_CHARS
|| resource_id.chars().any(char::is_control)
{
return Err("引用的素材 ID 无效,请移除后重新选择".to_string());
}
Ok(resource_id.to_string())
}
fn render_resource_reference_line(
manifest: &GameCreationAppManifest,
reference: &DirectCodexResourceReference,
) -> Result<String, String> {
let resource_id = validate_resource_reference_id(&reference.resource_id)?;
let asset = manifest
.assets
.iter()
.find(|asset| asset.id == resource_id)
.ok_or_else(|| "引用的素材已不存在,请移除后重新选择".to_string())?;
let local_path = sanitize_attachment_local_path(&asset.local_path)
.ok_or_else(|| "引用的素材路径无效,请移除后重新选择".to_string())?;
let label = sanitize_reference_label(reference.label.as_deref())
.unwrap_or_else(|| asset_display_label(asset));
let source = sanitize_reference_source(reference.source.as_deref())
.unwrap_or_else(|| "unknown".to_string());
Ok(format!(
"- 素材 ID{resource_id};名称:{label};类型:{};媒体类型:{};项目路径:{local_path};来源:{source}",
asset.kind, asset.media_type
))
}
fn render_runtime_region_reference_line(
manifest: &GameCreationAppManifest,
reference: &DirectCodexRuntimeRegionReference,
) -> Result<String, String> {
let label = sanitize_reference_label(reference.label.as_deref())
.unwrap_or_else(|| "运行画面区域".to_string());
let run_id = sanitize_reference_source(reference.run_id.as_deref());
let version_id = sanitize_reference_source(reference.version_id.as_deref());
let element_tag = sanitize_reference_element(reference.element_tag.as_deref());
let element_role = sanitize_reference_element(reference.element_role.as_deref());
let text = reference
.text
.as_deref()
.and_then(|value| sanitize_reference_text(value, MAX_DIRECT_CODEX_REFERENCE_TEXT_CHARS));
let width = sanitize_reference_dimension(reference.width);
let height = sanitize_reference_dimension(reference.height);
let mut related_resource_ids = Vec::new();
for resource_id in &reference.resource_ids {
let resource_id = validate_resource_reference_id(resource_id)?;
if !manifest.assets.iter().any(|asset| asset.id == resource_id) {
return Err("运行画面引用的素材已变化,请重新点选".to_string());
}
related_resource_ids.push(resource_id);
}
let mut parts = vec![format!("名称:{label}")];
if let Some(run_id) = run_id {
parts.push(format!("运行标识:{run_id}"));
}
if let Some(version_id) = version_id {
parts.push(format!("版本标识:{version_id}"));
}
if let Some(element_tag) = element_tag {
parts.push(format!("元素:{element_tag}"));
}
if let Some(element_role) = element_role {
parts.push(format!("角色:{element_role}"));
}
if let Some(text) = text {
parts.push(format!("文本摘要:{text}"));
}
if let (Some(width), Some(height)) = (width, height) {
parts.push(format!("尺寸:{width}x{height}"));
}
if !related_resource_ids.is_empty() {
parts.push(format!("关联素材 ID{}", related_resource_ids.join(",")));
}
Ok(format!("- 运行画面区域:{}", parts.join("")))
}
pub(crate) fn render_direct_codex_references_section(
root: &Path,
references: &[DirectCodexTurnReference],
) -> Result<Option<String>, String> {
if references.is_empty() {
return Ok(None);
}
if references.len() > MAX_DIRECT_CODEX_REFERENCES {
return Err(format!("一次最多引用 {MAX_DIRECT_CODEX_REFERENCES} 个素材"));
}
let manifest = read_manifest_for_project(root)?;
let mut lines = Vec::with_capacity(references.len());
for reference in references {
lines.push(match reference {
DirectCodexTurnReference::Resource(reference) => {
render_resource_reference_line(&manifest, reference)?
}
DirectCodexTurnReference::RuntimeRegion(reference) => {
render_runtime_region_reference_line(&manifest, reference)?
}
});
}
Ok(Some(
std::iter::once(DIRECT_CODEX_REFERENCE_HEADER.to_string())
.chain(lines)
.collect::<Vec<_>>()
.join("\n"),
))
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture_project() -> tempfile::TempDir {
let directory = tempfile::tempdir().expect("temp project");
let root = directory.path();
std::fs::create_dir_all(root.join(".agent")).expect("create agent dir");
let mut manifest = new_game_creation_app_manifest("project-1", "测试项目");
manifest.assets.push(GameCreationAppAssetManifestEntry {
id: "asset-hero".to_string(),
kind: "character".to_string(),
media_type: "image/png".to_string(),
local_path: "assets/hero.png".to_string(),
source: GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Uploaded,
canvas_project_id: None,
resource_id: None,
asset_object_id: None,
task_id: None,
prompt: None,
model: None,
generation_route: None,
generation_kind: None,
reference_resource_ids: Vec::new(),
},
image_sequence_frames: None,
image_sequence_duration_ms: None,
category: game_creation_app_asset_category_for_kind("character"),
tags: Vec::new(),
});
write_manifest(&root.join(".agent/manifest.json"), &manifest).expect("write manifest");
directory
}
#[test]
fn resource_reference_uses_manifest_identity_and_never_accepts_client_paths() {
let project = fixture_project();
let reference: DirectCodexTurnReference = serde_json::from_str(
r#"{"type":"resource","resourceId":"asset-hero","label":"主角","source":"asset-picker","localPath":"C:\\secret.png"}"#,
)
.expect("reference json");
let section = render_direct_codex_references_section(
project.path(),
std::slice::from_ref(&reference),
)
.expect("render")
.expect("section");
assert!(section.contains("素材 IDasset-hero"));
assert!(section.contains("名称:主角"));
assert!(section.contains("项目路径:assets/hero.png"));
assert!(!section.contains("C:\\secret.png"));
}
#[test]
fn deleted_resource_fails_closed() {
let project = fixture_project();
let reference: DirectCodexTurnReference = serde_json::from_str(
r#"{"type":"resource","resourceId":"asset-missing","label":"不存在"}"#,
)
.expect("reference json");
let error = render_direct_codex_references_section(
project.path(),
std::slice::from_ref(&reference),
)
.expect_err("missing resource");
assert_eq!(error, "引用的素材已不存在,请移除后重新选择");
}
#[test]
fn runtime_region_keeps_only_safe_summary_and_existing_resource_ids() {
let project = fixture_project();
let reference: DirectCodexTurnReference = serde_json::from_str(
r#"{"type":"runtime-region","label":"开始按钮","runId":"run-1","elementTag":"button","elementRole":"button","text":"开始游戏","width":120.4,"height":40.2,"resourceIds":["asset-hero"],"html":"<button onclick=secret>"}"#,
)
.expect("reference json");
let section = render_direct_codex_references_section(
project.path(),
std::slice::from_ref(&reference),
)
.expect("render")
.expect("section");
assert!(section.contains("运行画面区域:名称:开始按钮"));
assert!(section.contains("文本摘要:开始游戏"));
assert!(section.contains("尺寸:120x40"));
assert!(section.contains("关联素材 IDasset-hero"));
assert!(!section.contains("onclick"));
assert!(!section.contains("secret"));
}
}
File diff suppressed because it is too large Load Diff
@@ -1786,6 +1786,15 @@ fn direct_codex_failure_recovery_hint(stage: DirectCodexFailureStage, error: &st
{
return "历史画布资源不满足安全恢复条件,请先在资源画布确认唯一可用的核心图集";
}
if direct_project_history_injection_oversize(error) {
return "项目对话历史有单条记录或整份载荷超过注入上限,无法整体注入 Codex;请按项目诊断里的 itemId 处理该条记录后再发送需求";
}
if direct_project_history_shape_failure(error) {
return "项目对话历史存在本版本无法识别的记录,旧格式已兼容读取,请检查项目诊断后修复该历史文件再发送需求";
}
if direct_project_history_contention_failure(error) {
return "另一个客户端进程正在读写该项目的历史或项目锁,本轮历史未能落盘;请稍后重试,若确认没有其它客户端在运行请重启客户端后再发送需求";
}
match stage {
DirectCodexFailureStage::ArtPreparation => {
"平台资源暂时无法完成准备,请稍后重试;如持续失败请检查项目诊断"
@@ -1806,6 +1815,9 @@ fn direct_codex_failure_public_summary(error: &str) -> Option<&'static str> {
if direct_codex_error_is_mud_points_insufficient(error) {
return Some("泥点余额不足");
}
if direct_project_history_injection_oversize(error) {
return Some("项目对话历史有单条记录超过注入上限");
}
if private_external_editor_credentials_storage_preparation_failed(error) {
return Some("本机开发者凭据存储目录未安全初始化;未创建远端凭据");
}
@@ -1819,6 +1831,14 @@ fn direct_codex_failure_is_retryable(error: &str) -> bool {
if direct_codex_error_is_mud_points_insufficient(error) {
return false;
}
if direct_project_history_shape_failure(error) {
return false;
}
// 注入超限与「行形状」同类:同一份历史文件每次读都会得到同一结论,重试只会
// 再次注入同一份(且本轮用户消息已先追加进同一文件,载荷只会更大),因此不标可重试。
if direct_project_history_injection_oversize(error) {
return false;
}
![
"private-external-editor-credential-storage-preparation-failed",
"private-external-editor-credential-persistence-failed",
@@ -1832,6 +1852,45 @@ fn direct_codex_failure_is_retryable(error: &str) -> bool {
.any(|marker| error.contains(marker))
}
/// DirectProject 历史文件里与“行形状”有关的失败:同一份文件每次读都会得到同一结果,
/// 重试不会改变结论。IO 类失败(打开/读取目录)不在其中,那些仍按可重试处理。
const DIRECT_PROJECT_HISTORY_SHAPE_FAILURE_MARKERS: &[&str] = &[
"DirectProject 历史记录类型无效",
"DirectProject 历史记录缺少 payload",
"解析 DirectProject 历史失败",
];
/// DirectProject 历史**注入超限**的失败标记。
///
/// 判据是「同一份历史 ⇒ 同一份载荷 ⇒ 同一结论」:本次注入因为单条记录(或整份载荷)超过
/// 单行上限而被前置校验拦下(前缀与字节数定义在 `agent/codex_app_server.rs`),重试只会再注入
/// 同一份、且更大的历史。所以按不可重试处理,并给专属恢复提示;这里沿用本文件既有的
/// 「字面量子串」口径,只取前缀的特征子串。
const DIRECT_PROJECT_HISTORY_INJECTION_OVERSIZE_MARKERS: &[&str] = &["历史注入载荷超过单行上限"];
fn direct_project_history_injection_oversize(error: &str) -> bool {
DIRECT_PROJECT_HISTORY_INJECTION_OVERSIZE_MARKERS
.iter()
.any(|marker| error.contains(marker))
}
fn direct_project_history_shape_failure(error: &str) -> bool {
DIRECT_PROJECT_HISTORY_SHAPE_FAILURE_MARKERS
.iter()
.any(|marker| error.contains(marker))
}
/// 跨进程锁 / 项目锁争用。
///
/// 与"行形状"类相反:它不是同一份历史的同一个结论,而是别的进程此刻正拿着锁——锁本身
/// 没有残留(所有权是句柄,进程退出即释放),所以"稍后重试"是真能生效的动作。提示因此
/// 指向现象与动作,而不是原来 CodeGeneration 阶段那句"请检查运行时配置后重试"。
/// 判据只认两个常量,不各自复制中文。
fn direct_project_history_contention_failure(error: &str) -> bool {
error.contains(crate::project::PROJECT_APPEND_LOCK_TIMEOUT_MARKER)
|| error.contains(crate::project::PROJECT_WRITE_LOCK_CONTENTION_PREFIX)
}
fn direct_codex_error_is_mud_points_insufficient(error: &str) -> bool {
let normalized = error.to_ascii_lowercase();
error.contains("泥点余额不足")
@@ -4414,6 +4473,7 @@ pub(crate) async fn chat_with_game_creator_direct_codex(
creation_type: Option<String>,
client_turn_id: Option<String>,
attachments: Option<Vec<DirectCodexTurnAttachment>>,
references: Option<Vec<DirectCodexTurnReference>>,
) -> Result<String, String> {
let root = Path::new(project_path.trim());
let turn_id = normalize_direct_client_turn_id(client_turn_id.as_deref())?;
@@ -4428,16 +4488,33 @@ pub(crate) async fn chat_with_game_creator_direct_codex(
&prompt,
attachments.as_deref().unwrap_or_default(),
);
let user_prompt = match render_direct_codex_user_prompt(
&prompt,
attachments.as_deref().unwrap_or_default(),
) {
let attachments = attachments.unwrap_or_default();
let references = references.unwrap_or_default();
let mut user_prompt = match render_direct_codex_user_prompt(&prompt, &attachments) {
Ok(prompt) => prompt,
Err(_) if !references.is_empty() && prompt.trim().is_empty() => String::new(),
Err(error) => {
audit.finish(false);
return Err(error);
}
};
if let Some(reference_section) = match render_direct_codex_references_section(root, &references)
{
Ok(section) => section,
Err(error) => {
audit.finish(false);
return Err(error);
}
} {
if !user_prompt.trim().is_empty() {
user_prompt.push_str("\n\n");
}
user_prompt.push_str(&reference_section);
}
if user_prompt.trim().is_empty() {
audit.finish(false);
return Err("聊天内容不能为空".to_string());
}
let reply = match run_direct_game_creator_turn_at_with_creation_type_and_emitter(
root,
&user_prompt,
@@ -4531,6 +4608,23 @@ mod tests {
assert!(!direct_codex_failure_is_retryable(error));
}
#[test]
fn direct_project_history_shape_failure_has_explicit_non_retryable_guidance() {
let error =
"DirectProject 历史记录类型无效:$PROJECT_ROOT/.agent/conversations/project.jsonl";
assert!(direct_project_history_shape_failure(error));
assert_eq!(
direct_codex_failure_recovery_hint(DirectCodexFailureStage::CodeGeneration, error),
"项目对话历史存在本版本无法识别的记录,旧格式已兼容读取,请检查项目诊断后修复该历史文件再发送需求"
);
assert!(!direct_codex_failure_is_retryable(error));
// 历史文件的 IO 失败仍按可重试处理:它与行形状无关,重试可能成功。
let io_error = "打开 DirectProject 历史失败:拒绝访问";
assert!(!direct_project_history_shape_failure(io_error));
assert!(direct_codex_failure_is_retryable(io_error));
}
#[test]
fn client_turn_id_is_strictly_normalized_and_bounded() {
assert_eq!(
@@ -6505,6 +6599,50 @@ mod tests {
assert!(!diagnostic.contains("provider.example"));
}
#[test]
fn direct_failure_diagnostic_marks_project_history_shape_failure_as_not_retryable() {
let root = tempfile::tempdir().expect("temp dir");
init_local_game_project_at(root.path(), "direct-diagnostic", "直连诊断")
.expect("init project");
let history_path = root
.path()
.join(".agent/conversations/project.jsonl")
.display()
.to_string();
let error = record_direct_codex_turn_failure(
root.path(),
DirectCodexTurnFailure::new(
DirectCodexFailureStage::CodeGeneration,
format!("DirectProject 历史记录类型无效:{history_path}"),
),
);
assert!(
error.starts_with(
"direct-codex-failure:v1 stage=code-generation retryable=false summary="
),
"{error}"
);
assert!(
error.contains(
";建议:项目对话历史存在本版本无法识别的记录,旧格式已兼容读取,请检查项目诊断后修复该历史文件再发送需求;"
),
"{error}"
);
assert!(error.ends_with("已保存脱敏项目诊断"), "{error}");
let diagnostics = root.path().join(".agent/runtime/direct-codex-diagnostics");
let entries = std::fs::read_dir(&diagnostics)
.expect("diagnostic directory")
.filter_map(Result::ok)
.collect::<Vec<_>>();
assert_eq!(entries.len(), 1);
let diagnostic = std::fs::read_to_string(entries[0].path().join("failure.json"))
.expect("diagnostic sidecar");
assert!(diagnostic.contains("\"retryable\": false"), "{diagnostic}");
assert!(diagnostic.contains("旧格式已兼容读取"), "{diagnostic}");
}
#[test]
fn direct_failure_diagnostic_marks_ambiguous_canvas_identity_as_not_retryable() {
let root = tempfile::tempdir().expect("temp dir");
@@ -1161,6 +1161,12 @@ fn bridge_registered_resource(
"localPath": asset.local_path,
"kind": asset.kind,
"mediaType": asset.media_type,
// Agent 与 UI 必须看到同一个口径:UI 栏目走 TS 的 `gameCreationAppAssetCategory`
// (落盘值 + 按 kind 派生 + 读时自愈),这里走 Rust 的同构实现。
// 直接透传落盘 `asset.category` 会让 `kind:"ui"` 的资产在 UI 显示「UI 交互」、
// 在 Agent 侧读到 `unclassified`(真机 55 条分歧)。
"category": game_creation_app_asset_effective_category(&asset.kind, asset.category),
"tags": asset.tags,
"canvasProjectId": asset.source.canvas_project_id,
"resourceId": asset.source.resource_id,
"assetObjectId": asset.source.asset_object_id,
@@ -2990,6 +2996,8 @@ mod tests {
local_path: "assets/art-spritesheet-slices/player.png".to_string(),
image_sequence_frames: None,
image_sequence_duration_ms: None,
category: game_creation_app_asset_category_for_kind("art-spritesheet-slice"),
tags: Vec::new(),
source: GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Canvas,
canvas_project_id: Some("canvas-1".to_string()),
@@ -3053,6 +3061,8 @@ mod tests {
},
]),
image_sequence_duration_ms: Some(4_000),
category: game_creation_app_asset_category_for_kind("character-animation"),
tags: Vec::new(),
source: GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Canvas,
canvas_project_id: Some("canvas-1".to_string()),
@@ -3072,6 +3082,8 @@ mod tests {
projection["imageSequenceFrames"][0]["objectKey"],
"animations/frame-01.png"
);
assert_eq!(projection["category"], "character");
assert_eq!(projection["tags"], json!([]));
let serialized = projection.to_string();
assert!(!serialized.contains("signed.invalid"));
assert!(!serialized.contains("private prompt"));
@@ -3079,6 +3091,83 @@ mod tests {
assert!(!serialized.contains("private.invalid"));
}
/// 落盘分类是明确值时,Agent 侧原样透传。
///
/// (用例名从 `..._verbatim` 改成 `..._keeps_explicit...`:透传只在落盘值不是
/// `unclassified` 时成立,落盘 `unclassified` 要按读显示口径自愈,见下一个用例。)
#[test]
fn bridge_registered_resource_keeps_explicit_manifest_classification() {
let asset = GameCreationAppAssetManifestEntry {
id: "spec-1".to_string(),
kind: "spec".to_string(),
media_type: "application/json".to_string(),
local_path: "assets/specs/hero.json".to_string(),
image_sequence_frames: None,
image_sequence_duration_ms: None,
category:
shared_contracts::game_creation_app::GameCreationAppAssetCategory::UiInteraction,
tags: vec!["主角".to_string(), "第一版".to_string()],
source: GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Generated,
canvas_project_id: None,
resource_id: None,
asset_object_id: None,
task_id: None,
prompt: Some("private prompt".to_string()),
model: Some("private model".to_string()),
generation_route: None,
generation_kind: None,
reference_resource_ids: Vec::new(),
},
};
let projection = bridge_registered_resource(&asset, false);
assert_eq!(projection["kind"], "spec");
assert_eq!(projection["category"], "ui-interaction");
assert_eq!(projection["tags"], json!(["主角", "第一版"]));
assert_eq!(projection["imageSequenceFrames"], Value::Null);
let serialized = projection.to_string();
assert!(!serialized.contains("private prompt"));
assert!(!serialized.contains("private model"));
}
/// Agent 侧必须与 UI 看到同一个分类口径。
///
/// UI 栏目走 TS 的 `gameCreationAppAssetCategory`(落盘值 + 按 kind 派生 + 读时自愈),
/// Agent 走 Rust 的同构实现 `game_creation_app_asset_effective_category`。直接透传落盘
/// `category` 会让 `kind:"ui"` 的资产在 UI 显示「UI 交互」、在 Agent 侧读到
/// `unclassified`——真机 122 条资产里有 55 条这样分叉。
#[test]
fn bridge_registered_resource_projects_effective_category_not_raw_persisted_value() {
let asset = GameCreationAppAssetManifestEntry {
id: "ui-1".to_string(),
kind: "ui".to_string(),
media_type: "application/json".to_string(),
local_path: "assets/UI 设计 1.json".to_string(),
image_sequence_frames: None,
image_sequence_duration_ms: None,
category:
shared_contracts::game_creation_app::GameCreationAppAssetCategory::Unclassified,
tags: Vec::new(),
source: GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Generated,
canvas_project_id: None,
resource_id: None,
asset_object_id: None,
task_id: None,
prompt: None,
model: None,
generation_route: None,
generation_kind: None,
reference_resource_ids: Vec::new(),
},
};
let projection = bridge_registered_resource(&asset, false);
assert_eq!(projection["kind"], "ui");
assert_eq!(projection["category"], "ui-interaction");
}
#[test]
fn bridge_success_warnings_are_redacted_before_serialization() {
let root = tempfile::tempdir().expect("warning redaction root");
@@ -256,7 +256,7 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool) -> Value {
}),
json!({
"name": "agc_list_registered_assets",
"description": "查询当前项目由客户端权威 manifest 登记的资源与未完成资源 operation。结果有界且只包含项目相对路径、稳定资源身份、序列帧身份和恢复状态,不返回 prompt、模型、签名 URL、宿主路径或凭据。",
"description": "查询当前项目由客户端权威 manifest 登记的资源与未完成资源 operation。结果有界且只包含项目相对路径、稳定资源身份、功能分类与自定义标签、序列帧身份和恢复状态,不返回 prompt、模型、签名 URL、宿主路径或凭据。",
"inputSchema": {
"type": "object",
"properties": {
@@ -1247,8 +1247,6 @@ fn external_mcp_record_response(root: &Path, arguments: &Value) -> Value {
Err(error) => return mcp_tool_result(error, Vec::new(), true),
};
let redacted = redact_external_mcp_response(&content);
let key = format!("{request_id}\u{0}{sequence}");
let message_id = format!("external-codex-{:x}", Sha256::digest(key.as_bytes()));
let guard = EXTERNAL_MCP_JOURNAL_LOCK
.get_or_init(|| Mutex::new(()))
.lock();
@@ -1358,29 +1356,6 @@ fn external_mcp_record_response(root: &Path, arguments: &Value) -> Value {
true,
);
}
// Reuse the existing conversation projection so the current UI can read
// the explicit external response without treating it as business truth.
if let Err(error) = append_local_conversation_message_for_session_idempotent_at(
root,
None,
None,
LocalConversationMessage {
role: "assistant".to_string(),
content: record
.get("content")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
agent_id: None,
},
&message_id,
) {
return mcp_tool_result(
format!("Codex 返回已写入但对话投影失败:{error}"),
Vec::new(),
true,
);
}
records.push(record.clone());
mcp_tool_result(record.to_string(), Vec::new(), false)
}
@@ -2280,4 +2255,33 @@ mod tests {
}))
.is_err());
}
#[test]
fn recorded_codex_response_only_writes_its_own_journal() {
let temporary = crate::tests::canonical_test_tempdir("direct-tools-record-response-");
let root = temporary.path();
init_local_game_project_at(root, "direct-tools-record", "Codex 返回记录测试")
.expect("init project");
let result = external_mcp_record_response(
root,
&json!({
"requestId": "req-1",
"sequence": 0,
"content": "已完成的返回正文"
}),
);
assert_eq!(result.get("isError"), Some(&json!(false)), "{result}");
let journal = root.join(".agent/conversations/codex-responses.jsonl");
let journal_text = std::fs::read_to_string(&journal).expect("read codex responses journal");
assert!(journal_text.contains("已完成的返回正文"), "{journal_text}");
let project_history = root.join(".agent/conversations/project.jsonl");
let project_history_text = std::fs::read_to_string(&project_history).unwrap_or_default();
assert_eq!(
project_history_text, "",
"显式 Codex 返回不能再往项目主对话写 legacy 行"
);
}
}
@@ -6786,6 +6786,8 @@ fn register_platform_art_slice_manifest_entries_at(
local_path: registration.local_path.clone(),
image_sequence_frames: None,
image_sequence_duration_ms: None,
category: game_creation_app_asset_category_for_kind("art-spritesheet-slice"),
tags: Vec::new(),
source,
});
ids.push(id);
@@ -1114,6 +1114,9 @@ pub(in crate::agent) fn ui_prototype_visual_inspection_blocker_detail_at_locked(
let image = images
.pop()
.ok_or_else(|| "UI 原型图片读取结果为空".to_string())?;
// 摘要缺失必须失败关闭:视觉检查审计按摘要证明「检查过的就是当前这张图」,
// 不能退化成空摘要比较,否则一条 sha256 为空的记录就能通过复核。
let image_sha256 = image.sha256_digest()?.to_string();
let (records, scan_truncated) =
read_agent_db_records_bounded(root, AGENT_RUNTIME_ACTION_HISTORY_MAX_DB_BYTES)?;
let matching = records.iter().rev().find(|record| {
@@ -1139,13 +1142,13 @@ pub(in crate::agent) fn ui_prototype_visual_inspection_blocker_detail_at_locked(
&& items[0].get("path").and_then(serde_json::Value::as_str)
== Some(expected_path)
&& items[0].get("sha256").and_then(serde_json::Value::as_str)
== Some(image.sha256.as_str())
== Some(image_sha256.as_str())
})
});
let Some(record) = matching else {
return Ok(Some(format!(
"expectedPath={expected_path} · currentSha256={} · requiredInspection=image.inspect · inspectionRunId={} · scanTruncated={scan_truncated}",
image.sha256,
image_sha256,
required_run_id.unwrap_or("latest-current-image")
)));
};
@@ -429,16 +429,26 @@ pub(in crate::agent) async fn observe_agent_runtime_image_inspect(
.as_deref()
.map(|value| sanitize_agent_runtime_text(value, 160))
.filter(|value| !value.trim().is_empty());
let image_metadata = images
.iter()
.map(|image| {
serde_json::json!({
"path": image.relative_path,
"sha256": image.sha256,
"bytes": image.byte_len,
})
})
.collect::<Vec<_>>();
// 视觉检查审计按 sha256 记录并复核「检查过的就是当前这张图」,摘要缺失必须失败关闭。
let mut image_metadata = Vec::with_capacity(images.len());
for image in &images {
let sha256 = match image.sha256_digest() {
Ok(sha256) => sha256,
Err(error) => {
return AgentRuntimeToolObservation {
tool: "image.inspect".to_string(),
status: "failed".to_string(),
summary: sanitize_agent_runtime_text(&error, 240),
detail: None,
};
}
};
image_metadata.push(serde_json::json!({
"path": image.relative_path,
"sha256": sha256,
"bytes": image.byte_len,
}));
}
let validation_profile =
ui_prototype_inspection.then_some(AGENT_RUNTIME_UI_PROTOTYPE_VALIDATION_PROFILE);
let passed = ui_prototype_assessment
@@ -467,6 +467,77 @@ pub(crate) fn external_editor_api_credentials_override_is_active() -> bool {
active_external_editor_api_credentials().is_some()
}
/// 上传素材落盘的 manifest `kind`:只由**内容证据**推导(`mediaType` 优先,扩展名兜底)。
///
/// 不用 `uploaded` 这类**来源词**当类型:它不在 canonical 目录里,会经别名表落到
/// `image → unclassified`,把本该归「音频」「文档」的上传素材一起说成图片。
///
/// 更不能把 `ui` 当"图片的默认类型"`ui → ui-design → ui-interaction` 会把任意上传图片
/// 钉死在「UI 交互」栏,而"是不是 UI 素材"跟"扩展名是不是 .png"毫无关系;这类错值还不可恢复
/// ——读时自愈只在落盘 `category` 是 `unclassified` 且该 `kind` 能派生出非 `unclassified`
/// 分类时才生效,`kind` 本身错时自愈只会把错值放大。
///
/// 判不出内容类型时返回中性的 `asset`(派生 `unclassified` → 「待归类」),**不猜具体类型**。
fn uploaded_asset_kind(file_name: &str, media_type: &str) -> &'static str {
let media_type = media_type.trim().to_ascii_lowercase();
let extension = Path::new(file_name)
.extension()
.and_then(|value| value.to_str())
.unwrap_or_default()
.to_ascii_lowercase();
let extension = extension.as_str();
if media_type.starts_with("audio/")
|| matches!(
extension,
"mp3" | "wav" | "ogg" | "m4a" | "aac" | "flac" | "opus"
)
{
"audio"
} else if media_type.starts_with("video/") || matches!(extension, "mp4" | "webm" | "mov") {
"video"
} else if media_type.starts_with("font/")
|| matches!(extension, "ttf" | "otf" | "woff" | "woff2")
{
"document"
} else if media_type.starts_with("image/")
|| matches!(
extension,
"png" | "jpg" | "jpeg" | "webp" | "gif" | "svg" | "avif" | "bmp"
)
{
"image"
} else if matches!(media_type.as_str(), "text/html" | "text/css")
|| media_type.contains("javascript")
|| media_type.contains("typescript")
|| matches!(
extension,
"html" | "htm" | "css" | "js" | "mjs" | "cjs" | "jsx" | "ts" | "tsx"
)
{
"code"
} else if media_type.starts_with("text/")
|| matches!(media_type.as_str(), "application/json" | "application/xml")
|| matches!(
extension,
"md" | "markdown"
| "mdx"
| "txt"
| "json"
| "yaml"
| "yml"
| "toml"
| "csv"
| "ini"
| "conf"
| "xml"
)
{
"document"
} else {
"asset"
}
}
pub(crate) fn upload_local_asset_at(
root: &Path,
file_name: &str,
@@ -492,7 +563,7 @@ pub(crate) fn upload_local_asset_at(
register_local_asset_entry(
root,
&relative_path,
"uploaded",
uploaded_asset_kind(file_name, media_type),
media_type,
"upload",
GameCreationAppAssetSource {
@@ -1635,27 +1706,35 @@ pub(crate) fn normalized_zip_entry_name(name: &str) -> Result<String, String> {
normalize_relative_path(&normalized)
}
/// 画板导出图层推断出的资源 kind。
///
/// 返回值必须是 **canonical kind**:这个值会被 `register_local_asset_entry` 原样写入
/// manifest,并据以派生落盘 `category`。历史上这里写过非 canonical 的
/// `ui` / `animation` / `asset`,只能靠别名表兜底,等于同时维护两套词汇;别名表只用于
/// 兼容存量数据,不作为新写入值的来源。
pub(crate) fn infer_canvas_export_asset_kind(
layer: &CanvasExportLayerMetadata,
file: &str,
) -> &'static str {
let layer_type = layer.visible.layer_type.as_str();
if file.starts_with("sequences/") || contains_any(layer_type, &["序列", "动画", "动作"]) {
return "animation";
}
if file.starts_with("media/") || contains_any(layer_type, &["音频", "音乐", "音效"]) {
return "audio";
}
if contains_any(layer_type, &["角色"]) {
return "character";
}
if contains_any(layer_type, &["场景", "背景"]) {
return "scene";
}
if contains_any(layer_type, &["UI", "界面", "图标"]) {
return "ui";
}
"asset"
let inferred = if file.starts_with("sequences/")
|| contains_any(layer_type, &["序列", "动画", "动作"])
{
"character-animation"
} else if file.starts_with("media/") || contains_any(layer_type, &["音频", "音乐", "音效"])
{
"audio"
} else if contains_any(layer_type, &["角色"]) {
"character"
} else if contains_any(layer_type, &["场景", "背景"]) {
"scene"
} else if contains_any(layer_type, &["UI", "界面", "图标"]) {
"ui-design"
} else {
"image"
};
// 防御性归一:分支字面量写错时由覆盖测试暴露,这里再兜一层。
shared_contracts::game_creation_app::canonical_game_creation_app_asset_kind(inferred)
}
pub(crate) fn infer_canvas_export_media_type(file: &str) -> &'static str {
@@ -1751,7 +1830,14 @@ pub(crate) fn register_local_asset_entry(
.iter_mut()
.find(|asset| asset.local_path == normalized_path)
{
existing.kind = kind.to_string();
// kind 变了必须重派生 category:否则同路径重登记会把新 kind 和旧分类拼在一起,
// 而陈旧的非 unclassified 值会被读侧无条件信任(自愈只在落盘值是 unclassified
// 时才触发),于是这个资产永远停在错误栏目。
// kind 没变时刻意不动 category——落盘分类是权威值,同 kind 重登记不得抹掉它。
if existing.kind != kind {
existing.kind = kind.to_string();
existing.category = game_creation_app_asset_category_for_kind(kind);
}
existing.media_type = media_type.to_string();
existing.source = source;
Ok((existing.id.clone(), "asset.update"))
@@ -1768,6 +1854,8 @@ pub(crate) fn register_local_asset_entry(
local_path: normalized_path.clone(),
image_sequence_frames: None,
image_sequence_duration_ms: None,
category: game_creation_app_asset_category_for_kind(kind),
tags: Vec::new(),
source,
});
Ok((id, "asset.register"))
@@ -1793,11 +1881,286 @@ pub(crate) fn register_local_asset_entry(
})
}
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub(crate) struct ReadLocalProjectAssetReferencesInput {
pub project_path: String,
pub asset_id: String,
}
/// 引用了某个素材的项目版本摘要。删除弹窗据此列出「被哪些版本使用」。
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct LocalProjectAssetReferenceVersion {
pub version_id: String,
pub project_revision: u64,
pub created_at: u64,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ReadLocalProjectAssetReferencesResult {
pub asset_id: String,
pub versions: Vec<LocalProjectAssetReferenceVersion>,
}
/// 版本数组里 `resourceBindings` 指向该素材的版本,顺序与版本数组一致。
///
/// 版本绑定即资源记录:`resourceId` 指向 manifest 资产 ID,命中即表示该版本用了这个素材。
fn asset_referencing_versions(
manifest: &GameCreationAppManifest,
asset_id: &str,
) -> Vec<LocalProjectAssetReferenceVersion> {
manifest
.versions
.iter()
.filter(|version| {
version
.resource_bindings
.iter()
.any(|binding| binding.resource_id == asset_id)
})
.map(|version| LocalProjectAssetReferenceVersion {
version_id: version.version_id.clone(),
project_revision: version.project_revision,
created_at: version.created_at,
})
.collect()
}
fn normalized_asset_id(asset_id: &str) -> Result<&str, String> {
let asset_id = asset_id.trim();
if asset_id.is_empty() {
return Err("素材 assetId 不能为空".to_string());
}
Ok(asset_id)
}
pub(crate) fn read_manifest_asset_references_at(
root: &Path,
asset_id: &str,
) -> Result<ReadLocalProjectAssetReferencesResult, String> {
let asset_id = normalized_asset_id(asset_id)?;
let manifest = read_existing_manifest_for_project(root)?;
Ok(ReadLocalProjectAssetReferencesResult {
asset_id: asset_id.to_string(),
versions: asset_referencing_versions(&manifest, asset_id),
})
}
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub(crate) struct DeleteLocalProjectAssetInput {
pub project_path: String,
pub expected_project_id: String,
pub expected_project_revision: u64,
pub asset_id: String,
/// 用户是否勾选「把相关游戏版本一并删除」;默认不勾,只删素材登记。
pub delete_referenced_versions: bool,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct DeleteLocalProjectAssetResult {
pub asset_id: String,
pub local_path: String,
pub committed_project_revision: u64,
/// 只删除 manifest 登记,磁盘文件保留;调用方据此提示用户。
pub file_retained: bool,
}
/// 删除一个素材登记,并按用户确认决定是否连带删除引用它的游戏版本。
///
/// 语义:
/// - 未被任何版本引用 → 只摘掉登记;
/// - 被引用且 `delete_referenced_versions` 为 `false` → 只摘掉登记,引用它的版本原样保留为
/// 悬空绑定,不做二次拦截;界面不为这些版本合成资源卡;
/// - 被引用且为 `true` → 素材与所有引用它的版本在**同一次 manifest 写入**里一起删除;
/// - 素材不可变,**不删除磁盘文件**,只摘掉 manifest 登记;
/// - 与资源分类更新同口径:持项目写锁后按 `expectedProjectId` / 当前 revision 做 CAS
/// 失败时 manifest 与 revision 都不变。
pub(crate) fn delete_manifest_asset_at(
root: &Path,
expected_project_id: &str,
expected_project_revision: u64,
asset_id: &str,
delete_referenced_versions: bool,
) -> Result<DeleteLocalProjectAssetResult, String> {
if expected_project_revision
> shared_contracts::game_creation_app::GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION
{
return Err("expectedProjectRevision 超出 JavaScript 安全整数范围".to_string());
}
let expected_project_id = expected_project_id.trim();
if expected_project_id.is_empty() {
return Err("素材删除 expectedProjectId 不能为空".to_string());
}
let asset_id = normalized_asset_id(asset_id)?;
if read_existing_manifest_for_project(root)?.project_id != expected_project_id {
return Err("project-identity-conflict".to_string());
}
let _lock = acquire_project_write_lock(root, "asset.register")?;
if read_existing_manifest_for_project(root)?.project_id != expected_project_id {
return Err("project-identity-conflict".to_string());
}
if read_game_creator_agent_runtime_project_revision(root)?.revision != expected_project_revision
{
return Err("project-revision-conflict".to_string());
}
let mut local_path = String::new();
mutate_manifest_at_allowing_version_removals(
root,
&|manifest| {
if !delete_referenced_versions {
return Vec::new();
}
asset_referencing_versions(manifest, asset_id)
.into_iter()
.map(|version| version.version_id)
.collect()
},
|manifest| {
let Some(index) = manifest
.assets
.iter()
.position(|asset| asset.id == asset_id)
else {
return Err(format!("项目资源不存在:{asset_id}"));
};
local_path = manifest.assets[index].local_path.clone();
manifest.assets.remove(index);
if delete_referenced_versions {
let referenced_version_ids: Vec<String> =
asset_referencing_versions(manifest, asset_id)
.into_iter()
.map(|version| version.version_id)
.collect();
manifest
.versions
.retain(|version| !referenced_version_ids.contains(&version.version_id));
}
Ok(())
},
)?;
let committed_project_revision = advance_agent_runtime_project_revision_locked(root)
.map_err(|error| format!("素材登记已删除,但项目 revision 未能推进:{error}"))?;
Ok(DeleteLocalProjectAssetResult {
asset_id: asset_id.to_string(),
local_path,
committed_project_revision,
file_retained: true,
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::{Read, Write};
/// 画板导出推断出的 kind 必须已经是 canonical 值。
///
/// 这个值会被原样写进 manifest 并据以派生落盘 `category`;一旦写出非 canonical 值
/// (历史上曾写 `ui` / `animation` / `asset`),就只能靠别名表兜底,等于同时维护
/// 两套词汇,且已落盘的 category 无法随别名修复自愈。这里逐分支钉死。
#[test]
fn canvas_export_asset_kind_is_always_canonical() {
fn layer(layer_type: &str) -> CanvasExportLayerMetadata {
serde_json::from_value(serde_json::json!({
"title": "测试图层",
"file": "layer.png",
"exportError": null,
"visible": {
"type": layer_type,
"model": "-",
"task": "-",
"object": "-",
},
}))
.expect("canvas export layer metadata")
}
// 每个分支的代表输入 → 期望的 canonical kind。
let cases: [(&str, &str, &str); 6] = [
("序列", "sequences/01.png", "character-animation"),
("动画", "layer.png", "character-animation"),
("音频", "layer.png", "audio"),
("角色", "layer.png", "character"),
("场景", "layer.png", "scene"),
("UI", "layer.png", "ui-design"),
// 无匹配时落到 image。
];
for (layer_type, file, expected) in cases {
let kind = infer_canvas_export_asset_kind(&layer(layer_type), file);
assert_eq!(kind, expected, "layer_type={layer_type} file={file}");
}
// media/ 前缀同样走音频分支。
assert_eq!(
infer_canvas_export_asset_kind(&layer("图层"), "media/bgm.mp3"),
"audio"
);
// 兜底分支是 image 而不是旧的 "asset"。
assert_eq!(
infer_canvas_export_asset_kind(&layer("图层"), "layer.png"),
"image"
);
// 所有分支的返回值都必须在 canonical 目录里,且不能是旧的非 canonical 写法。
for layer_type in ["序列", "音频", "角色", "场景", "UI", "图层", "其他"] {
let kind = infer_canvas_export_asset_kind(&layer(layer_type), "layer.png");
assert!(
shared_contracts::game_creation_app::GAME_CREATION_APP_CANONICAL_ASSET_KINDS
.contains(&kind),
"非 canonical kind: {kind}layer_type={layer_type}"
);
for legacy in ["ui", "animation", "asset"] {
assert_ne!(kind, legacy, "写回了非 canonical 的 {legacy}");
}
}
}
/// 上传素材的 `kind` 只由内容证据推导:既不能写 `uploaded`(来源词当类型),
/// 更不能把 `ui` 当图片默认值(`ui → ui-design → ui-interaction` 会把任意上传图片
/// 钉死在「UI 交互」栏,且落盘 `category` 非 `unclassified` 后读时自愈救不回来)。
///
/// 变异验证:把 `uploaded_asset_kind` 改回返回常量(`"uploaded"` 或 `"ui"`)必须让本用例变红。
#[test]
fn uploaded_asset_kind_uses_content_evidence_and_never_ui() {
assert_eq!(uploaded_asset_kind("hero.png", "image/png"), "image");
assert_eq!(uploaded_asset_kind("../角色.png", "image/png"), "image");
assert_eq!(uploaded_asset_kind("bg.webp", ""), "image");
assert_eq!(uploaded_asset_kind("theme.mp3", "audio/mpeg"), "audio");
assert_eq!(uploaded_asset_kind("intro.mp4", ""), "video");
assert_eq!(uploaded_asset_kind("rules.md", "text/markdown"), "document");
assert_eq!(uploaded_asset_kind("ui-font.ttf", "font/ttf"), "document");
assert_eq!(uploaded_asset_kind("game.js", "text/javascript"), "code");
// 判不出内容类型 → 中性 `asset`(派生 `unclassified` → 「待归类」),不猜具体类型。
assert_eq!(
uploaded_asset_kind("unknown.bin", "application/octet-stream"),
"asset"
);
assert_eq!(uploaded_asset_kind("no-extension", ""), "asset");
// 反查:正文里的"来源词"和"UI 默认值"都不得出现在返回值里。
for (file_name, media_type) in [
("hero.png", "image/png"),
("theme.mp3", "audio/mpeg"),
("unknown.bin", ""),
] {
let kind = uploaded_asset_kind(file_name, media_type);
assert_ne!(kind, "uploaded", "{file_name} 写回了来源词");
assert_ne!(kind, "ui", "{file_name} 写回了非 canonical 的 `ui`");
assert!(
shared_contracts::game_creation_app::GAME_CREATION_APP_CANONICAL_ASSET_KINDS
.contains(&kind)
|| kind == "asset",
"非 canonical kind: {kind}{file_name}"
);
}
}
#[test]
fn infer_file_extension_preserves_supported_local_resource_extensions() {
assert_eq!(
File diff suppressed because it is too large Load Diff
@@ -31,7 +31,10 @@ pub(crate) struct LocalProjectImagePreview {
pub(crate) struct AgentRuntimeInspectionImage {
pub(crate) relative_path: String,
pub(crate) sha256: String,
/// 只有 Agent 视觉检查(`image.inspect`)需要内容摘要:它要把摘要写进动作审计并按摘要
/// 复核「检查过的就是当前这张图」。资源卡预览只需要字节、媒体类型和像素尺寸,摘要没有
/// 任何消费者,因此预览路径不计算它。取摘要必须走 [`Self::sha256_digest`],缺摘要即失败关闭。
pub(crate) sha256: Option<String>,
pub(crate) byte_len: u64,
pub(crate) media_type: &'static str,
pixel_width: u32,
@@ -48,6 +51,15 @@ impl AgentRuntimeInspectionImage {
)
}
pub(crate) fn sha256_digest(&self) -> Result<&str, String> {
self.sha256.as_deref().ok_or_else(|| {
format!(
"image.inspect 缺少内容摘要:{};摘要只在视觉检查读取路径上计算",
self.relative_path
)
})
}
fn data_url_with_cancellation(
&self,
cancellation: &ProjectResourcePreviewScopeCancellation,
@@ -83,8 +95,12 @@ pub(crate) fn load_local_project_image_preview_with_cancellation(
let absolute = resolve_local_project_path(root, &normalized)?;
validate_agent_runtime_inspection_ancestors(root, &absolute)?;
cancellation.check()?;
let image =
read_agent_runtime_inspection_image_with_cancellation(&absolute, normalized, cancellation)?;
let image = read_agent_runtime_inspection_image_with_cancellation(
&absolute,
normalized,
cancellation,
false,
)?;
cancellation.check()?;
Ok(LocalProjectImagePreview {
path: image.relative_path.clone(),
@@ -273,13 +289,21 @@ fn read_agent_runtime_inspection_image(
path,
relative_path,
&ProjectResourcePreviewScopeCancellation::uncancelled(),
true,
)
}
/// 单次受控读取:门禁、分块读取、漂移与替换复核、签名 / 尺寸校验对两条调用方完全一致,
/// 只有内容摘要按 `include_sha256` 分叉。
///
/// `include_sha256 = true` 是 Agent 视觉检查路径(`image.inspect`):摘要要写进动作审计,并在
/// `project_gates` 里按摘要复核「检查过的就是当前这张图」。资源卡预览路径传 `false`,因为它的
/// `LocalProjectImagePreview` 从来不消费摘要,只为它多算一遍全长 SHA-256 是纯开销。
fn read_agent_runtime_inspection_image_with_cancellation(
path: &Path,
relative_path: String,
cancellation: &ProjectResourcePreviewScopeCancellation,
include_sha256: bool,
) -> Result<AgentRuntimeInspectionImage, String> {
cancellation.check()?;
let (mut file, initial_metadata) = open_project_snapshot_regular_file(path, "视觉检查图片")?;
@@ -360,7 +384,7 @@ fn read_agent_runtime_inspection_image_with_cancellation(
));
}
cancellation.check()?;
let sha256 = format!("{:x}", Sha256::digest(&bytes));
let sha256 = include_sha256.then(|| format!("{:x}", Sha256::digest(&bytes)));
Ok(AgentRuntimeInspectionImage {
relative_path,
sha256,
@@ -582,17 +606,40 @@ fn runtime_path_component(value: &str, fallback: &str) -> String {
}
#[cfg(windows)]
fn metadata_is_windows_reparse_point(metadata: &fs::Metadata) -> bool {
pub(crate) fn metadata_is_windows_reparse_point(metadata: &fs::Metadata) -> bool {
use std::os::windows::fs::MetadataExt;
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
}
#[cfg(not(windows))]
fn metadata_is_windows_reparse_point(_metadata: &fs::Metadata) -> bool {
pub(crate) fn metadata_is_windows_reparse_point(_metadata: &fs::Metadata) -> bool {
false
}
/// 已打开句柄的跨平台文件身份:Windows 用 `(volume serial, file index)`Unix 用 `(dev, ino)`。
/// 资源卡预览的 manifest 缓存用它判断「路径指向的还是同一个文件」。
pub(crate) fn open_file_identity_key(file: &fs::File) -> Result<(u64, u64), String> {
#[cfg(windows)]
{
let (volume_serial_number, file_index) = windows_file_identity(file)?;
Ok((u64::from(volume_serial_number), file_index))
}
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
let metadata = file
.metadata()
.map_err(|error| format!("读取文件身份元数据失败:{error}"))?;
Ok((metadata.dev(), metadata.ino()))
}
#[cfg(not(any(unix, windows)))]
{
let _ = file;
Err("当前平台不支持文件身份判定".to_string())
}
}
#[cfg(unix)]
pub(crate) fn same_open_file_snapshot(left: &fs::Metadata, right: &fs::Metadata) -> bool {
use std::os::unix::fs::MetadataExt;
@@ -26,7 +26,9 @@ use serde::{Deserialize, Serialize};
use sha2::Digest;
use shared_contracts::error_reports::ErrorReportLogInput;
use shared_contracts::game_creation_app::{
new_game_creation_app_manifest, new_game_creation_app_seed_tasks,
game_creation_app_asset_category_for_kind, game_creation_app_asset_category_from_str,
game_creation_app_asset_effective_category, new_game_creation_app_manifest,
new_game_creation_app_seed_tasks, normalize_game_creation_app_asset_tags,
validate_game_iteration_versions, GameCreationAgentArtifactTrace,
GameCreationAgentCapabilityDescriptor, GameCreationAgentPassPlanTrace,
GameCreationAgentRepairRouteTrace, GameCreationAgentRunStep,
@@ -2216,6 +2218,13 @@ where
fn handle_game_creator_gui_run_event(event: &tauri::RunEvent) {
if matches!(event, tauri::RunEvent::Exit) {
// 退出时统一收尾本地预览:进程内监听线程随进程消失,但 `.agent/manifest.json`
// 里的 preview 记录会留在 running 上,下次进项目就照着它渲染打不开的运行界面。
if let Err(error) =
preview::stop_local_game_preview_on_exit(&game_creator_preview_registry())
{
app_log!("preview.gui_exit.stop_failed: {error}");
}
if let Err(error) = agent::shutdown_game_creator_codex_app_servers() {
app_log!("agent.direct_codex.gui_exit.shutdown_failed: {error}");
}
@@ -2562,6 +2571,7 @@ fn main() {
pick_local_project_directory,
rename_local_game_project,
suggest_automatic_project_name,
polish_local_project_prompt,
pick_local_file,
pick_client_extension_file,
pick_client_extension_directory,
@@ -2613,6 +2623,7 @@ fn main() {
upload_local_asset,
register_local_asset,
create_ui_design_resource,
update_local_project_resource_classification,
derive_local_project_resource,
list_pending_local_project_resource_edits,
resume_local_project_resource_edit,
@@ -2645,6 +2656,7 @@ fn main() {
import_local_project_image_assets,
read_local_project_file,
read_local_project_image_preview,
save_local_project_asset_file,
read_local_project_text_preview,
read_local_project_media_preview,
cancel_local_project_resource_preview_scope,
@@ -2683,24 +2695,9 @@ fn main() {
read_local_project_resource_canvas_layout,
read_local_project_resource_graph,
update_local_project_resource_canvas_layout,
create_local_project_asset_canvas_draft,
read_local_project_asset_canvas_draft,
discover_local_project_asset_canvas_draft,
update_local_project_asset_canvas_draft,
acknowledge_local_project_asset_canvas_candidate_layers,
import_local_project_asset_canvas_images,
store_local_project_asset_canvas_media,
stage_local_project_asset_canvas_image,
finalize_local_project_asset_canvas_generation_failure,
archive_failed_local_project_asset_canvas_generation,
generate_local_project_asset_canvas_image,
recover_local_project_asset_canvas_generations,
confirm_local_project_asset_canvas_generation_service_identity,
read_local_project_asset_canvas_media,
discard_local_project_asset_canvas_draft,
recover_local_project_asset_canvas_transactions,
commit_local_project_asset,
commit_local_project_asset_canvas_candidate,
delete_local_project_asset,
read_local_project_asset_references,
rename_local_project_asset,
get_local_game_project_revision,
get_local_game_manifest,
download_agc_update,
@@ -488,6 +488,113 @@ const PREVIEW_FIT_BRIDGE_SCRIPT: &str = r#"(() => {
if (!document.hidden) schedule();
}, 500);
};
const inspectMessageType = 'genarrative.local-preview-inspect.v1';
const inspectState = { enabled: false, overlay: null, current: null };
const inspectOverlay = () => {
if (inspectState.overlay) return inspectState.overlay;
const overlay = document.createElement('div');
overlay.dataset.genarrativePreviewInspect = 'true';
overlay.style.cssText = 'position:fixed;z-index:2147483647;pointer-events:none;border:2px solid #f97316;background:rgba(249,115,22,0.14);box-shadow:inset 0 0 0 1px rgba(255,255,255,0.85);';
(document.body || document.documentElement).appendChild(overlay);
inspectState.overlay = overlay;
return overlay;
};
const clearInspectOverlay = () => {
inspectState.overlay?.remove();
inspectState.overlay = null;
inspectState.current = null;
};
const inspectElementSummary = (element) => {
const rect = element.getBoundingClientRect();
const tag = element.tagName ? element.tagName.toLowerCase() : 'div';
const role = (element.getAttribute && element.getAttribute('role')) || '';
const text = (element.innerText || element.textContent || '')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 120);
const resourceIds = [];
if (element.getAttribute) {
for (const name of ['data-resource-id', 'data-asset-id', 'data-genarrative-resource-id']) {
const value = element.getAttribute(name);
if (value && !resourceIds.includes(value)) resourceIds.push(value);
}
}
const media = element.querySelector ? element.querySelector('img,video,audio') : null;
const rawSource =
(element.getAttribute && element.getAttribute('src')) ||
(media && media.getAttribute && media.getAttribute('src')) ||
'';
let sourcePath = '';
if (rawSource) {
try {
sourcePath = new URL(rawSource, window.location.href).pathname;
} catch {
sourcePath = '';
}
}
return {
label: text || tag,
elementTag: tag,
elementRole: role.slice(0, 80),
text,
width: Math.max(1, Math.round(rect.width)),
height: Math.max(1, Math.round(rect.height)),
resourceIds,
sourcePath,
};
};
const postInspect = (action, selection) => {
window.parent.postMessage({ type: inspectMessageType, action, selection }, '*');
};
const stopInspect = (cancelled) => {
if (!inspectState.enabled) return;
inspectState.enabled = false;
document.removeEventListener('mousemove', handleInspectMove, true);
document.removeEventListener('click', handleInspectClick, true);
document.removeEventListener('keydown', handleInspectKeyDown, true);
clearInspectOverlay();
postInspect(cancelled ? 'cancelled' : 'disabled');
};
const handleInspectMove = (event) => {
if (!inspectState.enabled) return;
const target = event.target;
if (!(target instanceof Element)) return;
inspectState.current = target;
const rect = target.getBoundingClientRect();
const overlay = inspectOverlay();
overlay.style.left = `${rect.left}px`;
overlay.style.top = `${rect.top}px`;
overlay.style.width = `${rect.width}px`;
overlay.style.height = `${rect.height}px`;
};
const handleInspectClick = (event) => {
if (!inspectState.enabled) return;
event.preventDefault();
event.stopPropagation();
const target = event.target;
if (target instanceof Element) postInspect('selected', inspectElementSummary(target));
stopInspect(false);
};
const handleInspectKeyDown = (event) => {
if (!inspectState.enabled || event.key !== 'Escape') return;
event.preventDefault();
stopInspect(true);
};
const startInspect = () => {
if (inspectState.enabled) return;
inspectState.enabled = true;
document.addEventListener('mousemove', handleInspectMove, true);
document.addEventListener('click', handleInspectClick, true);
document.addEventListener('keydown', handleInspectKeyDown, true);
postInspect('enabled');
};
window.addEventListener('message', (event) => {
if (event.source !== window.parent) return;
const data = event.data;
if (!data || data.type !== inspectMessageType) return;
if (data.action === 'enable') startInspect();
else if (data.action === 'disable') stopInspect(false);
});
if (ready) activate();
else window.addEventListener('load', activate, { once: true });
})();"#;
@@ -651,7 +758,14 @@ pub(crate) fn start_local_game_preview_at_revision(
}
let (preview, previous_preview) = registry.set_running(preview, stop);
if let Some(previous_preview) = previous_preview.as_ref() {
record_replaced_preview_stop(previous_preview);
// 同一个项目重启预览(换监听线程、换端口)时,旧的 registry 身份与新预览共享
// 同一份落盘记录:上面刚写进去的是 running,这里若再用旧预览收尾,就会把它覆盖
// 成 stopped,下次进项目便丢掉"预览在跑"这条事实。只有旧预览属于**另一个项目**
// 时才替它收尾(那才是真的换项目)。
let previous_status = local_preview_status_from_result(previous_preview);
if ensure_preview_belongs_to_project(&previous_status, root).is_err() {
record_replaced_preview_stop(previous_preview);
}
}
if let Err(error) = append_preview_start_trace_step(root, &preview) {
let _ = registry.stop();
@@ -685,14 +799,44 @@ pub(crate) fn stop_local_game_preview_for_root(
registry: &PreviewRegistry,
) -> Result<LocalPreviewStatus, String> {
let (status, stopped) = registry.stop_for_project(root);
if let Some(root) = root.filter(|_| stopped) {
record_preview_state(root, GameCreationAppPreviewStatus::Stopped, None, None)?;
append_preview_log(root, "stopped", None)?;
append_preview_stop_trace_step(root)?;
let Some(root) = root else {
return Ok(status);
};
// 没有活体预览可停时,落盘记录仍可能是陈旧的 running:预览服务器是进程内线程、
// 非 Linux 端口还是随机临时端口,客户端退出后记录会一直写着 running。这时把记录
// 对齐成 stopped 才算真的"停"——否则下次进项目会照着陈旧记录渲染打不开的运行
// 界面(进入项目时的活体核对是第二道保险,记录本身也该在自己的生命周期里结束)。
let stale_record = !stopped
&& read_manifest_for_project(root)?
.preview
.is_some_and(|preview| preview.status == GameCreationAppPreviewStatus::Running);
if !stopped && !stale_record {
return Ok(status);
}
record_preview_state(root, GameCreationAppPreviewStatus::Stopped, None, None)?;
append_preview_log(root, "stopped", None)?;
append_preview_stop_trace_step(root)?;
Ok(status)
}
/// 退出路径:把当前预览真正停掉,并把落盘记录对齐成 stopped。
///
/// 客户端退出是"记录与真相分叉"的主要来源(进程内监听线程随进程消失,记录却留着
/// running),所以退出时统一收尾。进入项目时的活体核对仍然保留:进程被强杀、断电这类
/// 不走退出路径的情况,靠那道核对自愈。
pub(crate) fn stop_local_game_preview_on_exit(registry: &PreviewRegistry) -> Result<(), String> {
let status = registry.status();
if status.status != "running" {
return Ok(());
}
let Some(root) = status.root.as_deref() else {
// running 却没有 root 不该出现:只把 listener 停掉,不猜记录该写到哪个项目。
registry.stop();
return Ok(());
};
stop_local_game_preview_for_root(Some(Path::new(root)), registry).map(|_| ())
}
#[tauri::command]
pub(crate) fn stop_local_game_preview_if_matches(
project_path: String,
@@ -4,7 +4,8 @@ use similar::TextDiff;
use std::io::{Seek, SeekFrom};
mod agent_db;
mod asset_canvas;
mod asset_export;
mod asset_rename;
mod checkpoint;
mod conversation;
mod export;
@@ -19,7 +20,8 @@ mod verification;
mod write_lock;
pub(crate) use agent_db::*;
pub(crate) use asset_canvas::*;
pub(crate) use asset_export::*;
pub(crate) use asset_rename::*;
pub(crate) use checkpoint::*;
pub(crate) use conversation::*;
pub(crate) use export::*;
@@ -3537,6 +3537,16 @@ pub(crate) struct ProjectAppendGuard<'a> {
_os_lock: File,
}
/// append 锁的等待口径与项目写锁对齐:`agent/runtime_actions/project_gates.rs:1821-1823`
/// 是 5ms × 2000(≈10s 完整窗口)/ 200(≈1s 短窗口),这里曾经是 10ms × 100 ≈ 1s。
const PROJECT_APPEND_LOCK_RETRY_INTERVAL: Duration = Duration::from_millis(5);
const PROJECT_APPEND_LOCK_WAIT_ATTEMPTS: usize = 2_000;
const PROJECT_APPEND_LOCK_SHORT_WAIT_ATTEMPTS: usize = 200;
/// 锁超时文案里的稳定片段:控制流(DirectProject 失败提示、收尾重试判据)只认这个常量,
/// 不各自复制一份中文(改一次文案等于改一次重试语义)。
pub(crate) const PROJECT_APPEND_LOCK_TIMEOUT_MARKER: &str = "跨进程锁超时";
impl ProjectAppendLock {
fn lock_process(&self, error_label: &str) -> Result<std::sync::MutexGuard<'_, ()>, String> {
self.process_lock
@@ -3544,12 +3554,30 @@ impl ProjectAppendLock {
.map_err(|_| format!("获取{error_label}进程内锁失败:锁已损坏"))
}
/// 追加写入口,走完整等待窗口(≈10s)。这条等待的失败会直接变成用户可见的
/// "写历史失败",而持锁方可能正在做一件 O(文件大小) 的事(整份读历史)甚至等 UAC,
/// 1s 的窗口在这种对手面前必然打满。
pub(crate) fn lock(&self, error_label: &str) -> Result<ProjectAppendGuard<'_>, String> {
self.lock_with_attempts(error_label, PROJECT_APPEND_LOCK_WAIT_ATTEMPTS)
}
/// 只读入口,沿用短窗口(≈1s):调用方本来就会重跑(面板刷新、下一轮 prompt 组装),
/// 长时间阻塞只会把它一起拖住。
pub(crate) fn lock_short(&self, error_label: &str) -> Result<ProjectAppendGuard<'_>, String> {
self.lock_with_attempts(error_label, PROJECT_APPEND_LOCK_SHORT_WAIT_ATTEMPTS)
}
fn lock_with_attempts(
&self,
error_label: &str,
max_attempts: usize,
) -> Result<ProjectAppendGuard<'_>, String> {
let process_guard = self
.process_lock
.lock()
.map_err(|_| format!("获取{error_label}进程内锁失败:锁已损坏"))?;
let os_lock = acquire_project_append_os_lock(&self.os_lock_path, error_label)?;
let os_lock =
acquire_project_append_os_lock(&self.os_lock_path, error_label, max_attempts)?;
Ok(ProjectAppendGuard {
_process_guard: process_guard,
_os_lock: os_lock,
@@ -3606,21 +3634,111 @@ fn project_append_os_lock_path(path: &Path) -> Result<PathBuf, String> {
.join(format!("{}.lock", &fingerprint[..32])))
}
fn acquire_project_append_os_lock(path: &Path, error_label: &str) -> Result<File, String> {
fn acquire_project_append_os_lock(
path: &Path,
error_label: &str,
max_attempts: usize,
) -> Result<File, String> {
if let Some(parent) = path.parent() {
ensure_game_creator_private_directory_tree(parent, error_label)?;
prepare_game_creator_private_path_for_read(parent, true, error_label)?;
}
prepare_game_creator_private_path_for_read(path, false, error_label)?;
for attempt in 0..100 {
if let Some(file) = try_open_project_append_os_lock(path, error_label)? {
let max_attempts = max_attempts.max(1);
for attempt in 0..max_attempts {
if let Some(mut file) = try_open_project_append_os_lock(path, error_label)? {
refresh_project_append_lock_diagnostic(&mut file, error_label);
return Ok(file);
}
if attempt < 99 {
thread::sleep(Duration::from_millis(10));
if attempt + 1 < max_attempts {
thread::sleep(PROJECT_APPEND_LOCK_RETRY_INTERVAL);
}
}
Err(format!("获取{error_label}跨进程锁超时:{}", path.display()))
Err(format!(
"获取{error_label}{PROJECT_APPEND_LOCK_TIMEOUT_MARKER}{}{}",
path.display(),
project_append_lock_holder_diagnostic(path)
))
}
/// 超时时报出"谁在持锁",只读锁文件里的诊断元数据。
///
/// 它**只用于报错文案**:不判活、不回收、不抢占。读不到就明说读不到——现场最怕的是
/// 一句"检查运行时配置",那既不是现象也不是动作。
fn project_append_lock_holder_diagnostic(path: &Path) -> String {
let Ok(content) = fs::read_to_string(path) else {
return "持锁方身份不可读:锁文件正被独占持有或已不可读".to_string();
};
let Ok(record) = serde_json::from_str::<serde_json::Value>(&content) else {
return "持锁方身份不可读:锁文件里没有可解析的诊断元数据".to_string();
};
let pid = record.get("pid").and_then(serde_json::Value::as_u64);
let label = record.get("label").and_then(serde_json::Value::as_str);
let started_at = record
.get("processStartedAt")
.and_then(serde_json::Value::as_u64);
match (pid, label, started_at) {
(Some(pid), Some(label), Some(started_at)) => {
format!("持锁方 pid={pid}(进程启动于 {started_at},取锁用途 {label}")
}
(Some(pid), Some(label), None) => format!("持锁方 pid={pid}(取锁用途 {label}"),
(Some(pid), None, _) => format!("持锁方 pid={pid}"),
(None, ..) => "持锁方身份不可读:锁文件里没有 pid".to_string(),
}
}
/// 把"谁在持这把锁"写进锁文件,供事后排障;同一进程重复取同一把锁时不重复写。
///
/// 所有权是句柄本身,不靠文件内容成立:这里写失败绝不影响取锁结果。诊断元数据也不参与
/// 任何判活/回收/抢占判断(既有口径见 docs/project-memory/shared-memory/decision-log.md
/// 2026-09-09「项目写锁残留回收与启动诊断」)。
fn refresh_project_append_lock_diagnostic(file: &mut File, error_label: &str) {
let pid = std::process::id();
let mut current = String::new();
if file.seek(SeekFrom::Start(0)).is_ok()
&& std::io::Read::read_to_string(file, &mut current).is_ok()
&& serde_json::from_str::<serde_json::Value>(&current)
.ok()
.and_then(|value| value.get("pid").and_then(serde_json::Value::as_u64))
== Some(u64::from(pid))
{
return;
}
let Ok(serialized) = serde_json::to_string(&serde_json::json!({
"acquiredAt": unix_timestamp(),
"label": error_label,
"pid": pid,
"processStartedAt": crate::project::project_write_lock_process_start_time_seconds(
u64::from(pid)
),
})) else {
return;
};
if file.set_len(0).is_err() || file.seek(SeekFrom::Start(0)).is_err() {
return;
}
let _ = file.write_all(serialized.as_bytes());
let _ = file.flush();
}
/// 测试专用探针:判断某个追加写目标此刻是否被别人持有 OS 锁。
///
/// 用与生产完全同一套打开方式(Windows 零共享句柄 / Unix `flock(LOCK_EX|LOCK_NB)`)去探同一
/// 把锁文件:拿得到 ⇒ 没人持锁;拿不到 ⇒ 有人持锁。锁文件尚未创建同样算"没人持锁"(这里
/// 刻意不创建它,探针不留副作用)。探针拿到的句柄立刻释放。
#[cfg(test)]
pub(crate) fn project_append_os_lock_is_held_for_test(path: &Path) -> Result<bool, String> {
let lock_path = project_append_os_lock_path(path)?;
if !lock_path.exists() {
return Ok(false);
}
match try_open_project_append_os_lock(&lock_path, "追加写锁探针")? {
Some(file) => {
drop(file);
Ok(false)
}
None => Ok(true),
}
}
#[cfg(unix)]
@@ -3694,6 +3812,22 @@ fn try_open_project_append_os_lock(path: &Path, error_label: &str) -> Result<Opt
}
}
/// 释放锁句柄之后才执行可能弹 UAC 的 ACL 提权修复。
///
/// `held_lock` 按值接收刚打开(因而正被持有)的锁句柄:调用点必须把所有权交出来,于是
/// "一边持锁一边等 UAC" 在这个签名下无法被表达。函数体第一件事就是 `drop`,之后才提权;
/// 修复成功后调用方返回"这轮没取到锁",由外层重试循环重新打开,按修复后的 DACL 通过严格校验。
#[cfg(windows)]
fn release_project_append_os_lock_then_repair_acl(
held_lock: File,
path: &Path,
strict_error: &str,
) -> Result<(), String> {
drop(held_lock);
crate::secure_windows_game_creator_path_for_current_user_with_auto_elevation(path, false, true)
.map_err(|repair_error| format!("{strict_error};自动提权修复未完成:{repair_error}"))
}
#[cfg(windows)]
fn try_open_project_append_os_lock(path: &Path, error_label: &str) -> Result<Option<File>, String> {
use std::os::windows::fs::OpenOptionsExt;
@@ -3713,15 +3847,23 @@ fn try_open_project_append_os_lock(path: &Path, error_label: &str) -> Result<Opt
Ok(file) => {
crate::runner::validate_windows_regular_file_handle(&file, error_label)?;
if !existed {
// 自建对象的本地收紧不会触发 UAC(既有口径:本进程新建的 Windows 私有对象
// 不因继承 DACL 自动提权),所以它留在持锁期间;**提权修复**不留在这里。
if let Err(error) = harden_new_game_creator_private_path(path, false, error_label) {
drop(file);
let _ = fs::remove_file(path);
return Err(error);
}
}
crate::secure_windows_game_creator_path_for_current_user_with_auto_elevation(
path, false, true,
)?;
// 严格校验留在持锁期间(对象就是刚打开的这个句柄),**修复移到锁外**:提权修复会
// `Start-Process -Verb RunAs -Wait` 同步等用户点 UAC,在这里等它等于把"用户犹豫的
// 时间"记进别人的持锁窗口(1s 预算必然打满,而且只有 Windows 有这条路径)。
if let Err(error) =
crate::secure_windows_game_creator_path_for_current_user(path, false, true)
{
release_project_append_os_lock_then_repair_acl(file, path, &error)?;
return Ok(None);
}
crate::runner::validate_windows_regular_file_handle(&file, error_label)?;
Ok(Some(file))
}
@@ -3088,3 +3088,146 @@ fn agent_db_tail_repair_handles_an_exact_limit_record() {
fs::remove_dir_all(valid_root).ok();
fs::remove_dir_all(invalid_root).ok();
}
/// append 锁的等待预算必须与项目写锁的完整窗口同口径。
///
/// 它曾经是 10ms × 100 ≈ 1s,而持锁方做的可能是"整份读一份 7MB 历史"甚至等 UAC
/// 那个窗口必然打满,打满的后果是用户直接看到"写历史失败"。这条用例把口径钉在常量上,
/// 谁把它改回 1s 都会红。
#[test]
fn append_lock_wait_budget_matches_the_project_write_lock_window() {
assert_eq!(
PROJECT_APPEND_LOCK_RETRY_INTERVAL,
std::time::Duration::from_millis(5)
);
assert_eq!(PROJECT_APPEND_LOCK_WAIT_ATTEMPTS, 2_000);
assert_eq!(PROJECT_APPEND_LOCK_SHORT_WAIT_ATTEMPTS, 200);
assert!(
PROJECT_APPEND_LOCK_RETRY_INTERVAL * 2_000 >= std::time::Duration::from_secs(10),
"append 锁完整窗口必须 >= 项目写锁的 10s"
);
assert!(
PROJECT_APPEND_LOCK_RETRY_INTERVAL * 200 <= std::time::Duration::from_secs(1),
"只读短窗口必须留在 1s 级,避免把面板读路径一起拖住"
);
}
fn append_lock_test_target(test_name: &str) -> (PathBuf, PathBuf) {
let root = unique_agent_db_test_root(test_name);
let target = root.join(".agent/conversations/project.jsonl");
fs::create_dir_all(target.parent().expect("history parent")).expect("history dir");
(root, target)
}
#[cfg(windows)]
fn hold_lock_file_exclusively_for_test(path: &Path) -> File {
use std::os::windows::fs::OpenOptionsExt;
std::fs::OpenOptions::new()
.read(true)
.write(true)
.share_mode(0)
.open(path)
.expect("hold lock file exclusively")
}
#[cfg(unix)]
fn hold_lock_file_exclusively_for_test(path: &Path) -> File {
use std::os::fd::AsRawFd;
let file = std::fs::OpenOptions::new()
.read(true)
.write(true)
.open(path)
.expect("open lock file");
let result = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
assert_eq!(result, 0, "flock lock file");
file
}
/// 锁文件必须留下"谁在持锁"的诊断元数据:零字节锁文件让现场无法回答是谁在持锁,
/// 而"可操作提示"正是靠它才能成立。这里也钉住"只诊断、不抢占"——元数据只被读来报错。
#[test]
fn append_lock_file_records_the_holder_for_postmortem() {
let (root, target) = append_lock_test_target("append-lock-diagnostic");
let lock = project_append_lock_for(&target).expect("resolve lock");
drop(
lock.lock("DirectProject 历史追加写")
.expect("acquire append lock"),
);
let lock_path = project_append_os_lock_path(&target).expect("lock path");
let raw = fs::read_to_string(&lock_path).expect("read lock file");
let record: serde_json::Value =
serde_json::from_str(&raw).expect("lock file carries diagnostic json");
assert_eq!(
record.get("pid").and_then(serde_json::Value::as_u64),
Some(u64::from(std::process::id())),
"{raw}"
);
assert_eq!(
record.get("label").and_then(serde_json::Value::as_str),
Some("DirectProject 历史追加写"),
"{raw}"
);
assert!(record.get("processStartedAt").is_some(), "{raw}");
assert!(record.get("acquiredAt").is_some(), "{raw}");
fs::remove_dir_all(&root).ok();
}
#[test]
fn append_lock_holder_diagnostic_reports_the_recorded_pid() {
let (root, target) = append_lock_test_target("append-lock-holder-diagnostic");
let lock_path = project_append_os_lock_path(&target).expect("lock path");
fs::create_dir_all(lock_path.parent().expect("lock parent")).expect("lock dir");
fs::write(
&lock_path,
"{\"acquiredAt\":1,\"label\":\"测试用途\",\"pid\":4242,\"processStartedAt\":7}",
)
.expect("seed lock file");
let diagnostic = project_append_lock_holder_diagnostic(&lock_path);
assert!(diagnostic.contains("pid=4242"), "{diagnostic}");
assert!(diagnostic.contains('7'), "{diagnostic}");
assert!(diagnostic.contains("测试用途"), "{diagnostic}");
fs::remove_file(&lock_path).ok();
let missing = project_append_lock_holder_diagnostic(&lock_path);
assert!(missing.contains("身份不可读"), "{missing}");
fs::remove_dir_all(&root).ok();
}
/// 真的被别人独占持有时:超时文案必须报出锁路径与持锁方线索,而且**不留残留**——
/// 释放后立刻要能重新取到(这把锁的所有权是句柄,所以本来就不该有 stale 回收)。
#[test]
fn append_lock_short_wait_times_out_with_the_holder_and_leaks_nothing() {
let (root, target) = append_lock_test_target("append-lock-timeout");
let lock = project_append_lock_for(&target).expect("resolve lock");
// 先让生产代码创建并收紧锁文件(本地 harden 只在首次创建时发生)。
drop(
lock.lock_short("DirectProject 历史追加写")
.expect("first acquire"),
);
let lock_path = project_append_os_lock_path(&target).expect("lock path");
let held = hold_lock_file_exclusively_for_test(&lock_path);
let error = match lock.lock_short("DirectProject 历史追加写") {
Ok(_guard) => panic!("exclusively held lock must not be acquired"),
Err(error) => error,
};
assert!(
error.contains(PROJECT_APPEND_LOCK_TIMEOUT_MARKER),
"{error}"
);
assert!(
error.contains("持锁方"),
"超时文案必须回答是谁在持锁:{error}"
);
drop(held);
drop(
lock.lock_short("DirectProject 历史追加写")
.expect("reacquire after release"),
);
fs::remove_dir_all(&root).ok();
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,249 @@
use super::*;
use std::fs::File;
use std::io::{Read, Write};
/// 显式保存:把项目内已登记的素材文件复制到用户选定的目标路径。
///
/// 这里刻意不做"浏览器下载"——AGC 是 Tauri/WebView2 宿主,未注册 `on_download`
/// 时 `<a download>` 能否落盘不可靠,所以保存路径由原生对话框给出,复制由 Rust 完成。
const ASSET_EXPORT_COPY_CHUNK_BYTES: usize = 64 * 1024;
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub(crate) struct SaveLocalProjectAssetFileInput {
pub(crate) project_path: String,
pub(crate) relative_path: String,
pub(crate) destination_path: String,
}
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct SaveLocalProjectAssetFileResult {
pub(crate) destination_path: String,
pub(crate) byte_len: u64,
}
fn resolve_export_source_file(root: &Path, relative_path: &str) -> Result<PathBuf, String> {
let normalized = normalize_relative_path(relative_path.trim())?;
if normalized.is_empty() {
return Err("待保存的素材路径不能为空".to_string());
}
let source = root.join(&normalized);
// 只允许保存项目根内真实存在的普通文件:符号链接与目录都要拒绝。
let metadata = std::fs::symlink_metadata(&source)
.map_err(|error| format!("素材文件不存在或不可读:{error}"))?;
if metadata.file_type().is_symlink() {
return Err("素材路径不能是符号链接".to_string());
}
if !metadata.is_file() {
return Err("素材路径必须是普通文件".to_string());
}
Ok(source)
}
fn resolve_export_destination_file(destination_path: &str) -> Result<PathBuf, String> {
let trimmed = destination_path.trim();
if trimmed.is_empty() {
return Err("保存目标路径不能为空".to_string());
}
let destination = PathBuf::from(trimmed);
if destination.file_name().is_none() {
return Err("保存目标必须是文件路径".to_string());
}
let parent = destination
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.ok_or_else(|| "保存目标缺少父目录".to_string())?;
if !parent.is_dir() {
return Err("保存目标的父目录不存在".to_string());
}
if destination.is_dir() {
return Err("保存目标不能是目录".to_string());
}
Ok(destination)
}
pub(crate) fn save_local_project_asset_file_at(
input: SaveLocalProjectAssetFileInput,
) -> Result<SaveLocalProjectAssetFileResult, String> {
let root = Path::new(input.project_path.trim());
validate_project_root(root)?;
enforce_project_auto_permission_policy(root, "file.read")?;
let source = resolve_export_source_file(root, &input.relative_path)?;
let destination = resolve_export_destination_file(&input.destination_path)?;
let mut reader = File::open(&source).map_err(|error| format!("打开素材文件失败:{error}"))?;
let mut writer =
File::create(&destination).map_err(|error| format!("创建保存目标失败:{error}"))?;
let mut buffer = vec![0_u8; ASSET_EXPORT_COPY_CHUNK_BYTES];
let mut byte_len = 0_u64;
loop {
let read = reader
.read(&mut buffer)
.map_err(|error| format!("读取素材文件失败:{error}"))?;
if read == 0 {
break;
}
writer
.write_all(&buffer[..read])
.map_err(|error| format!("写入保存目标失败:{error}"))?;
byte_len += read as u64;
}
writer
.flush()
.map_err(|error| format!("刷新保存目标失败:{error}"))?;
Ok(SaveLocalProjectAssetFileResult {
destination_path: destination.to_string_lossy().into_owned(),
byte_len,
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU64, Ordering};
static NEXT_ASSET_EXPORT_TEST_ID: AtomicU64 = AtomicU64::new(0);
fn unique_asset_export_directory(prefix: &str) -> PathBuf {
std::env::temp_dir().join(format!(
"genarrative-asset-export-{prefix}-{}-{}",
std::process::id(),
NEXT_ASSET_EXPORT_TEST_ID.fetch_add(1, Ordering::Relaxed)
))
}
fn create_export_project() -> (PathBuf, PathBuf) {
let root = unique_asset_export_directory("project");
let assets = root.join("assets");
std::fs::create_dir_all(&assets).expect("create project assets directory");
(root, assets)
}
#[test]
fn copies_registered_asset_to_chosen_destination() {
let (root, assets) = create_export_project();
let source_bytes = b"genarrative-asset-export-payload".to_vec();
std::fs::write(assets.join("hero.png"), &source_bytes).expect("write source asset");
let destination_directory = unique_asset_export_directory("destination");
std::fs::create_dir_all(&destination_directory).expect("create destination directory");
let destination = destination_directory.join("hero-copy.png");
let result = save_local_project_asset_file_at(SaveLocalProjectAssetFileInput {
project_path: root.to_string_lossy().into_owned(),
relative_path: "assets/hero.png".to_string(),
destination_path: destination.to_string_lossy().into_owned(),
})
.expect("save asset file");
assert_eq!(result.byte_len, source_bytes.len() as u64);
assert_eq!(result.destination_path, destination.to_string_lossy());
assert_eq!(
std::fs::read(&destination).expect("read copied asset"),
source_bytes
);
// 源素材保持不变:保存只是复制,不改动项目内文件。
assert_eq!(
std::fs::read(assets.join("hero.png")).expect("read source asset"),
source_bytes
);
let _ = std::fs::remove_dir_all(&root);
let _ = std::fs::remove_dir_all(&destination_directory);
}
#[test]
fn rejects_missing_source_asset() {
let (root, _assets) = create_export_project();
let destination_directory = unique_asset_export_directory("destination");
std::fs::create_dir_all(&destination_directory).expect("create destination directory");
let error = save_local_project_asset_file_at(SaveLocalProjectAssetFileInput {
project_path: root.to_string_lossy().into_owned(),
relative_path: "assets/missing.png".to_string(),
destination_path: destination_directory
.join("missing-copy.png")
.to_string_lossy()
.into_owned(),
})
.expect_err("missing source must fail");
assert!(error.contains("素材文件不存在或不可读"), "{error}");
let _ = std::fs::remove_dir_all(&root);
let _ = std::fs::remove_dir_all(&destination_directory);
}
#[test]
fn rejects_directory_as_source_asset() {
let (root, assets) = create_export_project();
std::fs::create_dir_all(assets.join("nested")).expect("create nested directory");
let destination_directory = unique_asset_export_directory("destination");
std::fs::create_dir_all(&destination_directory).expect("create destination directory");
let error = save_local_project_asset_file_at(SaveLocalProjectAssetFileInput {
project_path: root.to_string_lossy().into_owned(),
relative_path: "assets/nested".to_string(),
destination_path: destination_directory
.join("nested-copy")
.to_string_lossy()
.into_owned(),
})
.expect_err("directory source must fail");
assert!(error.contains("必须是普通文件"), "{error}");
let _ = std::fs::remove_dir_all(&root);
let _ = std::fs::remove_dir_all(&destination_directory);
}
#[test]
fn rejects_blank_destination_and_missing_parent_directory() {
let (root, assets) = create_export_project();
std::fs::write(assets.join("hero.png"), b"payload").expect("write source asset");
let blank = save_local_project_asset_file_at(SaveLocalProjectAssetFileInput {
project_path: root.to_string_lossy().into_owned(),
relative_path: "assets/hero.png".to_string(),
destination_path: " ".to_string(),
})
.expect_err("blank destination must fail");
assert!(blank.contains("保存目标路径不能为空"), "{blank}");
let missing_parent_directory = unique_asset_export_directory("absent");
let missing_parent = save_local_project_asset_file_at(SaveLocalProjectAssetFileInput {
project_path: root.to_string_lossy().into_owned(),
relative_path: "assets/hero.png".to_string(),
destination_path: missing_parent_directory
.join("hero-copy.png")
.to_string_lossy()
.into_owned(),
})
.expect_err("missing parent directory must fail");
assert!(
missing_parent.contains("保存目标的父目录不存在"),
"{missing_parent}"
);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn rejects_destination_that_is_an_existing_directory() {
let (root, assets) = create_export_project();
std::fs::write(assets.join("hero.png"), b"payload").expect("write source asset");
let destination_directory = unique_asset_export_directory("destination");
std::fs::create_dir_all(&destination_directory).expect("create destination directory");
let error = save_local_project_asset_file_at(SaveLocalProjectAssetFileInput {
project_path: root.to_string_lossy().into_owned(),
relative_path: "assets/hero.png".to_string(),
destination_path: destination_directory.to_string_lossy().into_owned(),
})
.expect_err("directory destination must fail");
assert!(error.contains("保存目标不能是目录"), "{error}");
let _ = std::fs::remove_dir_all(&root);
let _ = std::fs::remove_dir_all(&destination_directory);
}
}

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