Compare commits

..

7 Commits

Author SHA1 Message Date
suzmii 1a507dbf2d Merge remote-tracking branch 'origin/master' into feat/agc-canvas-ux-followups
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Failing after 6m50s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Failing after 5m29s
Project CI / AI game creator shell Rust crates (pull_request) Failing after 41s
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Failing after 10m19s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Failing after 5m17s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m2s
Project CI / Repository checks (pull_request) Successful in 4m57s
Project CI / Frontend tests (pull_request) Successful in 6m13s
Project CI / Backend tests (pull_request) Successful in 7m40s
Project CI / Native shell tests (pull_request) Successful in 8m27s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m57s
2026-09-21 06:21:38 +08:00
suzmii 5467ed6d7b 自查修正:拖到对话落点逐值比较、替换面板锚进画布容器并与任务面板互斥
- 拖动落点改逐值比较 sameResourceCardReferenceDrop(条数 + 对话栏矩形四值),指针在对话栏上移动不再每帧 setState、不再每帧重渲染工作台。
- 替换面板从 stage 顶层移进画布容器 .game-resource-book-manager(本身 position: relative),top 3.2rem 排在任务开关下方;相对 stage 写死的偏移会在工具条换行时压上去,与刚修的任务开关是同一类隐患。
- 打开替换会话时收起「生成任务」面板:画布右上角同一时刻只留一块浮层(任务照旧在账本里推进)。
- 面板上方补一行「替换源素材:<显示名>」:非模态之后画布上的源素材光环会被空白点击清掉,源身份得在面板这一侧留一份可读说法。
- 测试:替换用例新增「面板锚在画布容器里、写明源素材、并与任务面板互斥」(20 passed)、落点模型新增逐值比较用例(6 passed)。
- 文档:decision-log 新增本批自查条目、待办文档补登记、验收用例 C9 注脚改为 nonModal 形态。
2026-09-21 06:21:19 +08:00
suzmii 7148ff9eaf 验收修正:任务锚点改挂画布那一格,展开后开关让位
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Failing after 7m22s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Failing after 7m32s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Failing after 7m46s
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Failing after 7m50s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m10s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 3m8s
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
- 重叠:右上角锚点不再用 position: absolute + 写死的 top(工具条是 stage 第一行、高度随按钮换行变化),改为 stage 网格里与工作面同一个单元格的条目:grid-row 按 placement 分档 + grid-column 1 + justify-self end + align-self start + margin;同格的三处容器也显式钉住第 1 列,避免画布被自动列放置挤进隐式第二列。
- 并排:开关只在完全收起时渲染(收起动画期间也不画),展开态画布右上角只留面板,收起走面板头部 × 或点画布外部。
- 测试:面板用例新增「展开时开关让位」、计数用例拆成收起查开关 / 展开查面板;样式用例改判网格条目坐标与窄屏 justify-self: stretch;dismiss 用例改走面板 × 收起、收起后开关回来能再打开。
- 文档:PRD 3.10 与更新时间、decision-log 新增 2026-09-21 验收修正条目、待办文档补登记该修正。
2026-09-21 04:33:26 +08:00
suzmii b20d980ba7 替换点选判据的注释口径跟上新流程
- resolveResourceReplacementPick 的注释里「点选态」「点选替换」改为「画布点选」「面板里的当前替换目标」,与退役掉的按钮 / 提示条口径一致;只改注释,行为不变。
2026-09-21 03:53:07 +08:00
suzmii de502e9bc3 替换面板改为非模态浮层,支持在画布上点选替换目标
- 共享选择组件新增 opt-in nonModal:不铺遮罩、不做焦点陷阱、限高 + 内部滚动,Esc 在 document 阶段截断后取消,关掉即卸载;网页端美术画布不传,弹窗行为逐字不变。
- 新增 initialSelectionRevision:面板开着时宿主换了目标才重同步当前选择,面板里的搜索词与分类筛选不被重置。
- AGC 侧把候选面板锚到画布右上角(任务开关下方),壳样式放共享样式表、宿主只负责锚定;面板开着时画布照常可点,点中合法候选落成面板当前选择,写入仍只由「确认」发起。
- 退役旧的「点选替换」按钮、画布提示条与 resourceReplacementPickMode:同一个功能不留两条 UI 路径;合法性判据仍复用 resolveResourceReplacementPick,写入仍是同一个 confirm 函数。
- 确认时用画布点选结果兜底,避免点完当帧按确认误报「请选择一个替换素材」。
- 测试:替换用例 4 条点选路径改写为非模态 + 面板点选(含四类非法目标在面板内报因、Esc 只收面板),新增「画布点选不清掉面板搜索/分类」;共享组件用例新增 nonModal 形态与浮层壳声明级契约。
- 文档:PRD 5.3 / 7.8 第 8 条改写、验收用例 S15a 改写、decision-log 新增 2026-09-21 条目、待办文档登记三项并勾掉对应需求。
2026-09-21 03:48:40 +08:00
suzmii 9639a080c6 资源卡拖到对话栏实现批量 @ 引用
- 新增落点模型 resourceCardReferenceDropModel:对话栏矩形量取(0 x 0 判成没有落点,避免画布内拖动被误判)、命中判据、引用构造与落点提示文案。
- 引用构造与工具条「引用」按钮逐字一致(只认已登记 manifest 素材、source 为 resource-card、kind/分类/标签取自资源投影),一条都构造不出来时用提示条说明原因。
- 拖动中指针落到对话栏上即从排版切到引用:卡片不再跟指针走,改铺虚线落点浮层并提示「松手即可 @ 引用 N 项素材」;拖回画布内松手仍是原来的排版语义。
- 批量范围与拖动位移同一个集合(多选整批、单选只引用按住的那张);新增批量事件 RESOURCE_REFERENCE_INSERT_MANY_EVENT,App 侧一次插入整批并只聚焦一次,空批次不派发。
- 对话栏挂 ref(不 document.querySelector)、落点浮层按对话栏当时的屏幕矩形铺在根节点上,不改对话栏自身的定位与排版。
- 测试:新增落点模型用例 5 条与画布集成用例 4 条(单卡/多选/拖回画布/pointercancel),resourceReferenceInput 新增批量事件与一次插入整批 chip 两条,共 39 条定向全绿。
- 文档:功能说明新增「拖拽引用」一节与完成清单条目、PRD 更新时间、decision-log 新增 2026-09-21 条目。
2026-09-21 03:37:43 +08:00
suzmii d56b5ff15a AGC「生成任务」侧栏移到画布右上角:开关常驻、面板贴着它展开
- 新增右上角锚点 game-resource-generation-tasks-anchor,挂在 game-workbench-stage 内;资源栏目画布与 UI 编辑器用画布顶边那一档,运行表现层下移让开右上角的版本入口。
- 开关改由 ResourceCanvasAssetGenerationTasksPanelView 自己渲染(保留 data-resource-generation-task-toggle 与计数属性、可访问名唯一),收起态只剩这一枚开关;工具条上两处重复入口与宿主那份 resourceAssetGenerationInFlightCount 一并删除。
- 面板自己不再定位,改由 max-height 封顶 + 内部滚动;进场 / 退场动画位移方向跟着锚点翻到正 X。
- 开关沿用原工具条那枚次级胶囊外观,颜色 / 圆角 / 字重 / 阴影继续全部走平台 token。
- 测试:面板用例新增「折叠只剩右上角开关」「开关计数与面板头部同源」「锚点按工作面分档」;样式用例改为锚点坐标 / 运行档下移 / 面板不再自定位 / 开关走 token;dismiss 用例改为右上角入口且工具条不再有生成任务按钮。
- 文档:PRD 3.10 与更新时间、decision-log 新增 2026-09-21 条目、画布验收待办登记第四批并勾掉该条。
2026-09-21 03:25:53 +08:00
27 changed files with 2349 additions and 708 deletions
+27 -1
View File
@@ -283,7 +283,9 @@ import type {
import {
chatComposerDraftToDirectCodexUserItem,
RESOURCE_REFERENCE_INSERT_EVENT,
RESOURCE_REFERENCE_INSERT_MANY_EVENT,
type ResourceReferenceInsertEventDetail,
type ResourceReferenceInsertManyEventDetail,
} from './features/project-workspace/resourceReferences';
import { SupervisorChatOnlyView } from './features/project-workspace/SupervisorChatOnlyView';
import { RuntimeConfigDialog } from './features/runtime-config/RuntimeConfigDialog';
@@ -3633,15 +3635,39 @@ export function App({
chatComposerRef.current?.insertReferences([detail.reference]);
chatComposerRef.current?.focus();
};
/**
* @
*
* 稿
*
*/
const handleResourceReferenceInsertMany = (event: Event) => {
const detail = (
event as CustomEvent<ResourceReferenceInsertManyEventDetail>
).detail;
const references = detail?.references ?? [];
if (references.length === 0) return;
chatComposerRef.current?.insertReferences(references);
chatComposerRef.current?.focus();
};
window.addEventListener(
RESOURCE_REFERENCE_INSERT_EVENT,
handleResourceReferenceInsert,
);
return () =>
window.addEventListener(
RESOURCE_REFERENCE_INSERT_MANY_EVENT,
handleResourceReferenceInsertMany,
);
return () => {
window.removeEventListener(
RESOURCE_REFERENCE_INSERT_EVENT,
handleResourceReferenceInsert,
);
window.removeEventListener(
RESOURCE_REFERENCE_INSERT_MANY_EVENT,
handleResourceReferenceInsertMany,
);
};
}, []);
function prepareProjectAssetRegisterDraft(localPath: string) {
@@ -74,6 +74,31 @@ export function dispatchResourceReferenceInsert(reference: ChatReference) {
);
}
/**
* 一次插入多条引用的事件(画布拖拽批量 @ 引用用)。
*
* 与单条事件同一条链路、同一个消费点,只是把「N 条」合成一次派发:逐条派发会让草稿重建
* N 次、聚焦 N 次,而且插入顺序只能靠事件顺序兜着。批次为空时什么都不派发(不开一次空事务)。
*/
export const RESOURCE_REFERENCE_INSERT_MANY_EVENT =
'agc-resource-reference-insert-many';
export type ResourceReferenceInsertManyEventDetail = {
references: ChatReference[];
};
export function dispatchResourceReferenceInsertMany(
references: ChatReference[],
) {
if (typeof window === 'undefined' || references.length === 0) return;
window.dispatchEvent(
new CustomEvent<ResourceReferenceInsertManyEventDetail>(
RESOURCE_REFERENCE_INSERT_MANY_EVENT,
{ detail: { references } },
),
);
}
/**
* `@` 输入区 portal 到 `document.body` 的两块浮层:资源选择器与输入区候选菜单。
*
@@ -43,6 +43,13 @@ export type ResourceCanvasAssetGenerationTasksPanelViewProps = {
onToggleOpen: () => void;
/** 定位到该任务产出的素材卡(宿主复用既有 `pendingResourceFocusRef` 聚焦链)。 */
onFocusTask: (task: ResourceCanvasAssetGenerationTask) => void;
/**
* 锚点落在哪个工作面:资源栏目画布 / 运行表现层 / UI 编辑器。
*
* 三处右上角的既有 chrome 不同(运行表现层右上角是版本入口、UI 编辑器是整页编辑器壳),
* 所以坐标由这一档在样式里分档给出;缺省按资源栏目画布算。
*/
placement?: 'canvas' | 'run' | 'editor';
};
/**
@@ -222,14 +229,16 @@ function resourceCanvasGenerationTaskRowNode(
/**
* 画布上的「生成任务」侧栏(常驻、可折叠、非模态)。
*
* 形态对齐网页端美术画布的任务侧栏:贴边的独立 `<aside>`,展开时是一列任务(画布照常可交互),
* 折叠时只留一个可点的贴边把手(带在途数量角标)。它**不是**模态浮层:不铺全屏遮罩、不做焦点
* 陷阱、不进 `isResourceCanvasFloatingPanelOpen` / `resourceCanvasHostGenerationPanelOpen` 的
* 遮挡判据;折叠也不影响任务推进——任务活在账本与本地队列里,与这个视图的生命周期无关。
* 形态对齐网页端美术画布的任务侧栏:**画布右上角**的一枚独立开关 + 展开时贴着它的浮动面板
* ——收起态只有那一枚开关,展开态只有面板(开关让位,不并排;面板自己带 × 收起),
* 面板是一列任务(画布照常可交互)。它**不是**模态浮层:
* 不铺全屏遮罩、不做焦点陷阱、不进 `isResourceCanvasFloatingPanelOpen` /
* `resourceCanvasHostGenerationPanelOpen` 的遮挡判据;折叠也不影响任务推进——任务活在账本与
* 本地队列里,与这个视图的生命周期无关。
*
* 位置取舍:AGC 栏目画布的右侧是「智能创作」对话面板、底部是栏目工具栏、顶部是栏目标题栏,所以
* 侧栏挂在**左侧、标题栏之下、工具栏之上**,并且是**覆盖式**而不是把画布挤窄(画布的视口数学与
* 资源卡排布都不被 reflow 动到,收起即完全让出画布)。宽度 300px 落在 280340px 区间内。
* 位置取舍:右上角是画布上唯一「既不被对话面板占、也不和底部工具栏 / 右下角缩放 Dock 打架」的
* 稳定空位,与美术画布同一处;锚点是**覆盖式**而不是把画布挤窄(画布的视口数学与资源卡排布都
* 不被 reflow 动到,收起即只留那一枚开关)。宽度 300px 落在 280340px 区间内。
*
* 分栏、条数、封顶、折叠语义都在这里;颜色与排版在同目录的 CSS 文件里,取值全部走
* `--platform-*` 设计 token。
@@ -240,6 +249,7 @@ export function ResourceCanvasAssetGenerationTasksPanelView({
open,
onToggleOpen,
onFocusTask,
placement = 'canvas',
}: ResourceCanvasAssetGenerationTasksPanelViewProps) {
const [nowMillis, setNowMillis] = useState(() => Date.now());
/**
@@ -334,115 +344,141 @@ export function ResourceCanvasAssetGenerationTasksPanelView({
return () => window.clearTimeout(timer);
}, [open]);
// 折叠态不再在画布左侧留贴边把手:开合口只剩工具条上那一枚「生成任务」按钮
// (带在途计数),收起时侧栏完全让出画布(播完收起动画之后)。
if (!open && phase === 'idle') {
return null;
}
/**
* 右上角开关只在**完全收起**时渲染:展开态把开关藏起来(验收口径「开关与面板不该并排,
* 详情出来以后入口就该隐藏」——收起改走面板自己的 × 与点外部),收起动画期间也不画,
* 否则那 160ms 里两者又会同框。
*
* 在途计数只在这里算一次,开关与面板头部共用同一个值,两处不会各算一套。
*/
const showToggle = !open && phase === 'idle';
const toggle = (
<button
type="button"
className="game-resource-generation-tasks-toggle"
aria-label="生成任务"
aria-expanded={open}
data-resource-generation-task-toggle=""
data-resource-generation-task-count={inFlightCount}
onClick={onToggleOpen}
>
<ListChecks size={15} aria-hidden="true" />
{inFlightCount > 0 ? `生成任务 · ${inFlightCount}` : '生成任务'}
</button>
);
return (
<aside
className={`platform-theme platform-theme--light game-resource-generation-tasks-sidebar${
open ? '' : ' is-leaving'
}`}
role="region"
aria-label="生成任务"
data-resource-generation-task-count={rendered.inFlightCount}
<div
className="platform-theme platform-theme--light game-resource-generation-tasks-anchor"
data-generation-tasks-placement={placement}
>
<header className="game-resource-generation-tasks-sidebar-header">
<h2 className="game-resource-generation-tasks-sidebar-title">
<ListChecks size={15} aria-hidden="true" />
<span
className="game-resource-generation-tasks-sidebar-count"
aria-label={`在途生成任务 ${rendered.inFlightCount}`}
>
{rendered.inFlightCount}
</span>
</h2>
<button
type="button"
className="game-resource-generation-tasks-sidebar-icon-button"
aria-label="关闭生成任务"
onClick={onToggleOpen}
{showToggle ? toggle : null}
{/* 收起要先把退场动画播完(`is-leaving`)再卸载,所以 `phase` 未回到 idle 时面板还在。 */}
{open || phase !== 'idle' ? (
<aside
className={`game-resource-generation-tasks-sidebar${
open ? '' : ' is-leaving'
}`}
role="region"
aria-label="生成任务"
data-resource-generation-task-count={rendered.inFlightCount}
>
<X size={16} aria-hidden="true" />
</button>
</header>
<div
className="game-resource-generation-tasks-scroll"
data-resource-generation-task-scroll=""
>
{rendered.ordered.length === 0 ? (
<p className="game-resource-generation-tasks-empty" role="status">
</p>
) : (
<>
<section
className="game-resource-generation-tasks-section"
aria-label="排队与生成中"
<header className="game-resource-generation-tasks-sidebar-header">
<h2 className="game-resource-generation-tasks-sidebar-title">
<ListChecks size={15} aria-hidden="true" />
<span
className="game-resource-generation-tasks-sidebar-count"
aria-label={`在途生成任务 ${rendered.inFlightCount}`}
>
{rendered.inFlightCount}
</span>
</h2>
<button
type="button"
className="game-resource-generation-tasks-sidebar-icon-button"
aria-label="关闭生成任务"
onClick={onToggleOpen}
>
<h3 className="game-resource-generation-tasks-section-title">
<span>/</span>
<span className="game-resource-generation-tasks-section-title-count">
{rendered.active.length}
</span>
</h3>
{rendered.active.length === 0 ? (
<p className="game-resource-generation-tasks-empty">
</p>
) : (
<ul className="game-resource-generation-tasks-list">
{rendered.active.map((row) =>
resourceCanvasGenerationTaskRowNode(
row,
nowMillis,
onFocusTask,
),
)}
</ul>
)}
</section>
<section
className="game-resource-generation-tasks-section"
aria-label="已完成"
>
<h3 className="game-resource-generation-tasks-section-title">
<span></span>
<span className="game-resource-generation-tasks-section-title-count">
{rendered.done.length}
</span>
</h3>
{rendered.done.length === 0 ? (
<p className="game-resource-generation-tasks-empty">
</p>
) : (
<>
<ul className="game-resource-generation-tasks-list">
{rendered.visibleDone.map((row) =>
resourceCanvasGenerationTaskRowNode(
row,
nowMillis,
onFocusTask,
),
)}
</ul>
{rendered.done.length > rendered.visibleDone.length ? (
<X size={16} aria-hidden="true" />
</button>
</header>
<div
className="game-resource-generation-tasks-scroll"
data-resource-generation-task-scroll=""
>
{rendered.ordered.length === 0 ? (
<p className="game-resource-generation-tasks-empty" role="status">
</p>
) : (
<>
<section
className="game-resource-generation-tasks-section"
aria-label="排队与生成中"
>
<h3 className="game-resource-generation-tasks-section-title">
<span>/</span>
<span className="game-resource-generation-tasks-section-title-count">
{rendered.active.length}
</span>
</h3>
{rendered.active.length === 0 ? (
<p className="game-resource-generation-tasks-empty">
{`仅显示最近 ${RESOURCE_CANVAS_ASSET_GENERATION_DONE_SECTION_LIMIT} 条,另有 ${
rendered.done.length - rendered.visibleDone.length
} 条较早记录`}
</p>
) : null}
</>
)}
</section>
</>
)}
</div>
</aside>
) : (
<ul className="game-resource-generation-tasks-list">
{rendered.active.map((row) =>
resourceCanvasGenerationTaskRowNode(
row,
nowMillis,
onFocusTask,
),
)}
</ul>
)}
</section>
<section
className="game-resource-generation-tasks-section"
aria-label="已完成"
>
<h3 className="game-resource-generation-tasks-section-title">
<span></span>
<span className="game-resource-generation-tasks-section-title-count">
{rendered.done.length}
</span>
</h3>
{rendered.done.length === 0 ? (
<p className="game-resource-generation-tasks-empty">
</p>
) : (
<>
<ul className="game-resource-generation-tasks-list">
{rendered.visibleDone.map((row) =>
resourceCanvasGenerationTaskRowNode(
row,
nowMillis,
onFocusTask,
),
)}
</ul>
{rendered.done.length > rendered.visibleDone.length ? (
<p className="game-resource-generation-tasks-empty">
{`仅显示最近 ${RESOURCE_CANVAS_ASSET_GENERATION_DONE_SECTION_LIMIT} 条,另有 ${
rendered.done.length - rendered.visibleDone.length
} 条较早记录`}
</p>
) : null}
</>
)}
</section>
</>
)}
</div>
</aside>
) : null}
</div>
);
}
@@ -1,21 +1,89 @@
/* 「生成任务」侧栏的样式。
*
* 分层与网页端美术画布的任务侧栏(`ImageCanvasTaskSidebarView.tsx` + `src/index.css:6084+`)一一对应:
* 容器 → 头部(标题 + 在途计数)→ 分栏标题(各带条数)→ 条目卡片(状态徽标 / 阶段 / 耗时 / 定位)。
* 差别只有一处:**颜色全部换成 AGC 的平台设计 token`--platform-*`**,不再照抄网页端的固定色值,
* 右上角锚点(收起态是开关、展开态是浮动面板,同一时刻只有其一)→ 头部(标题 + 在途计数)→
* 分栏标题(各带条数)→
* 条目卡片(状态徽标 / 阶段 / 耗时 / 定位)。
*
* 位置与开合形态照抄美术画布:**画布右上角那枚开关是收起态的入口,展开后开关让位、只留面板**
* (两者不同框 —— 初版把它们并排摆过,验收现场直接判为不对)。差别只有一处:
* **颜色与外形全部走 AGC 的平台设计 token`--platform-*`**,不再照抄网页端的固定色值,
* 这样亮/暗主题都跟着 `platform-theme` 走,也不引入第二套色板。
*
* 行为(分栏、条数、已完成封顶、折叠不清列表、提交后自动展开)都不在这里,样式只负责表现。
*/
.game-resource-generation-tasks-sidebar {
position: fixed;
top: 4rem;
bottom: 6rem;
left: 0.75rem;
/*
* 右上角锚点:开关与面板同挂在这里,所以「收起只剩开关、展开只剩面板」这两个状态共用同一处
* 坐标,不会出现开关与面板各锚一处、开合时互相漂移。
*
* 锚点是 stage 网格里**与工作面同一个单元格**的另一个条目(`grid-row` 按 `placement` 分档 +
* `justify-self: end` 贴右 + `align-self: start` 贴顶),**不用 `position: absolute`**
* 绝对定位要写死一个 `top`,而工具条(stage 第一行)的高度会随按钮换行变化 —— 那个写死的
* 偏移量迟早会压在工具条上(验收现场那条「生成任务和菜单栏重叠」就是这么来的)。
* 放进同一个网格单元格以后,「画布顶边 + 一小段内缩」由网格自己保证,工具条多高都不用管。
*
* 分档:资源栏目画布与 UI 编辑器在第二 / 第三行,运行表现层占 `2 / -1` 且它右上角
* `top: 20px; right: 20px`)被版本入口占着,所以那一档把内缩上边距加大到版本入口之下。
*/
.game-resource-generation-tasks-anchor {
grid-row: 3;
grid-column: 1;
justify-self: end;
align-self: start;
margin: 0.85rem;
z-index: 40;
display: flex;
align-items: flex-start;
gap: 0.45rem;
pointer-events: auto;
}
.game-resource-generation-tasks-anchor[data-generation-tasks-placement='run'] {
grid-row: 2 / -1;
margin: 3.5rem 1.1rem 0.85rem 0.85rem;
}
.game-resource-generation-tasks-anchor[data-generation-tasks-placement='editor'] {
grid-row: 2;
}
/*
* 右上角开关:外观沿用原工具条那一枚「生成任务」按钮(次级胶囊:描边 + secondary 填充 +
* 12px/700),搬到画布上多一档面板投影,保证压在画布素材上仍然看得清。
*/
.game-resource-generation-tasks-toggle {
display: inline-flex;
min-height: 30px;
align-items: center;
gap: 5px;
padding: 0 11px;
border: 1px solid var(--platform-surface-border);
border-radius: 999px;
background: var(--platform-button-secondary-fill);
box-shadow: var(--platform-panel-shadow);
color: var(--platform-button-secondary-text);
cursor: pointer;
font-size: 12px;
font-weight: 700;
white-space: nowrap;
}
.game-resource-generation-tasks-toggle:hover {
border-color: var(--platform-accent);
color: var(--platform-text-strong);
}
.game-resource-generation-tasks-toggle:focus-visible {
outline: 2px solid var(--platform-accent);
outline-offset: 2px;
box-shadow: 0 0 0 3px var(--platform-input-focus-ring);
}
.game-resource-generation-tasks-sidebar {
display: flex;
width: min(300px, 80vw);
max-height: min(30rem, calc(100vh - 12rem));
flex-direction: column;
overflow: hidden;
border: 1px solid var(--platform-subpanel-border);
@@ -27,11 +95,12 @@
animation: game-resource-generation-tasks-enter 160ms ease-out;
}
/* 提交后面板会重新挂载,动画只负责「进场」这一下;不做宽度 reflow,避免画布抖动。 */
/* 提交后面板会重新挂载,动画只负责「进场」这一下;不做宽度 reflow,避免画布抖动。
* 进场方向跟着锚点走:面板从右上角往左展开,所以位移取正 X。 */
@keyframes game-resource-generation-tasks-enter {
from {
opacity: 0;
transform: translateX(-0.5rem);
transform: translateX(0.5rem);
}
to {
@@ -54,7 +123,7 @@
to {
opacity: 0;
transform: translateX(-0.5rem);
transform: translateX(0.5rem);
}
}
@@ -356,11 +425,14 @@
/* 窄屏(含 360px):侧栏占满可用宽度,不挡画布操作。 */
@media (max-width: 480px) {
.game-resource-generation-tasks-anchor {
/* 拉满画布那一格:`justify-self: end` 只让锚点贴右,窄屏要的是整宽。 */
justify-self: stretch;
margin: 0.5rem;
}
.game-resource-generation-tasks-sidebar {
top: 3.5rem;
right: 0.5rem;
bottom: 5.5rem;
left: 0.5rem;
max-height: calc(100vh - 12rem);
width: auto;
}
}
@@ -1212,56 +1212,47 @@
}
/*
* 「点选替换」提示条:点选态下贴在资源画布顶端居中浮出
* 「替换素材」面板的锚点:画布右上角、任务开关那一枚的下方
*
* 与右下角缩放控件同一层语义(都在管理区之上,`position: absolute` 锚在
* `.game-resource-book-manager` 上,而不是挂在会随视口缩放 / 平移的场景里),
* 配色沿用工作台提示条那一套(`.game-resource-live-notice`
* 面板本体(`.image-canvas-editor__project-asset-picker--floating`)是**非模态浮层**
* 面板开着的时候画布照常可点,在画布上点目标素材即落进面板的当前选择,写入只由面板「确认」发起。
* 所以这里只负责锚定,壳样式(边框 / 底色 / 阴影 / 头部与滚动区)在共享样式表里,宿主不抄第二份
*
* 它是**会话级**提示:会话期间常驻,不跟随某张卡,所以层级高于场景(20)与缩放(40)
* 场景根是 `pointer-events: none`,这里必须显式收回指针事件,「取消」才点得动。
* 位置取舍:右上角是画布上唯一不被栏目工具栏(左下)与缩放 / 撤销 Dock(右下)占用的稳定空位
* 顶部 0.85rem 起是任务开关,面板从 3.2rem 起排(开关约 30px 高 + 一档间距),两者不重叠 ——
* 而且打开替换面板时会把「生成任务」面板收起来,同一个角只留一块浮层。
*
* 锚在**画布容器**`.game-resource-book-manager`,本身就是 `position: relative`)而不是 stage
* 相对 stage 写死的偏移会在工具条换行变高时压上去(与任务开关同一类问题)。
*/
.game-resource-canvas-pick-hint {
.game-resource-replacement-panel {
position: absolute;
top: 14px;
left: 50%;
z-index: 50;
display: inline-flex;
align-items: center;
gap: 10px;
max-width: min(92vw, 34rem);
padding: 8px 8px 8px 12px;
border: 1px solid #edc7b5;
border-radius: 10px;
background: rgb(255 247 241 / 96%);
box-shadow: 0 6px 18px rgb(112 70 52 / 14%);
color: #8d5b45;
font-size: 11px;
line-height: 1.4;
transform: translateX(-50%);
pointer-events: auto;
top: 3.2rem;
right: 0.85rem;
z-index: 45;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.4rem;
}
.game-resource-canvas-pick-hint-text {
flex: 1 1 auto;
min-width: 0;
}
.game-resource-canvas-pick-hint-error {
flex: 0 1 auto;
min-width: 0;
color: #b3261e;
font-weight: 700;
}
.game-resource-canvas-pick-hint button {
flex: 0 0 auto;
border: 1px solid #dc9b7d;
border-radius: 8px;
background: #fff;
color: #9b5537;
font-size: 11px;
cursor: pointer;
/*
* 源素材那一行小字:面板改成非模态以后,画布上的「源素材」光环会被一次空白点击清掉,
* 而面板里只列候选、不说明在替换谁 —— 源身份得在面板这一侧留一份可读的说法。
*/
.game-resource-replacement-panel-source {
max-width: 100%;
overflow: hidden;
border: 1px solid var(--platform-subpanel-border);
border-radius: 999px;
background: var(--platform-subpanel-fill);
box-shadow: var(--platform-panel-shadow);
color: var(--platform-text-base);
font-size: 0.72rem;
font-weight: 750;
padding: 0.2rem 0.6rem;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 栏目画布底部工具栏:栏目页(view=child)左下角的画布 chrome。
@@ -291,18 +291,18 @@ export function resourceVersionReplacementErrorMessage(error: unknown): string {
return message;
}
/** 画布点选态点到的资源卡,能不能作为这次替换的目标。 */
/** 画布点到的资源卡,能不能作为这次替换的目标。 */
export type ResourceReplacementPickResolution =
| { status: 'replace'; replacementResourceId: string }
| { status: 'rejected'; message: string };
/**
* 「点选替换」:把画布上点到的那张卡解析成写入目标,或给出「为什么不能替换」。
* 画布点选:把画布上点到的那张卡解析成**面板里的当前替换目标**,或给出「为什么不能替换」。
*
* 判据与候选弹窗**同源**,不重算兼容性:目标必须是 manifest 资产、必须在后端给出的权威
* 判据与面板里的候选列表**同源**,不重算兼容性:目标必须是 manifest 资产、必须在后端给出的权威
* 候选里(源素材已被后端排除)、且硬门禁通过(`resourceReplacementBlockedReason`)。
* 拒绝文案也走同一条翻译路径(`resourceVersionReplacementErrorMessage` 认的就是 Rust 那几种
* 错误形状),所以点选与列表两条入口对同一件事永远说同一句话。
* 错误形状),所以画布点选与面板候选列表对同一件事永远说同一句话。
*/
export function resolveResourceReplacementPick({
manifestAssetId,
+119 -23
View File
@@ -1070,7 +1070,11 @@ textarea {
padding: 10px 12px;
border: 1px solid var(--platform-subpanel-border);
border-radius: 12px;
background: color-mix(in srgb, var(--platform-subpanel-fill) 94%, transparent);
background: color-mix(
in srgb,
var(--platform-subpanel-fill) 94%,
transparent
);
box-shadow: 0 8px 24px rgb(31 24 16 / 8%);
}
@@ -5896,6 +5900,9 @@ iframe.preview-frame {
.game-workbench-stage > .game-resource-manager {
grid-row: 3;
/* 显式钉住第 1 右上角任务锚点是同一格里第二个网格条目画布若走自动列放置会被挤到
第二列隐式列画布会被压窄一半 */
grid-column: 1;
min-height: 0;
}
@@ -5904,13 +5911,46 @@ iframe.preview-frame {
box-shadow: var(--platform-nav-active-shadow);
}
/*
* 拖动素材到对话的落点提示按对话栏当时的屏幕矩形铺一层虚线描边 + 一枚说明 chip
*
* 纯只读浮层`pointer-events: none`不接管命中也不改对话栏自身的排版与定位拖动是指针
* 捕获的命中判定本来就在画布那侧算这里只负责把"松手会发生什么"说出来
*/
.game-workbench-chat-reference-drop {
position: fixed;
z-index: 60;
display: grid;
place-items: start center;
padding-top: 1rem;
border: 2px dashed var(--platform-accent);
border-radius: 12px;
background: color-mix(in srgb, var(--platform-accent) 8%, transparent);
pointer-events: none;
}
.game-workbench-chat-reference-drop-chip {
display: inline-flex;
min-height: 30px;
align-items: center;
padding: 0 12px;
border: 1px solid var(--platform-subpanel-border);
border-radius: 999px;
background: var(--platform-subpanel-fill);
box-shadow: var(--platform-panel-shadow);
color: var(--platform-text-strong);
font-size: 12px;
font-weight: 700;
}
.game-workbench-stage[data-resource-view-state='resources.ui-editor'] {
grid-template-rows: auto minmax(0, 1fr);
}
.game-workbench-stage[data-resource-view-state='resources.ui-editor']
> div:last-child {
> .game-workbench-editor-shell {
grid-row: 2;
grid-column: 1;
min-height: 0;
}
@@ -7557,7 +7597,6 @@ iframe.preview-frame {
padding-right: 50px;
}
/* 替换血缘标注本次会话内有效源素材卡已被 替换/ 替换素材卡替换自
卡片底部整条是卡面名称`.game-resource-card-name`所以血缘角标压在名称条之上
右下角仍是媒体播放钮最大宽度按右侧让出播放钮的宽度
@@ -8360,6 +8399,9 @@ iframe.preview-frame {
display: grid;
grid-template-rows: minmax(300px, 1fr) auto;
grid-row: 2 / -1;
/* 与右上角任务锚点同格 resourceCanvasAssetGenerationTasksSidebar.css显式钉第 1
避免画布被自动列放置挤到隐式列里 */
grid-column: 1;
gap: 12px;
height: 100%;
min-height: 0;
@@ -10423,7 +10465,9 @@ button.design-workspace-tree__entry:hover,
overflow: visible;
}
.game-workbench-layout--design .game-workbench-chat .project-supervisor-surface {
.game-workbench-layout--design
.game-workbench-chat
.project-supervisor-surface {
grid-row: 2;
}
@@ -10781,8 +10825,7 @@ button.design-workspace-tree__entry:hover,
这样消息内容 / 顶栏 / 输入盒三者共用同一个基准值不会再出现
消息内缩 16px输入盒只有 10px这种左右不齐或不一致的上/下间距
direct-codex 的面板仍走上面那条 `padding: 10px` */
.game-workbench-chat
.project-supervisor-surface.is-direct-codex {
.game-workbench-chat .project-supervisor-surface.is-direct-codex {
padding: 0;
}
@@ -10921,7 +10964,9 @@ button.design-workspace-tree__entry:hover,
菜单跑到**整个 composer 上方**和触发钮之间隔着整个输入区输入区一变高多行
引用 chipAI 润色菜单与提示就跟着往上飘看起来就是"编辑框把弹层挤开了"
`relative` 不会把它移出控制排`right/bottom: auto` 仍在原位只补回锚点 */
.game-workbench-layout--design .project-supervisor-composer-controls .conversation-model-select,
.game-workbench-layout--design
.project-supervisor-composer-controls
.conversation-model-select,
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-composer.is-direct-codex
@@ -10957,7 +11002,9 @@ button.design-workspace-tree__entry:hover,
/* 控制排三只方钮`+` 附件 / `@` 引用 / 发送共用一套尺寸发送钮单独加圆角与主色
见下面两条规则 */
.game-workbench-layout--design .project-supervisor-composer-controls .project-supervisor-submit-button,
.game-workbench-layout--design
.project-supervisor-composer-controls
.project-supervisor-submit-button,
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-composer.is-direct-codex
@@ -11021,7 +11068,9 @@ button.design-workspace-tree__entry:hover,
}
/* 发送钮是圆形主色块(Codex 观感):直径与左右两只方钮同档,圆角收到 999px。 */
.game-workbench-layout--design .project-supervisor-composer-controls .project-supervisor-submit-button,
.game-workbench-layout--design
.project-supervisor-composer-controls
.project-supervisor-submit-button,
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-composer.is-direct-codex
@@ -12253,7 +12302,8 @@ button.design-workspace-tree__entry:hover,
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-message-list
> * + * {
> *
+ * {
margin-top: 14px;
}
@@ -12310,14 +12360,16 @@ button.design-workspace-tree__entry:hover,
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-message-list
details[data-testid='live-reasoning'] > summary {
details[data-testid='live-reasoning']
> summary {
cursor: pointer;
}
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-message-list
details[data-testid='live-reasoning'] pre {
details[data-testid='live-reasoning']
pre {
margin: 6px 0 0;
white-space: pre-wrap;
overflow-wrap: anywhere;
@@ -12330,14 +12382,33 @@ button.design-workspace-tree__entry:hover,
.project-supervisor-surface.is-direct-codex
.project-supervisor-message-list
.message
:where(p, h1, h2, h3, h4, h5, h6, ul, ol, li, blockquote, pre, code, strong, em, th, td) {
:where(
p,
h1,
h2,
h3,
h4,
h5,
h6,
ul,
ol,
li,
blockquote,
pre,
code,
strong,
em,
th,
td
) {
color: inherit;
}
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-message-list
.message p {
.message
p {
opacity: 1;
}
@@ -12361,19 +12432,23 @@ button.design-workspace-tree__entry:hover,
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-message-list
.message p,
.message
p,
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-message-list
.message ul,
.message
ul,
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-message-list
.message ol,
.message
ol,
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-message-list
.message li {
.message
li {
line-height: 1.5 !important;
}
@@ -12429,7 +12504,11 @@ button.design-workspace-tree__entry:hover,
.message-turn-process
> summary
> .agent-process-summary
:is(.agent-process-summary-icon, .agent-process-summary-meta, .agent-process-summary-chevron) {
:is(
.agent-process-summary-icon,
.agent-process-summary-meta,
.agent-process-summary-chevron
) {
color: inherit;
}
@@ -12501,8 +12580,14 @@ button.design-workspace-tree__entry:hover,
}
/* chevron 旋转只有一处来源:折叠态不旋转,展开态 rotate(180deg)(120ms 过渡内截图会看到中间角度)。 */
.message-turn-process > summary .agent-process-summary .agent-process-summary-chevron,
.design-agent-reasoning > summary .agent-process-summary .agent-process-summary-chevron {
.message-turn-process
> summary
.agent-process-summary
.agent-process-summary-chevron,
.design-agent-reasoning
> summary
.agent-process-summary
.agent-process-summary-chevron {
transform: none;
}
@@ -12559,10 +12644,21 @@ details.design-agent-reasoning[open]
.project-supervisor-surface.is-direct-codex
.agent-tool-call-group[data-has-failure='true']
> .agent-tool-call-group-head
:is(.agent-process-summary-icon, .agent-process-summary-meta, .agent-process-summary-chevron),
:is(
.agent-process-summary-icon,
.agent-process-summary-meta,
.agent-process-summary-chevron
),
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.agent-tool-call-group-row[data-status='failed']
:is(.agent-tool-call-row-head, .agent-tool-call-row-text, .agent-tool-call-row-status, .agent-tool-call-row-icon, .agent-tool-call-row-duration, .agent-tool-call-row-chevron) {
:is(
.agent-tool-call-row-head,
.agent-tool-call-row-text,
.agent-tool-call-row-status,
.agent-tool-call-row-icon,
.agent-tool-call-row-duration,
.agent-tool-call-row-chevron
) {
color: var(--platform-button-danger-text, #a6402f);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,121 @@
import { parseGameCreationAppAssetKind } from '../../../../../packages/shared/src/contracts/gameCreationApp';
import type { ResourceReference } from '../../features/project-workspace/resourceReferences';
import {
type ProjectResource,
projectResourceAssetCategory,
} from './resourceProjectionModel';
/** 对话栏在屏幕坐标里的矩形(拖动落点的唯一判据)。 */
export type ResourceCardReferenceDropRect = {
left: number;
top: number;
width: number;
height: number;
};
/**
* 量对话栏的落点矩形:没有元素、或元素还没量出尺寸(0 x 0,jsdom 与折叠态)时返回 null。
*
* 零面积矩形必须当「没有落点」:命中测试是 contains,一个 0 x 0 的矩形会把任何点都判成
* 在里面,于是画布内正常拖动松手也会被误判成「拖到对话」,排版写入被整段跳过。
*/
export function resourceCardReferenceDropRect(
element: Element | null,
): ResourceCardReferenceDropRect | null {
if (!element) return null;
const rect = element.getBoundingClientRect();
if (!(rect.width > 0) || !(rect.height > 0)) {
return null;
}
return {
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
};
}
export function resourceCardReferenceDropContains(
rect: ResourceCardReferenceDropRect,
clientX: number,
clientY: number,
) {
return (
clientX >= rect.left &&
clientX <= rect.left + rect.width &&
clientY >= rect.top &&
clientY <= rect.top + rect.height
);
}
/**
* 两次落点状态是否**逐值相同**。
*
* 指针在对话栏上移动时每帧都会算出一个新的落点对象;只看「有没有落点」会让每一帧都 `setState`
* 一次、把工作台整棵子树重渲染一遍(`pointermove` 热路径上这是实打实的浪费)。带上这一条,
* 只有条数或对话栏矩形真的变了才重新渲染。
*/
export function sameResourceCardReferenceDrop(
left: { count: number; rect: ResourceCardReferenceDropRect },
right: { count: number; rect: ResourceCardReferenceDropRect },
) {
return (
left.count === right.count &&
left.rect.left === right.rect.left &&
left.rect.top === right.rect.top &&
left.rect.width === right.rect.width &&
left.rect.height === right.rect.height
);
}
/**
* 拖到对话要插入的引用。
*
* 与资源卡工具条那枚「引用」按钮**同一口径**:只认已登记到 manifest 的素材
* manifestAssetId),来源标 resource-cardkind / 分类 / 标签全部取自资源投影,
* 于是同一个素材无论从按钮还是从拖拽进来,草稿里的引用是同一枚(去重键也跟着走)。
* 未登记的卡(任务产物、附件、Agent 回执等)在这里被过滤掉,调用方按「一条都没有」给原因。
*
* 顺序即插入顺序:movedResourceIds 是本次拖动真正参与位移的那批(第 0 项是被按住的那张),
* 多选后整批引用、单选只引用按住的那张,与拖动位移的语义同源。
*/
export function resourceCardReferenceDropReferences(
movedResourceIds: readonly string[],
resources: readonly ProjectResource[],
): ResourceReference[] {
if (movedResourceIds.length === 0) {
return [];
}
const byId = new Map(resources.map((resource) => [resource.id, resource]));
const references: ResourceReference[] = [];
for (const resourceId of movedResourceIds) {
const resource = byId.get(resourceId);
if (!resource?.manifestAssetId) {
continue;
}
references.push({
type: 'resource',
resourceId: resource.manifestAssetId,
kind: parseGameCreationAppAssetKind(
resource.subtype,
'project-development.resource-card-reference-drop',
),
mediaType: resource.mediaType,
label: resource.label,
category: projectResourceAssetCategory(resource),
tags: resource.assetTags ?? [],
source: 'resource-card',
});
}
return references;
}
/**
* 落点提示文案。条数为 0 时不写「引用 0 项」,而是把「为什么一条也引用不了」说清楚:
* 用户此时已经拖到对话上了,静默什么都不发生会被读成功能坏了。
*/
export function resourceCardReferenceDropHintLabel(count: number) {
return count > 0
? `松手即可 @ 引用 ${count} 项素材`
: '这些素材还没登记为项目资源,不能 @ 引用';
}
@@ -335,6 +335,80 @@ describe('「选择替换素材」弹窗在 AGC 的面板底色', () => {
expect(overlay!.className).toMatch(/platform-theme--(light|dark)/u);
});
it('nonModal 形态:只渲染浮层壳,不铺遮罩也不算模态面板', () => {
render(
<ImageCanvasProjectAssetPickerDialog
open
assets={[]}
selectedAssetIds={[]}
singleSelect
nonModal
selectionNoun="替换素材"
onCancel={() => {}}
onConfirm={() => {}}
/>,
);
const dialog = screen.getByRole('dialog', { name: '选择替换素材' });
expect(dialog.className).toContain(
'image-canvas-editor__project-asset-picker--floating',
);
expect(dialog.className).not.toContain(PLATFORM_MODAL_SHELL.slice(1));
// 非模态的核心判据:没有全屏遮罩——宿主画布必须保持可点(在画布上点选目标素材)。
expect(document.querySelector(PLATFORM_OVERLAY)).toBeNull();
// 关掉就是卸载:宿主把 open 置回 false 后不该在画布角落留半块面板。
cleanup();
render(
<ImageCanvasProjectAssetPickerDialog
open={false}
assets={[]}
selectedAssetIds={[]}
singleSelect
nonModal
selectionNoun="替换素材"
onCancel={() => {}}
onConfirm={() => {}}
/>,
);
expect(screen.queryByRole('dialog', { name: '选择替换素材' })).toBeNull();
});
it('AGC 加载的样式表带非模态浮层的底色 / 边框 / 投影与滚动区', () => {
const shell = findClassRule(
loadedRules,
'.image-canvas-editor__project-asset-picker--floating',
);
expect(
shell,
'非模态浮层壳不在 AGC 加载的样式表里:面板会变透明',
).not.toBeNull();
expect(shell!.declarations.get('background')).toBe(
'var(--platform-modal-fill)',
);
expect(shell!.declarations.get('border')).toBe(
'1px solid var(--platform-modal-border)',
);
expect(shell!.declarations.get('box-shadow')).toBe(
'var(--platform-panel-shadow)',
);
const header = findClassRule(
loadedRules,
'.image-canvas-editor__project-asset-picker-header',
);
expect(header).not.toBeNull();
// 浮层自己限高 + 内部滚动:候选再多也不把画布整块盖住。
const scroll = findClassRule(
loadedRules,
'.image-canvas-editor__project-asset-picker-scroll',
);
expect(scroll!.declarations.get('overflow-y')).toBe('auto');
const actions = findClassRule(
loadedRules,
'.image-canvas-editor__project-asset-picker-actions',
);
expect(actions).not.toBeNull();
});
it('没有平行拷贝:外壳三条规则只在共享表里定义一次', () => {
/*
这里必须**先转绝对路径再判断存在**`filter(existsSync)` 拿到的是仓库相对路径,`existsSync`
@@ -55,7 +55,10 @@ function task(
function renderSidebar(
tasks: readonly ResourceCanvasAssetGenerationTask[],
options: { open?: boolean } = {},
options: {
open?: boolean;
placement?: 'canvas' | 'run' | 'editor';
} = {},
) {
const onToggleOpen = vi.fn();
const onFocusTask = vi.fn();
@@ -65,6 +68,7 @@ function renderSidebar(
open={options.open ?? true}
onToggleOpen={onToggleOpen}
onFocusTask={onFocusTask}
placement={options.placement}
/>,
);
return { onToggleOpen, onFocusTask, ...view };
@@ -180,16 +184,105 @@ describe('「生成任务」侧栏', () => {
).not.toBeNull();
});
test('折叠后不再渲染,画布上不留任何常驻入口或把手', async () => {
renderSidebar([], { open: false });
test('折叠后只剩画布右上角那一枚开关:面板不渲染,开关自己就是开合口', async () => {
const user = userEvent.setup();
const { onToggleOpen, container } = renderSidebar([], { open: false });
expect(screen.queryByRole('region', { name: '生成任务' })).toBeNull();
expect(screen.queryByRole('button')).toBeNull();
expect(
document.querySelector('.game-resource-generation-tasks-handle'),
).toBeNull();
expect(
document.querySelector('.game-resource-generation-tasks-sidebar'),
).toBeNull();
// 开关落在右上角锚点里(形态照抄美术画布),并且是「生成任务」这个可访问名的唯一持有者。
const toggle = screen.getByRole('button', { name: '生成任务' });
expect(toggle.getAttribute('aria-expanded')).toBe('false');
expect(toggle.dataset.resourceGenerationTaskCount).toBe('0');
expect(
toggle.closest('.game-resource-generation-tasks-anchor'),
).not.toBeNull();
expect(
container.querySelectorAll('.game-resource-generation-tasks-toggle'),
).toHaveLength(1);
await user.click(toggle);
expect(onToggleOpen).toHaveBeenCalledTimes(1);
});
test('开关上的在途计数与面板头部同源,且跟着任务收口归零', () => {
const running = task({
taskId: 't1',
dispatched: true,
status: 'running',
phaseDetail: '正在生成。',
});
const { rerender } = render(
<ResourceCanvasAssetGenerationTasksPanelView
tasks={[running]}
open={false}
onToggleOpen={vi.fn()}
onFocusTask={vi.fn()}
/>,
);
const collapsedToggle = screen.getByRole('button', { name: '生成任务' });
expect(collapsedToggle.dataset.resourceGenerationTaskCount).toBe('1');
expect(collapsedToggle.textContent).toContain('生成任务 · 1');
rerender(
<ResourceCanvasAssetGenerationTasksPanelView
tasks={[{ ...running, status: 'completed', finishedAtMillis: 2 }]}
open
onToggleOpen={vi.fn()}
onFocusTask={vi.fn()}
/>,
);
expect(screen.getByLabelText('在途生成任务 0').textContent).toBe('0');
});
test('展开时开关让位:同一时刻只有面板自己那枚 × 能收起它', async () => {
const user = userEvent.setup();
const { onToggleOpen } = renderSidebar([task({ taskId: 't1' })], {
open: true,
});
// 开关与面板不并排:展开态画布右上角只留面板(收起走面板头部那枚 × 或点外部)。
expect(
screen.queryByRole('button', { name: /^生成任务(?: · \d+)?$/ }),
).toBeNull();
expect(screen.getByRole('region', { name: '生成任务' })).not.toBeNull();
await user.click(screen.getByRole('button', { name: '关闭生成任务' }));
expect(onToggleOpen).toHaveBeenCalledTimes(1);
});
test('锚点按工作面分档:资源画布 / 运行表现层 / UI 编辑器各挂一档', () => {
const { container, rerender } = renderSidebar([], { placement: 'run' });
const placementOf = () =>
container
.querySelector('.game-resource-generation-tasks-anchor')
?.getAttribute('data-generation-tasks-placement');
expect(placementOf()).toBe('run');
rerender(
<ResourceCanvasAssetGenerationTasksPanelView
tasks={[]}
open
onToggleOpen={vi.fn()}
onFocusTask={vi.fn()}
placement="editor"
/>,
);
expect(placementOf()).toBe('editor');
// 缺省 = 资源栏目画布,老调用方不传这个 prop 也落在画布右上角。
rerender(
<ResourceCanvasAssetGenerationTasksPanelView
tasks={[]}
open
onToggleOpen={vi.fn()}
onFocusTask={vi.fn()}
/>,
);
expect(placementOf()).toBe('canvas');
});
test('收起时先播完收起动画再卸载,动画期间列表内容不跳', () => {
@@ -188,18 +188,83 @@ describe('「生成任务」侧栏样式', () => {
'var(--platform-line-soft)',
);
// 侧栏高度有界:上下边界都给死,不靠内容撑高(内容再多也只滚列表)。
// 面板高度有界:不再靠上下边界拉满,改成锚点里的浮动卡片 + 封顶高度(内容再多也只滚列表)。
const sidebar = resolved(['.game-resource-generation-tasks-sidebar']);
expect(declaration(sidebar, 'top')).toBe('4rem');
expect(declaration(sidebar, 'bottom')).toBe('6rem');
expect(declaration(sidebar, 'width')).toBe('min(300px, 80vw)');
expect(declaration(sidebar, 'max-height')).toBe(
'min(30rem, calc(100vh - 12rem))',
);
// 面板自己不再定位:坐标只由右上角锚点一处决定,避免两处各锚一处互相漂移。
expect(sidebar.has('position')).toBe(false);
expect(sidebar.has('left')).toBe(false);
});
test('窄屏(360px)下侧栏不溢出不挡把手', () => {
const narrow = resolved(['.game-resource-generation-tasks-sidebar'], 360);
expect(declaration(narrow, 'width')).toBe('auto');
expect(declaration(narrow, 'left')).toBe('0.5rem');
expect(declaration(narrow, 'right')).toBe('0.5rem');
test('锚点贴在工作面右上角,运行表现层让开版本入口,开关沿用次级胶囊样式', () => {
const anchor = resolved(['.game-resource-generation-tasks-anchor']);
// 锚点是 stage 网格里与工作面同一个单元格的条目:贴右贴顶由网格给,不再用写死 top 的绝对定位
//(写死的 top 会被工具条换行顶穿,验收现场那条「生成任务和菜单栏重叠」就是这么来的)。
expect(anchor.has('position')).toBe(false);
expect(declaration(anchor, 'grid-row')).toBe('3');
expect(declaration(anchor, 'grid-column')).toBe('1');
expect(declaration(anchor, 'justify-self')).toBe('end');
expect(declaration(anchor, 'align-self')).toBe('start');
expect(declaration(anchor, 'z-index')).toBe('40');
// 画布顶边内缩一小段,不压在工具条那一行上。
expect(declaration(anchor, 'margin')).toBe('0.85rem');
// 运行表现层右上角被版本入口(`game-run-version-picker`top 20px / right 20px)占着,
// 这一档必须把开关压到那枚版本入口下面(内缩上边距 3.5rem)。
const runAnchor = resolved([
'.game-resource-generation-tasks-anchor',
".game-resource-generation-tasks-anchor[data-generation-tasks-placement='run']",
]);
expect(declaration(runAnchor, 'grid-row')).toBe('2 / -1');
expect(declaration(runAnchor, 'margin')).toContain('3.5rem');
// UI 编辑器那一档与资源画布同档(第二行),不引入第三套坐标。
const editorAnchor = resolved([
'.game-resource-generation-tasks-anchor',
".game-resource-generation-tasks-anchor[data-generation-tasks-placement='editor']",
]);
expect(declaration(editorAnchor, 'grid-row')).toBe('2');
expect(declaration(editorAnchor, 'margin')).toBe('0.85rem');
// 开关保留原来工具条那一枚的外观(次级胶囊),只是搬到画布上并加一档面板投影;
// 取值仍然全部走平台 token,不引入第二套色板。
const toggle = resolved(['.game-resource-generation-tasks-toggle']);
expect(declaration(toggle, 'border-radius')).toBe('999px');
expect(declaration(toggle, 'border')).toBe(
'1px solid var(--platform-surface-border)',
);
expect(declaration(toggle, 'background')).toBe(
'var(--platform-button-secondary-fill)',
);
expect(declaration(toggle, 'color')).toBe(
'var(--platform-button-secondary-text)',
);
expect(declaration(toggle, 'box-shadow')).toBe(
'var(--platform-panel-shadow)',
);
for (const property of ['border', 'background', 'color', 'box-shadow']) {
expect(declaration(toggle, property)).not.toMatch(
/#[0-9a-f]{3,8}|rgba?\(|hsla?\(/iu,
);
}
});
test('窄屏(360px)下锚点拉满画布那一格,面板宽度自适应不溢出', () => {
const narrowAnchor = resolved(
['.game-resource-generation-tasks-anchor'],
360,
);
expect(declaration(narrowAnchor, 'justify-self')).toBe('stretch');
expect(declaration(narrowAnchor, 'margin')).toBe('0.5rem');
const narrowSidebar = resolved(
['.game-resource-generation-tasks-sidebar'],
360,
);
expect(declaration(narrowSidebar, 'width')).toBe('auto');
});
test('prefers-reduced-motion 下进场动画与过渡都被关掉', () => {
@@ -0,0 +1,398 @@
/** @vitest-environment jsdom */
import { act, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type {
GameCreationAppAssetManifestEntry,
GameCreationAppManifest,
ProjectResourceCanvasPosition,
} from '../../../packages/shared/src/contracts/gameCreationApp';
import {
type ChatReference,
RESOURCE_REFERENCE_INSERT_MANY_EVENT,
type ResourceReferenceInsertManyEventDetail,
} from '../src/features/project-workspace/resourceReferences';
import ProjectDevelopmentView from '../src/view/project-development';
import {
createGameCreationAppManifest,
fireEvent,
React,
render,
screen,
} from './appSurface/harness';
/**
* 「拖动素材到对话 = 批量 @ 引用」的行为级验收。
*
* 三条口径:
* 1. 把资源卡拖到 Agent 对话栏上松手 → 按这次拖动真正参与位移的那批(多选整批)插入引用,
* 一条布局坐标都不写(拖到对话不是排版)。
* 2. 只认已登记到 manifest 的素材,引用身份、来源标记与资源卡工具条那枚「引用」按钮逐字一致。
* 3. 画布内的拖动语义一行不改:照旧提交手动坐标、照旧不派发引用事件。
*/
type AssetFixture = GameCreationAppAssetManifestEntry;
function characterAsset(id: string, fileName: string): AssetFixture {
return {
id,
kind: 'character',
category: 'character',
mediaType: 'image/png',
localPath: `assets/${fileName}`,
source: { kind: 'generated', resourceId: `${id}-resource` },
};
}
function manifestFor(
projectId: string,
assets: AssetFixture[],
): GameCreationAppManifest {
return {
...createGameCreationAppManifest(projectId, `${projectId} 项目`),
assets: assets.map((asset) => structuredClone(asset)),
};
}
function resourceGraphFor(
resources: Array<{ resourceId: string }> | undefined,
) {
const resourceIds = (resources ?? []).map((resource) => resource.resourceId);
return {
resourceIds,
referenceEdges: [],
taskFlows: [],
connectionIndex: resourceIds.map((resourceId) => ({
resourceId,
upstreamReferenceResourceIds: [],
downstreamReferenceResourceIds: [],
referenceEdgeIds: [],
taskFlowIds: [],
})),
producerAssignments: [],
dependencyDepths: resourceIds.map((resourceId) => ({
resourceId,
dependencyDepth: 0,
})),
unresolvedReferenceResourceIds: [],
cyclicResourceIds: [],
cyclicTaskIds: [],
producerMappingTruncated: false,
};
}
type LayoutWrite = {
mode: string;
positions: ProjectResourceCanvasPosition[];
};
function installTauri(): { layoutWrites: LayoutWrite[] } {
const layoutWrites: LayoutWrite[] = [];
const persisted = new Map<string, ProjectResourceCanvasPosition[]>();
const revisions = new Map<string, number>();
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'get_local_game_project_revision') {
return { revision: 1 };
}
if (command === 'read_local_project_resource_graph') {
return resourceGraphFor(
args?.resources as Array<{ resourceId: string }> | undefined,
);
}
if (command === 'read_local_project_resource_canvas_layout') {
const key = `${String(args?.projectPath ?? '')}|${String(args?.mode ?? '')}`;
return {
schemaVersion: 'game-creator-resource-layout.v1',
// 前端只传 projectPath / mode;读回来的 projectId 必须与当前 scope 一致,否则这份布局
// 会被判成不属于当前项目(写入整段停摆)。
projectId: 'chat-reference-drop',
mode: args?.mode,
revision: revisions.get(key) ?? 0,
positions: structuredClone(persisted.get(key) ?? []),
updatedAt: 0,
};
}
if (command === 'update_local_project_resource_canvas_layout') {
const key = `${String(args?.projectPath ?? '')}|${String(args?.mode ?? '')}`;
const positions = structuredClone(
(args?.positions ?? []) as ProjectResourceCanvasPosition[],
);
persisted.set(key, positions);
revisions.set(key, Number(args?.expectedRevision ?? 0) + 1);
layoutWrites.push({ mode: String(args?.mode ?? ''), positions });
return {
status: 'updated',
layout: {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: String(args?.expectedProjectId ?? ''),
mode: String(args?.mode ?? ''),
revision: revisions.get(key)!,
positions,
updatedAt: 1,
},
};
}
if (command === 'list_pending_local_project_resource_edits') {
return [];
}
if (command === 'list_local_project_asset_generations') {
return [];
}
return undefined;
},
);
window.__TAURI__ = { core: { invoke } } as unknown as typeof window.__TAURI__;
return { layoutWrites };
}
async function settle() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
});
}
const CHAT_RECT = {
left: 900,
top: 40,
right: 1200,
bottom: 640,
width: 300,
height: 600,
x: 900,
y: 40,
} as DOMRect;
function collectReferenceInserts() {
const inserts: ChatReference[][] = [];
const listener = (event: Event) => {
const detail = (
event as CustomEvent<ResourceReferenceInsertManyEventDetail>
).detail;
inserts.push(detail?.references ?? []);
};
window.addEventListener(RESOURCE_REFERENCE_INSERT_MANY_EVENT, listener);
return {
inserts,
dispose: () =>
window.removeEventListener(
RESOURCE_REFERENCE_INSERT_MANY_EVENT,
listener,
),
};
}
async function mountCanvas() {
const tauri = installTauri();
const manifest = manifestFor('chat-reference-drop', [
characterAsset('drop-a', 'a.png'),
characterAsset('drop-b', 'b.png'),
]);
render(
React.createElement(ProjectDevelopmentView, {
projectName: manifest.name,
projectPath: '/tmp/chat-reference-drop',
manifest,
attachments: [],
recentRunStatus: null,
recentRunStopReason: null,
supervisor: React.createElement('div', null, '项目总控'),
onHomeOpen: vi.fn(),
onProjectsOpen: vi.fn(),
}),
);
await settle();
const manager = document.querySelector<HTMLElement>(
'.game-resource-book-manager',
)!;
vi.spyOn(manager, 'getBoundingClientRect').mockReturnValue({
x: 0,
y: 0,
left: 0,
top: 0,
right: 800,
bottom: 600,
width: 800,
height: 600,
toJSON: () => ({}),
} as DOMRect);
act(() => window.dispatchEvent(new Event('resize')));
fireEvent.click(screen.getByRole('button', { name: '打开角色与对象' }));
await settle();
const chat = document.querySelector<HTMLElement>('.game-workbench-chat')!;
vi.spyOn(chat, 'getBoundingClientRect').mockReturnValue(CHAT_RECT);
const inserts = collectReferenceInserts();
return { chat, manager, tauri, ...inserts };
}
function cardIn(manager: HTMLElement, resourceId: string) {
return manager.querySelector<HTMLElement>(
`[data-resource-id="${resourceId}"]`,
)!;
}
afterEach(() => {
delete window.__TAURI__;
vi.restoreAllMocks();
});
describe('拖动素材到对话:批量 @ 引用', () => {
it('拖到对话栏松手:插入引用、不写布局坐标、提示条只在落点上出现', async () => {
const { manager, tauri, inserts, dispose } = await mountCanvas();
const card = cardIn(manager, 'asset:drop-a');
expect(card).not.toBeNull();
const writesBefore = tauri.layoutWrites.length;
fireEvent.pointerDown(card, {
pointerId: 70,
button: 0,
clientX: 100,
clientY: 100,
});
fireEvent.pointerMove(card, {
pointerId: 70,
buttons: 1,
clientX: 1000,
clientY: 300,
});
// 落在对话上:提示条按对话栏矩形铺,卡片不再跟着指针走(不写成"把素材拖出项目")。
expect(screen.getByText('松手即可 @ 引用 1 项素材')).not.toBeNull();
expect(card.className).not.toContain('is-dragging');
fireEvent.pointerUp(card, {
pointerId: 70,
button: 0,
clientX: 1000,
clientY: 300,
});
await settle();
expect(inserts).toHaveLength(1);
expect(inserts[0]).toHaveLength(1);
expect(inserts[0]![0]).toMatchObject({
type: 'resource',
resourceId: 'drop-a',
kind: 'character',
source: 'resource-card',
});
// 拖到对话不是排版:一条坐标都不写。
expect(tauri.layoutWrites).toHaveLength(writesBefore);
expect(screen.queryByText('松手即可 @ 引用 1 项素材')).toBeNull();
dispose();
});
it('多选后拖任意一张:整批一起 @ 引用', async () => {
const { manager, tauri, inserts, dispose } = await mountCanvas();
const cardA = cardIn(manager, 'asset:drop-a');
const cardB = cardIn(manager, 'asset:drop-b');
fireEvent.click(cardA);
fireEvent.click(cardB, { shiftKey: true });
const writesBefore = tauri.layoutWrites.length;
fireEvent.pointerDown(cardB, {
pointerId: 71,
button: 0,
clientX: 200,
clientY: 200,
});
fireEvent.pointerMove(cardB, {
pointerId: 71,
buttons: 1,
clientX: 1000,
clientY: 320,
});
expect(screen.getByText('松手即可 @ 引用 2 项素材')).not.toBeNull();
fireEvent.pointerUp(cardB, {
pointerId: 71,
button: 0,
clientX: 1000,
clientY: 320,
});
await settle();
expect(inserts).toHaveLength(1);
expect(
new Set(inserts[0]!.map((reference) => reference.resourceId)),
).toEqual(new Set(['drop-a', 'drop-b']));
expect(tauri.layoutWrites).toHaveLength(writesBefore);
dispose();
});
it('指针移回画布后松手:照旧写手动坐标,不派发任何引用', async () => {
const { manager, tauri, inserts, dispose } = await mountCanvas();
const card = cardIn(manager, 'asset:drop-a');
const writesBefore = tauri.layoutWrites.length;
fireEvent.pointerDown(card, {
pointerId: 72,
button: 0,
clientX: 100,
clientY: 100,
});
fireEvent.pointerMove(card, {
pointerId: 72,
buttons: 1,
clientX: 1000,
clientY: 300,
});
expect(screen.getByText('松手即可 @ 引用 1 项素材')).not.toBeNull();
// 又拖回画布里:落点提示收起,拖动回到排版语义。
fireEvent.pointerMove(card, {
pointerId: 72,
buttons: 1,
clientX: 260,
clientY: 200,
});
expect(screen.queryByText('松手即可 @ 引用 1 项素材')).toBeNull();
fireEvent.pointerUp(card, {
pointerId: 72,
button: 0,
clientX: 260,
clientY: 200,
});
await settle();
expect(inserts).toHaveLength(0);
await waitFor(() =>
expect(tauri.layoutWrites.length).toBeGreaterThan(writesBefore),
);
expect(tauri.layoutWrites.at(-1)?.positions).toEqual(
expect.arrayContaining([
expect.objectContaining({
resourceId: 'asset:drop-a',
manuallyPlaced: true,
}),
]),
);
dispose();
});
it('拖动取消(pointercancel)时落点提示与引用一起收干净', async () => {
const { manager, inserts, dispose } = await mountCanvas();
const card = cardIn(manager, 'asset:drop-a');
fireEvent.pointerDown(card, {
pointerId: 73,
button: 0,
clientX: 100,
clientY: 100,
});
fireEvent.pointerMove(card, {
pointerId: 73,
buttons: 1,
clientX: 1000,
clientY: 300,
});
expect(screen.getByText('松手即可 @ 引用 1 项素材')).not.toBeNull();
fireEvent.pointerCancel(card, { pointerId: 73 });
await settle();
expect(screen.queryByText('松手即可 @ 引用 1 项素材')).toBeNull();
expect(inserts).toHaveLength(0);
dispose();
});
});
@@ -12,16 +12,18 @@ import {
render,
screen,
waitFor,
within,
} from './appSurface/harness';
import { repoPath } from './repoPath';
/**
* 「生成任务」侧栏与工具条入口的三条口径:
* 「生成任务」侧栏与右上角入口的三条口径:
*
* 1. **失去焦点即收起**:点画布、点别的工具栏按钮、点侧栏外部都算失去焦点;点侧栏内部
* (含任务卡与「定位到素材」)与点那枚开合按钮不算——后者自己负责 toggle,否则会先被
* (含任务卡与「定位到素材」)与点那枚开合开关不算——后者自己负责 toggle,否则会先被
* 收起再被 toggle 打开,表现为按钮失灵。
* 2. **入口顺序**「生成任务」排在「依赖 / 类型」排列方式之前(动作在前、排列方式收行尾)。
* 2. **入口位置**开关常驻画布**右上角**的锚点里(形态照抄美术画布),不再占工具条那一行;
* 工具条上因此只剩资源动作与排列方式。
* 3. **行内间距只有一套**:「依赖 / 类型」与前一按钮之间不能只剩分段控件自带的那点间隙。
*/
@@ -103,43 +105,63 @@ describe('「生成任务」侧栏的开合与入口位置', () => {
);
});
it('点侧栏内部与开合按钮都不收起:内部保持打开,按钮仍能 toggle 收起来', async () => {
it('展开时开关让位:点侧栏内部不收起,收起只走面板自己的 ×', async () => {
const sidebar = await renderWorkbench('workbench-tasks-dismiss-inside');
// 侧栏内部点击(任务列表区域)不该被当成点外部。
fireEvent.pointerDown(sidebar);
expect(screen.queryByRole('region', { name: '生成任务' })).not.toBeNull();
// 开合按钮自己负责 toggle:点一次必须真的收起(不能先被「点外部」收起再被 toggle 打开)
const entry = screen.getByRole('button', { name: '生成任务' });
fireEvent.pointerDown(entry);
fireEvent.click(entry);
// 展开态开关不渲染(开关与面板不并排):这一刻画布上只有面板自己那枚 ×
expect(
screen.queryByRole('button', { name: /^生成任务(?: · \d+)?$/ }),
).toBeNull();
fireEvent.click(
within(sidebar).getByRole('button', { name: '关闭生成任务' }),
);
await waitFor(() =>
expect(screen.queryByRole('region', { name: '生成任务' })).toBeNull(),
);
// 再点一次要能回来
// 收起之后开关回来,点它能再打开
const entry = await screen.findByRole('button', { name: '生成任务' });
fireEvent.click(entry);
expect(
await screen.findByRole('region', { name: '生成任务' }),
).not.toBeNull();
});
it('「生成任务」入口排在「依赖 / 类型」排列方式之前', async () => {
it('「生成任务」开关落在画布右上角锚点里,不再占工具条那一行', async () => {
await renderWorkbench('workbench-tasks-entry-order');
const actionsRow = document.querySelector('.game-workbench-view-actions');
expect(actionsRow).not.toBeNull();
const buttons = Array.from(actionsRow!.querySelectorAll('button'));
const tasksIndex = buttons.findIndex(
(button) => button.getAttribute('aria-label') === '生成任务',
);
// 工具条不再有「生成任务」入口:入口只有一个,就是右上角那一枚开关。
expect(
buttons.some(
(button) => button.getAttribute('aria-label') === '生成任务',
),
).toBe(false);
const sortIndex = buttons.findIndex(
(button) => button.getAttribute('aria-label') === '按依赖',
);
expect(tasksIndex).toBeGreaterThanOrEqual(0);
expect(sortIndex).toBeGreaterThanOrEqual(0);
// 依赖 / 类型收在行尾,生成任务在它前面
expect(tasksIndex).toBeLessThan(sortIndex);
// 依赖 / 类型仍然收在行尾。
expect(sortIndex).toBe(buttons.length - 2);
// 锚点挂在主视窗(stage)的**画布那一格**里:右上角坐标是相对画布工作面算的,
// 不是相对整个窗口,也不会压在工具条那一行上。
const anchor = document.querySelector(
'.game-resource-generation-tasks-anchor',
);
expect(anchor).not.toBeNull();
expect(anchor?.getAttribute('data-generation-tasks-placement')).toBe(
'canvas',
);
expect(
document
.querySelector('.game-workbench-stage')
?.contains(anchor as Element),
).toBe(true);
});
it('「依赖 / 类型」与前一按钮之间的间距跟行内其他按钮一致', () => {
@@ -0,0 +1,169 @@
import { describe, expect, test } from 'vitest';
import {
resourceCardReferenceDropContains,
resourceCardReferenceDropHintLabel,
resourceCardReferenceDropRect,
resourceCardReferenceDropReferences,
sameResourceCardReferenceDrop,
} from '../src/view/project-development/resourceCardReferenceDropModel';
import type { ProjectResource } from '../src/view/project-development/resourceProjectionModel';
function resource(
patch: Partial<ProjectResource> & { id: string },
): ProjectResource {
return {
category: 'character',
subtype: 'character',
label: patch.id,
path: `assets/${patch.id}.png`,
mediaType: 'image/png',
sourceLabel: '本地上传',
taskTitle: null,
manifestAssetId: null,
producerTaskId: null,
externalResourceId: null,
referenceResourceIds: [],
dependencies: [],
dependencyDepth: 0,
...patch,
};
}
function elementWithRect(rect: {
left: number;
top: number;
width: number;
height: number;
}): Element {
return {
getBoundingClientRect: () =>
({
...rect,
right: rect.left + rect.width,
bottom: rect.top + rect.height,
x: rect.left,
y: rect.top,
toJSON: () => ({}),
}) as DOMRect,
} as unknown as Element;
}
describe('拖动素材到对话:落点与引用构造', () => {
test('没有元素或没量出尺寸都不算落点', () => {
expect(resourceCardReferenceDropRect(null)).toBeNull();
// 0 x 0 必须当「没有落点」:否则任何点都会被判成落在里面,画布内拖动松手也会被当成拖到对话。
expect(
resourceCardReferenceDropRect(
elementWithRect({ left: 0, top: 0, width: 0, height: 0 }),
),
).toBeNull();
expect(
resourceCardReferenceDropRect(
elementWithRect({ left: 10, top: 20, width: 400, height: 600 }),
),
).toEqual({ left: 10, top: 20, width: 400, height: 600 });
});
test('命中判据按矩形边界闭区间算', () => {
const rect = { left: 800, top: 0, width: 400, height: 600 };
expect(resourceCardReferenceDropContains(rect, 800, 0)).toBe(true);
expect(resourceCardReferenceDropContains(rect, 1200, 600)).toBe(true);
expect(resourceCardReferenceDropContains(rect, 1000, 300)).toBe(true);
expect(resourceCardReferenceDropContains(rect, 799, 300)).toBe(false);
expect(resourceCardReferenceDropContains(rect, 1000, 601)).toBe(false);
});
test('只把已登记的 manifest 素材折成引用,顺序与拖动集合一致', () => {
const references = resourceCardReferenceDropReferences(
['asset:registered-b', 'featured:not-registered', 'asset:registered-a'],
[
resource({
id: 'asset:registered-b',
manifestAssetId: 'registered-b',
subtype: 'character',
label: '主角立绘',
mediaType: 'image/png',
assetCategory: 'character',
assetTags: ['主角', '立绘'],
}),
resource({ id: 'featured:not-registered', label: '任务产物' }),
resource({
id: 'asset:registered-a',
manifestAssetId: 'registered-a',
subtype: 'background-music',
label: '主城 BGM',
mediaType: 'audio/mpeg',
assetCategory: 'audio',
}),
],
);
expect(references.map((reference) => reference.resourceId)).toEqual([
'registered-b',
'registered-a',
]);
expect(references[0]).toMatchObject({
type: 'resource',
// 引用认的是 manifest 资产 id,不是画布投影 id。
resourceId: 'registered-b',
kind: 'character',
label: '主角立绘',
mediaType: 'image/png',
category: 'character',
tags: ['主角', '立绘'],
// 与资源卡工具条那枚「引用」按钮同一个来源标记:同一素材两处进来是同一枚引用。
source: 'resource-card',
});
expect(references[1]).toMatchObject({
resourceId: 'registered-a',
kind: 'background-music',
category: 'audio',
});
});
test('拖动集合里一条登记素材都没有时返回空数组', () => {
expect(
resourceCardReferenceDropReferences(
['featured:task'],
[resource({ id: 'featured:task' })],
),
).toEqual([]);
expect(resourceCardReferenceDropReferences([], [])).toEqual([]);
});
test('提示文案把条数说清楚,0 条时说原因而不是「引用 0 项」', () => {
expect(resourceCardReferenceDropHintLabel(1)).toBe(
'松手即可 @ 引用 1 项素材',
);
expect(resourceCardReferenceDropHintLabel(3)).toBe(
'松手即可 @ 引用 3 项素材',
);
expect(resourceCardReferenceDropHintLabel(0)).toBe(
'这些素材还没登记为项目资源,不能 @ 引用',
);
});
test('落点状态逐值比较:条数或矩形变了才算变化', () => {
const rect = { left: 900, top: 40, width: 300, height: 600 };
const base = { count: 2, rect };
// 指针在对话栏上移动时每帧都会算出新的对象:逐值相同必须判成「没变」,
// 否则每一帧都会 setState 一次、把工作台整棵子树重渲染一遍。
expect(
sameResourceCardReferenceDrop(base, { count: 2, rect: { ...rect } }),
).toBe(true);
expect(sameResourceCardReferenceDrop(base, { count: 1, rect })).toBe(false);
expect(
sameResourceCardReferenceDrop(base, {
count: 2,
rect: { ...rect, left: rect.left + 1 },
}),
).toBe(false);
expect(
sameResourceCardReferenceDrop(base, {
count: 2,
rect: { ...rect, height: rect.height + 1 },
}),
).toBe(false);
});
});
@@ -1,5 +1,6 @@
// @vitest-environment jsdom
import {
act,
cleanup,
fireEvent,
render,
@@ -9,7 +10,7 @@ import {
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { $getRoot } from 'lexical';
import { StrictMode, useState } from 'react';
import { createRef, StrictMode, useState } from 'react';
import { afterEach, describe, expect, test, vi } from 'vitest';
import type {
@@ -20,7 +21,10 @@ import {
LOCAL_GAME_PREVIEW_INSPECT_MESSAGE,
parseLocalGamePreviewInspectMessage,
} from '../src/features/project-workspace/LocalGamePreviewFrame';
import { ResourceReferenceInput } from '../src/features/project-workspace/ResourceReferenceInput';
import {
ResourceReferenceInput,
type ResourceReferenceInputHandle,
} from '../src/features/project-workspace/ResourceReferenceInput';
import {
type ChatComposerDraft,
type ChatReference,
@@ -28,9 +32,11 @@ import {
currentIterationVersionAssets,
dedupeChatReferences,
dispatchResourceReferenceInsert,
dispatchResourceReferenceInsertMany,
resolveActiveIterationVersion,
RESOURCE_REFERENCE_FILTERS,
RESOURCE_REFERENCE_INSERT_EVENT,
RESOURCE_REFERENCE_INSERT_MANY_EVENT,
RESOURCE_REFERENCE_SCOPES,
resourceReferenceCategory,
resourceReferenceFromAsset,
@@ -430,6 +436,62 @@ describe('ResourceReferenceInput', () => {
).toEqual(reference);
});
test('批量引用一次派发整批,空批次不派发', () => {
const listener = vi.fn();
const references = [
resourceReferenceFromAsset(assets[0]!, 'resource-card'),
resourceReferenceFromAsset(assets[2]!, 'resource-card'),
];
window.addEventListener(RESOURCE_REFERENCE_INSERT_MANY_EVENT, listener);
// 画布拖拽批量入口:N 条引用合成一次派发,不拆成 N 次单条事件。
dispatchResourceReferenceInsertMany(references);
// 空批次不开一次空事务:监听方连事件都收不到。
dispatchResourceReferenceInsertMany([]);
window.removeEventListener(RESOURCE_REFERENCE_INSERT_MANY_EVENT, listener);
expect(listener).toHaveBeenCalledTimes(1);
expect(
(listener.mock.calls[0]?.[0] as CustomEvent).detail.references,
).toEqual(references);
});
test('一次 insertReferences 按给定顺序插入整批 chip', async () => {
const onChange = vi.fn<(draft: ChatComposerDraft) => void>();
const composerRef = createRef<ResourceReferenceInputHandle>();
render(
<ResourceReferenceInput
ref={composerRef}
value=""
references={[]}
onChange={onChange}
assets={assets}
projectPath="C:/project"
ariaLabel="聊天"
/>,
);
await settleComposer();
const references = [
resourceReferenceFromAsset(assets[2]!, 'resource-card'),
resourceReferenceFromAsset(assets[0]!, 'resource-card'),
];
act(() => composerRef.current?.insertReferences(references));
await settleComposer();
const draft = onChange.mock.calls.at(-1)?.[0];
// 顺序就是派发顺序:拖拽时按住的那张在 `moves[0]`,整批引用的次序跟着它走。
expect(draft?.references.map((reference) => reference.resourceId)).toEqual([
'theme',
'hero',
]);
expect(
document.querySelector('[data-resource-reference-id="theme"]'),
).not.toBeNull();
expect(
document.querySelector('[data-resource-reference-id="hero"]'),
).not.toBeNull();
});
test('runtime inspect messages only expose the bounded safe selection shape', () => {
expect(
parseLocalGamePreviewInspectMessage({
@@ -297,7 +297,8 @@ function renderReplacementWorkbench(options: RenderOptions = {}) {
}
if (command === 'update_local_project_resource_classification') {
const input = args?.input as
{ assetId?: string; category?: string; tags?: string[] } | undefined;
| { assetId?: string; category?: string; tags?: string[] }
| undefined;
return {
asset: {
id: input?.assetId,
@@ -345,6 +346,13 @@ function renderReplacementWorkbench(options: RenderOptions = {}) {
if (command === 'get_local_game_manifest') {
return nextManifest;
}
// 「生成任务」面板与未完成编辑账本(本文件的互斥用例会开一次任务面板)。
if (command === 'list_local_project_asset_generations') {
return [];
}
if (command === 'list_pending_local_project_resource_edits') {
return [];
}
throw new Error(`unexpected invoke ${command}`);
},
);
@@ -425,15 +433,10 @@ function replacementWrites(invoke: { mock: { calls: unknown[][] } }) {
);
}
/** 点选态的提示条;不在点选态时为 `null`。 */
function pickHint() {
return document.querySelector<HTMLElement>('.game-resource-canvas-pick-hint');
}
/**
* 在点选态下点一张资源卡:按下(这一步就是点选)+ 真实浏览器里紧随其后的那一次 `click`。
* 在替换面板开着时点一张资源卡:按下(这一步就是点选)+ 真实浏览器里紧随其后的那一次 `click`。
*
* 之所以把 `click` 也补上:点选要求"卡片单击不参与选中",而那正是靠**消费掉紧随 pointerdown
* 之所以把 `click` 也补上:点选要求"卡片单击不参与选中",而那正是靠**消费掉紧随 pointerdown
* 的那一次 click** 实现的 —— 不补这一下,用例就漏掉了抑制残留这条最可能的回归。
*/
async function pickResourceCard(label: string) {
@@ -1121,12 +1124,10 @@ describe('版本级资源替换', () => {
});
/**
* 替换弹窗的「点选替换」入口(PRD §5.3):关掉弹窗、改在画布上点目标素材
*
* 进入点选必须**卸载弹窗**:弹窗外壳是全屏遮罩,留着它画布上的卡根本点不到。
* 入口本身不改数据:候选读取是打开弹窗时的事,这里一次替换写入都不该有。
* 替换面板是**非模态浮层**(PRD §5.3):面板开着的时候画布照常可点,在画布上点目标素材即落进
* 面板里的当前选择;写入仍然只由面板的「确认」发起,所以点选本身一次写入都不该有。
*/
it('点选替换:入口关掉弹窗进入点选态,提示条写明退出方式且不写盘', async () => {
it('替换面板非模态:画布上点候选即落进面板的当前选择,零写入', async () => {
const { invoke } = renderReplacementWorkbench();
const toolbar = await selectCardAndOpenToolbar('legacy.png');
@@ -1134,27 +1135,108 @@ describe('版本级资源替换', () => {
const dialog = await screen.findByRole('dialog', {
name: '选择替换素材',
});
// 非模态判据:没有全屏遮罩,面板自己是画布上的浮层(否则画布上的卡根本点不到)。
expect(dialog.className).toContain(
'image-canvas-editor__project-asset-picker--floating',
);
expect(document.querySelector('.platform-overlay')).toBeNull();
fireEvent.click(within(dialog).getByRole('button', { name: '点选替换' }));
act(() => {
observer?.triggerVisible();
});
await pickResourceCard('final.png');
expect(screen.queryByRole('dialog', { name: '选择替换素材' })).toBeNull();
const hint = pickHint();
if (!hint) throw new Error('点选提示条未渲染');
expect(hint.textContent).toContain('在画布上点选要替换成的素材');
// 空白处点击不退出这件事必须写在提示条上:用户按直觉点空白才不会以为点坏了。
expect(hint.textContent).toContain('点击空白处不会退出');
expect(within(hint).getByRole('button', { name: '取消' })).not.toBeNull();
const option = within(dialog).getByRole('option', {
name: '选择替换素材final.png',
});
await waitFor(() =>
expect(option.getAttribute('aria-selected')).toBe('true'),
);
// 点选只改面板里的当前选择:写入还是得等「确认」。
expect(replacementWrites(invoke)).toHaveLength(0);
// 点选那一下自带的 click 不得换画布选中:源素材仍是选中的那一个。
expect(
getResourceSelectButton('legacy.png').getAttribute('aria-pressed'),
).toBe('true');
});
/**
* 点选态下合法目标直接提交:写入路径与弹窗确认**完全同一条**(同一个 `confirm` 函数),
* 载荷必须逐字一致,且成功后自动退出点选态。
* 在画布上点目标素材只同步「当前选择」这一项:面板里的搜索词与分类筛选不能被重置 —— 用户
* 常常是先筛到一半、才想起目标就在画布上(这正是这次的入口动机)。同步点由宿主的显式序号给
* `initialSelectionRevision`),不是「props 变了就重置」。
*/
it('画布点选同步当前选择,但不清掉面板里的搜索与分类', async () => {
renderReplacementWorkbench();
const toolbar = await selectCardAndOpenToolbar('legacy.png');
fireEvent.click(toolbarAction(toolbar, '替换素材'));
const dialog = await screen.findByRole('dialog', {
name: '选择替换素材',
});
const search = within(dialog).getByLabelText('搜索参考图素材');
fireEvent.change(search, { target: { value: 'final' } });
expect((search as HTMLInputElement).value).toBe('final');
act(() => {
observer?.triggerVisible();
});
await pickResourceCard('final.png');
await waitFor(() =>
expect(
within(dialog)
.getByRole('option', { name: '选择替换素材final.png' })
.getAttribute('aria-selected'),
).toBe('true'),
);
// 搜索词还在:同步选择没有顺手把面板筛回「全部 / 空关键词」。
expect(
(within(dialog).getByLabelText('搜索参考图素材') as HTMLInputElement)
.value,
).toBe('final');
});
/**
* 面板锚在**画布容器**里(不是 stage 顶层):相对 stage 写死的偏移会在工具条换行变高时压上去
* —— 与任务开关同一类问题。同时钉住「同一个角只留一块浮层」:打开替换面板会收起任务面板
* (任务本身仍在账本里推进),并显示这次替换的源素材名(非模态之后源身份不再只靠画布光环)。
*/
it('面板锚在画布容器里、写明源素材,并与任务面板互斥', async () => {
renderReplacementWorkbench();
// 先开「生成任务」面板:替换入口与它抢同一个角。
fireEvent.click(screen.getByRole('button', { name: '生成任务' }));
await screen.findByRole('region', { name: '生成任务' });
const toolbar = await selectCardAndOpenToolbar('legacy.png');
fireEvent.click(toolbarAction(toolbar, '替换素材'));
const dialog = await screen.findByRole('dialog', {
name: '选择替换素材',
});
const anchor = dialog.closest('.game-resource-replacement-panel');
expect(anchor).not.toBeNull();
expect(
document
.querySelector('.game-resource-book-manager')
?.contains(anchor as Element),
).toBe(true);
expect(
within(anchor as HTMLElement).getByText(/legacy/u),
).not.toBeNull();
// 同一个角只留一块浮层:任务面板被收起。
await waitFor(() =>
expect(screen.queryByRole('region', { name: '生成任务' })).toBeNull(),
);
});
/**
* 面板里按下「确认」才写入:写入路径与旧的弹窗确认**完全同一条**(同一个 `confirm` 函数),
* 载荷必须逐字一致,成功后关闭面板并标注血缘。
*
* 同时钉住"一次性抑制"的收尾:退出点选后点**同一张**刚被点选过的卡,单击语义必须完好
* 同时钉住"一次性抑制"的收尾:关闭面板后点**同一张**刚被点选过的卡,单击语义必须完好
* (换选中)。抑制残留就会在这一步被吞掉 —— 这是最容易被写错的一处。
*/
it('点选替换:点中合法候选提交一次且载荷一致成功后自动退出点选态', async () => {
it('面板里确认才写入:载荷一致成功后关闭面板并标注血缘', async () => {
const { invoke, onManifestChange } = renderReplacementWorkbench();
const toolbar = await selectCardAndOpenToolbar('legacy.png');
@@ -1162,12 +1244,22 @@ describe('版本级资源替换', () => {
const dialog = await screen.findByRole('dialog', {
name: '选择替换素材',
});
fireEvent.click(within(dialog).getByRole('button', { name: '点选替换' }));
act(() => {
observer?.triggerVisible();
});
await pickResourceCard('final.png');
await waitFor(() =>
expect(
within(dialog)
.getByRole('option', { name: '选择替换素材final.png' })
.getAttribute('aria-selected'),
).toBe('true'),
);
fireEvent.click(
within(dialog).getByRole('button', { name: '确认选择替换素材' }),
);
await waitFor(() => expect(replacementWrites(invoke)).toHaveLength(1));
expect(replacementWrites(invoke)[0]?.[1]).toEqual({
@@ -1187,13 +1279,15 @@ describe('版本级资源替换', () => {
expect.objectContaining({ revision: 6, source: 'asset-command' }),
),
);
// 点选那一下自带的 click 不得换选中:源素材仍是选中的那一个
// 写入成功即结束这次会话:面板收掉,画布选中不动
await waitFor(() =>
expect(screen.queryByRole('dialog', { name: '选择替换素材' })).toBeNull(),
);
expect(
getResourceSelectButton('legacy.png').getAttribute('aria-pressed'),
).toBe('true');
await waitFor(() => expect(pickHint()).toBeNull());
// 退出点选后单击语义不变:点同一张卡照常选中自己,抑制没有残留。
// 会话结束后单击语义不变:点同一张卡照常选中自己,抑制没有残留。
const finalCard = (await findResourceSelectButton('final.png')).closest(
'.game-resource-card',
);
@@ -1210,13 +1304,13 @@ describe('版本级资源替换', () => {
});
/**
* 点选态下点非法目标:说明原因、**留在**点选态、零写入,且点空白既不退出也不清选中
* 非法目标:说明原因、**留在**面板里、零写入。
*
* 四种非法各点一次:源素材本身、不在权威候选里、分类不同的候选(跨栏目点)、
* manifest 里没有的身份(未登记附件)。判据全部来自同一条 `resolveResourceReplacementPick`
* 文案与候选弹窗同源。
* 文案与候选列表同源。
*/
it('点选替换:非法目标不提交、留在点选态并说明原因,点空白不退出', async () => {
it('非法目标:面板里说明原因、面板留着、零写入', async () => {
const { invoke } = renderReplacementWorkbench({
attachments: [
{
@@ -1233,17 +1327,16 @@ describe('版本级资源替换', () => {
const dialog = await screen.findByRole('dialog', {
name: '选择替换素材',
});
fireEvent.click(within(dialog).getByRole('button', { name: '点选替换' }));
const reason = () => within(dialog).getByRole('alert').textContent;
// ① 点的就是源素材本身。
await pickResourceCard('legacy.png');
expect(screen.getByRole('alert').textContent).toBe('替换素材与源素材相同');
expect(pickHint()).not.toBeNull();
await waitFor(() => expect(reason()).toBe('替换素材与源素材相同'));
expect(
getResourceSelectButton('legacy.png').getAttribute('aria-pressed'),
).toBe('true');
// 空白处左键:既不退出点选,也不清画布选中(清选中会连带收起选中工具条)
// 空白处左键:面板不关(非模态面板不该被画布点击吃掉),也不写入
const canvas = screen.getByLabelText('资源依赖视图');
// 指针捕获在 jsdom 里没有实现;这里补桩是为了让"这条按下到底走到哪一步"只由行为断言
// 判定,而不是被一个缺失的 DOM API 提前打断。
@@ -1258,31 +1351,23 @@ describe('版本级资源替换', () => {
clientX: 5,
clientY: 5,
});
expect(pickHint()).not.toBeNull();
expect(
getResourceSelectButton('legacy.png').getAttribute('aria-pressed'),
).toBe('true');
screen.queryByRole('dialog', { name: '选择替换素材' }),
).not.toBeNull();
// ② 不在权威候选里的素材:候选读取之后才登记的资源不属于这次替换目标,一律不放行
// (判据是"候选里有没有",不在前端另算一遍兼容性)。
await pickResourceCard('late.png');
expect(screen.getByRole('alert').textContent).toBe(
'替换素材未登记或已被删除',
);
expect(pickHint()).not.toBeNull();
await waitFor(() => expect(reason()).toBe('替换素材未登记或已被删除'));
// ③ 分类不同的候选:目标在别的栏目,点选会话要跨栏目活着。
// ③ 分类不同的候选:目标在别的栏目,替换会话要跨栏目活着。
await openResourceBookCategory('场景与环境');
expect(pickHint()).not.toBeNull();
act(() => {
observer?.triggerVisible();
});
await pickResourceCard('scene.png');
expect(screen.getByRole('alert').textContent).toBe(
'替换素材不兼容:分类不同',
);
expect(pickHint()).not.toBeNull();
// 非法目标同样不换选中:点选态里卡片单击只用来点选。
await waitFor(() => expect(reason()).toBe('替换素材不兼容:分类不同'));
// 非法目标同样不换选中:面板开着时卡片单击只用来点选。
expect(
getResourceSelectButton('scene.png').getAttribute('aria-pressed'),
).toBe('false');
@@ -1293,31 +1378,23 @@ describe('版本级资源替换', () => {
observer?.triggerVisible();
});
await pickResourceCard('草稿.png');
expect(screen.getByRole('alert').textContent).toBe(
'替换素材未登记或已被删除',
);
expect(pickHint()).not.toBeNull();
await waitFor(() => expect(reason()).toBe('替换素材未登记或已被删除'));
expect(replacementWrites(invoke)).toHaveLength(0);
});
/**
* 点选态的 Esc 只退出点选:资源画布自己的 Esc 挂在 window 上(清画布焦点 = 清选中 +
* 收浮层),提示条这条线必须在 document 上截断它 —— 否则用户按一次 Esc 会连正在替换的
* 选中一起丢。
* 面板开着时的 Esc 只收面板:资源画布自己的 Esc 挂在 window 上(清画布焦点 = 清选中 + 收浮层),
* 面板这条线必须在 document 上截断它 —— 否则用户按一次 Esc 会连正在替换的选中一起丢。
*
* 判据分两层:window 上的监听器收不到这次 Escape(截断生效),且选中工具条仍在。
* 变异验证:去掉 `event.stopPropagation()`,本用例必须失败。
*/
it('点选替换:Esc 退出点选态且不清画布选中', async () => {
it('替换面板Esc 收面板且不清画布选中', async () => {
renderReplacementWorkbench();
const toolbar = await selectCardAndOpenToolbar('legacy.png');
fireEvent.click(toolbarAction(toolbar, '替换素材'));
const dialog = await screen.findByRole('dialog', {
name: '选择替换素材',
});
fireEvent.click(within(dialog).getByRole('button', { name: '点选替换' }));
expect(pickHint()).not.toBeNull();
await screen.findByRole('dialog', { name: '选择替换素材' });
const windowEsc = vi.fn();
window.addEventListener('keydown', windowEsc);
@@ -1328,7 +1405,9 @@ describe('版本级资源替换', () => {
}
expect(windowEsc).not.toHaveBeenCalled();
expect(pickHint()).toBeNull();
await waitFor(() =>
expect(screen.queryByRole('dialog', { name: '选择替换素材' })).toBeNull(),
);
// 选中没有跟着被清掉:源素材仍是选中的那一个。
expect(
getResourceSelectButton('legacy.png').getAttribute('aria-pressed'),
@@ -1,6 +1,6 @@
# AI 游戏创作项目开发工作台 PRD
更新时间:`2026-09-21`2026-09-20 音频生成并入图片类那份后台任务账本、派生/修改类任务并入同一「生成任务」侧栏,2026-09-21 卡片浮层改为「提交即关」,见 §3.10 / §7.92026-09-14 图片类生成后台化:入口 IPC 为 `start_local_project_asset_generation` + 项目内任务账本 + 本地排队 + 非模态「生成任务」面板;2026-09-13 新增的功能画布底部工具栏入口矩阵 §3.10 / §7.9,以及右侧 Supervisor 对话气泡、可访问对比度与过程卡布局收口,资源卡预览、分区布局、非破坏性资源编辑、资源替换与 Godot 双根合同保持不变)
更新时间:`2026-09-21`2026-09-20 音频生成并入图片类那份后台任务账本、派生/修改类任务并入同一「生成任务」侧栏,2026-09-21 卡片浮层改为「提交即关」、**「生成任务」侧栏从画布左侧贴边改为画布右上角锚点(开关常驻)**、**资源卡可拖到对话栏批量 @ 引用**、**替换面板改为非模态浮层并支持在画布上点选目标**(验收现场两条修正:任务锚点改挂画布那一格、展开后开关让位),见 §3.10 / §5.3 / §7.8 / §7.9 与 [`【功能说明】AGC聊天素材引用`](../【功能说明】AGC聊天素材引用-2026-09-08.md)2026-09-14 图片类生成后台化:入口 IPC 为 `start_local_project_asset_generation` + 项目内任务账本 + 本地排队 + 非模态「生成任务」面板;2026-09-13 新增的功能画布底部工具栏入口矩阵 §3.10 / §7.9,以及右侧 Supervisor 对话气泡、可访问对比度与过程卡布局收口,资源卡预览、分区布局、非破坏性资源编辑、资源替换与 Godot 双根合同保持不变)
## 1. 产品定位
@@ -164,7 +164,7 @@
- 位置与层级:画布左下角(`left: 14px; bottom: 14px; z-index: 40`)。右下角是既有的缩放 / 撤销 Dock(`right: 14px; bottom: 14px`),左下角是画布上唯一两者都不占的稳定空位。工具栏是管理区 `.game-resource-book-manager` 的**直接子节点**、与画本场景并列,不进带 `scale()` 的场景层;二级菜单与「入口不可用原因」都贴着工具栏上沿弹出,不做内嵌内容。
- 外壳用共享 chrome`packages/image-canvas-react``CanvasToolbar / CanvasToolbarGroup / CanvasChromeButton`),样式落在 AGC 的 `resourceCanvasChrome.css`;共享包只承接通用表现,不含业务规则。
- 接线:图片类入口走本地 IPC `start_local_project_asset_generation``kind``image / character / spec / icon-spec / ui-prototype / art-spritesheet`;**提交即返回任务记录**,生成由 Rust 后台任务跑完写回项目,进度用 `list_local_project_asset_generations` 读回项目内账本 `.agent/runtime/asset-generation-tasks/tasks.json`);音频入口也走 `start_local_project_asset_generation`(同一份项目内任务账本;`kind` = `sound-effect` / `background-music`,并额外携带该次生成的请求身份 `idempotencyKey`,任务 id 即该次生成的 operation id;生成仍复用既有音频无源生成链路,`generationMode: 'create'``editKind` = `sound-effect` / `background-music`,不新增平台路由与请求体口径);「上传」复用 `upload_local_asset`。生成 / 上传成功后一律用「配对读 `(revision, manifest)`」交给 `onManifestChange`,走既有 manifest 刷新与资源投影链路,不重算依赖图、不另写布局。
- 本地排队与进度可见:AGC 本地 durable 输出槽已按**精确动作指纹**分槽(不同 prompt / 素材名各自独立成槽,具备并行能力),但本批前端仍按「同一时刻只派发一条」排队——真并行派发需要并发收口设计(配对读 + manifest CAS + 聚焦意图互不覆盖),留待下一批;所以第一条未终态时第二条提交停在**前端本地队列**里(不调用提交 IPC,显示本地排队的「排队中。」),前一条终态后自动补发;任务状态与阶段文案(后端 `phaseDetail`)由任务账本提供,前端不拼阶段、不做百分比。进度面是**画布上常驻的可折叠任务侧栏**(形态对齐网页端美术画布的任务侧栏):展开是两个分栏「排队/生成中」与「已完成」(各带条数,「已完成」封顶 20 条 + 列表滚动 + 高度有界),关闭入口只保留头部那一枚 ×(底部重复的关闭按钮与其分割线已删除)、折叠即整块让出画布且不留贴边把手,开合只走工具条上常驻的「生成任务 · N」按钮(两个页签下都在;每项显示状态徽标 / 阶段文案 / 已耗时 / 素材名,可「定位到素材」。侧栏非模态(不铺全屏遮罩、不做焦点陷阱、不参与模态遮挡判据),位置在画布左侧标题栏之下、**覆盖式**(不 reflow 挤窄画布视口),提交受理后自动展开。定位动作**每次点击都终局化**:能定位就定位并选中;素材在别的栏目先切栏目;不在投影里给「素材已不在项目里 / 已登记但尚未同步」的结论;3 秒内有界兜底,不允许提示条永久停在「正在定位生成的素材…」。
- 本地排队与进度可见:AGC 本地 durable 输出槽已按**精确动作指纹**分槽(不同 prompt / 素材名各自独立成槽,具备并行能力),但本批前端仍按「同一时刻只派发一条」排队——真并行派发需要并发收口设计(配对读 + manifest CAS + 聚焦意图互不覆盖),留待下一批;所以第一条未终态时第二条提交停在**前端本地队列**里(不调用提交 IPC,显示本地排队的「排队中。」),前一条终态后自动补发;任务状态与阶段文案(后端 `phaseDetail`)由任务账本提供,前端不拼阶段、不做百分比。进度面是**画布上常驻的可折叠任务侧栏**(位置与开合形态照抄网页端美术画布的任务侧栏的右上角锚点,颜色与外形仍走 AGC 平台 token2026-09-21 由「左侧贴边 + 工具条入口」改为「右上角锚点 + 常驻开关」):展开是两个分栏「排队/生成中」与「已完成」(各带条数,「已完成」封顶 20 条 + 列表滚动 + 高度有界),关闭入口只保留头部那一枚 ×(底部重复的关闭按钮与其分割线已删除)、折叠即整块让出画布、只留那一枚右上角开关(工具条上不再有重复入口),开合只走画布右上角那一枚「生成任务 · N」开关(两个页签下都在;**展开后开关让位、只留面板**,收起走面板头部 × 或点画布外部);锚点是画布那一格网格里的条目(不是写死 `top` 的绝对定位),工具条换行变高也不会压上去;每项显示状态徽标 / 阶段文案 / 已耗时 / 素材名,可「定位到素材」。侧栏非模态(不铺全屏遮罩、不做焦点陷阱、不参与模态遮挡判据),位置在画布右上角锚点里(照抄美术画布那一处)、**覆盖式**(不 reflow 挤窄画布视口),提交受理后自动展开。锚点按工作面分档:资源栏目画布与 UI 编辑器用画布顶边那一档,运行表现层下移让开右上角的版本入口;锚点是**画布那一格网格里的条目**(不是写死 `top` 的绝对定位),所以工具条换行变高也不会压上去。定位动作**每次点击都终局化**:能定位就定位并选中;素材在别的栏目先切栏目;不在投影里给「素材已不在项目里 / 已登记但尚未同步」的结论;3 秒内有界兜底,不允许提示条永久停在「正在定位生成的素材…」。
- 面板形态:独立浮层(`ThemedModal`),**不在当前面板下面追加内容**;面板内不写功能说明或规则解释文案。**点「生成」即同步关闭面板**(不等 IPC、不等排队、不等生成),面板里**不出现**「排队中。」「正在生成。」「提交中…」这类阶段文案——阶段文案的唯一去处是任务侧栏与工具栏提示条。**只有「点击瞬间就失败」**(校验不过、权限拒绝、start IPC 立即报错)才自动重开面板并带回草稿与原因;**受理之后才失败**只在侧栏把该任务收口为失败 + 原因,不重开面板。关闭 ≠ 取消请求(请求挂在任务与账本上,不挂在面板生命周期上)。
- 参数口径:比例 / 尺寸选项来自网页端美术画布的纯模型(`src/components/image-editor/ImageCanvasGenerationModel.ts`),并按本地 IPC 白名单收窄(本地通道明确拒绝 `4:3`);默认档 `1:1 · 1K`,生成 UI 设计图沿用网页端 UI 设计面板的默认 `16:9 · 1K`。本地 IPC 没有 `model` 入参,因此面板**不渲染模型选择器**(渲染一个改不了请求的控件就是假控件)。
- 规范类和固定档:生成规范下的图标规范 / 角色规范 / 自定义规范都是固定档——只读展示当前规格,不给点了不生效的比例控件。本地 IPC 只有一条规范通道(`spec` 在 Rust 侧收口到 `icon-spec`),**没有 `specType` 入参**,因此角色规范与自定义规范共用该通道,靠素材名与提示词区分。
@@ -448,7 +448,7 @@ type ProjectVersionResourceReplacement = {
- **失败即拒绝**:硬门禁任一项不等、源版本不存在、源版本未绑定该素材、替换素材未登记、替换素材与源素材相同、`expectedProjectId` / `expectedProjectRevision` CAS 冲突,都必须在写入前拒绝并说明原因;拒绝时 manifest、`versions` 与项目 revision 都不变,投影里不得出现新的版本卡或新的"当前使用"高亮。
- **落盘与留痕**:改绑定属于 `versions` 变化,写入后推进一次 `projectRevision`;并追加一条 `asset.version_binding.replace` 审计(`versionId / sourceResourceId / replacementResourceId / projectRevision`)。**没有可回溯的替换历史**:替换前的身份只有这条审计与 manifest 的 `.previous` 恢复副本。
- **入口与可见效果**:入口在资源卡选中工具条,只对「manifest 资产 + 被当前版本绑定」的素材放行;候选弹窗列出全部候选,命中硬门禁的项渲染但禁用并给出原因,只有尺寸规格差异的项仍可选但带提示。成功后**不切换版本**(没有新版本可切),只重读 manifest;可见变化是资源卡"当前使用"高亮移到替换素材、`@` 面板"当前版本素材"更新。按 §7.4 的口径,改绑定不做运行时资源重映射,运行画面本身不会因改绑定而变化;也不自动重载 / 重启运行中的预览(§3.2 末条)。
- **点选替换(2026-09-13 补充)**:候选弹窗 footer 提供「点选替换」,用于"目标就在画布上、不想在面板里筛"的场景。点它**必须关闭弹窗**(弹窗外壳是全屏遮罩,留着它画布上的卡点不到)并进入画布点选态,画布上出现提示条(「在画布上点选要替换成的素材;点击空白处不会退出」+「取消」)。点中合法候选直接走**同一条**写入链路(载荷与弹窗确认逐字一致,不产生第二套替换口径),成功后自动退出点选态;点中非法目标(分类 / 类型不同、不在后端给出的权威候选里、源素材本身、未登记资源)**不写入**,在提示条上说明原因(与候选弹窗同一条翻译口径)并**留在**点选态。退出只有条路:Esc(在 `document` 阶段截断,画布全局 Esc 的清选中不随之触发)、提示条「取消」、替换成功;**空白点击不退出、也不清画布选中**。点选态下卡片左键只用于点选(不起拖拽、不换选中,靠一次性抑制而不是全局关掉单击),滚轮平移`Ctrl/Cmd` 缩放照常可用。
- **替换面板在画布上点选目标(2026-09-212026-09-13 的「点选替换」由此取代)**:候选面板是画布右上角的**非模态浮层**(无全屏遮罩、不做焦点陷阱,面板自身限高 + 内部滚动),面板开着的时候画布照常可点——在画布上点目标素材即把它落成**面板里的当前选择**(面板随之高亮;用户在面板里已输入的搜索与分类筛选不被重置),写入仍然只由面板「确认」发起(载荷与弹窗确认逐字一致,不产生第二套替换口径)点中非法目标(分类 / 类型不同、不在后端给出的权威候选里、源素材本身、未登记资源)**不写入**,在面板里说明原因(同一条翻译口径)并保留原选择。退出只有条路:面板「取消」/ 头部 ×、Esc(在 `document` 阶段截断,画布全局 Esc 的清选中不随之触发);替换成功即关闭面板。面板开着时卡片左键只用于点选(不起拖拽、不换画布选中,靠一次性抑制而不是全局关掉单击),滚轮平移`Ctrl/Cmd` 缩放与空白处框选照常可用。
- **替换血缘标注(2026-09-13 补充)**:替换成功后只改绑定、光环本来就会从源素材移到替换素材,光靠光环说明不了"谁换成了谁",所以在卡面补一层**关系标注**:源素材卡标「已被 <替换素材显示名> 替换」,替换素材卡标「替换自 <源素材显示名>」,两处分别给出稳定 DOM 判据 `data-resource-replaced-by` / `data-resource-replacement-of`,值是对面资源的 **manifest 资产 id**(不是显示名)。这份关系是**宿主会话内状态**:同一会话里再次替换**整条覆盖**上一条(只保留当前有效的一条 A→B,不做历史链,与 §7.8「只展示当前有效关系」同口径);**切换项目 / 关闭工作台 / 重新加载 manifest 都不保留**——客户端没有替换关系的读侧事实源(写入结果只回 `versionId` / `committedProjectRevision`,审计 `asset.version_binding.replace` 只有写侧),manifest 的 `.previous` 只是原子安装的崩溃兜底副本、安装成功即删,不能当数据源,所以本标注**只在本次会话有效,不假装持久**。
### 5.4 游戏迭代版本(P1
@@ -657,7 +657,7 @@ type ProjectAgentMudPointAttribution = {
5. 硬门禁任一为 false、源版本不存在、源版本未绑定该素材、替换素材未登记、替换素材与源素材相同、`expectedProjectId` / `expectedProjectRevision` 冲突,都必须在写入前拒绝;拒绝时 manifest、`versions` 与项目 revision 都不变,投影里不得出现新的"当前使用"高亮或版本卡变化。
6. 成功后重读 manifest 并**不切换版本**(没有新版本可切);**不**自动重载或重启运行中的预览(§3.2 末条),也不做运行时资源重映射。可见变化只有:资源卡"当前使用"高亮移到替换素材、`@` 面板"当前版本素材"更新。
7. 替换成功后追加一条 `asset.version_binding.replace` 审计(`versionId / sourceResourceId / replacementResourceId / projectRevision`);**替换历史不可回溯**——替换前身份只有这条审计与 manifest 的 `.previous` 副本,不得声称能查到"某版本历史上换过什么"。
8. 候选弹窗提供「点选替换」(2026-09-13):点它关闭弹窗进入画布点选态,并把「在画布上点选要替换成的素材;点击空白处不会退出」与「取消」放在画布提示条上;点中合法候选按第 3~7 条**同一条**链路提交并自动退出点选态,点中非法目标(分类 / 类型不同、不在权威候选里、源素材本身、未登记资源)零写入、留在点选态并在提示条上说明原因。退出只有 Esc`document` 阶段截断,不清画布选中)/ 提示条「取消」/ 替换成功三条路,空白点击不退出;点选态下滚轮平移缩放照常,卡片单击的既有语义在非点选态下不变。
8. 替换面板在画布上点选目标(2026-09-21;取代 2026-09-13 的「点选替换」):候选面板是非模态浮层,面板开着时画布保持可点,点画布上的候选即落进面板的当前选择(搜索与分类筛选不被重置),写入仍由面板「确认」按第 3~7 条**同一条**链路发起;点中非法目标(分类 / 类型不同、不在权威候选里、源素材本身、未登记资源)零写入并在面板里说明原因。退出只有面板「取消」/ × 与 Esc`document` 阶段截断,不清画布选中)两条路,成功后自动关闭;面板开着时滚轮平移缩放与空白框选照常,卡片单击的既有语义在面板关闭后不变。
9. 替换血缘标注(2026-09-13):替换成功后,源素材卡带「已被 <替换素材显示名> 替换」、替换素材卡带「替换自 <源素材显示名>」,稳定 DOM 判据 `data-resource-replaced-by` / `data-resource-replacement-of` 的值为对面资源的 manifest 资产 id;未参与替换的资源卡两个属性都不出现。同一会话内再次替换时**只保留当前有效的一条 A→B**(旧标注从卡上消失,不做历史链)。这份标注**只在本次会话有效**:切换项目 / 关闭工作台 / 重新加载 manifest 都不保留,也不得声称可回溯(第 7 条仍成立)。
### 7.9 P1 功能画布底部工具栏验收
@@ -1,5 +1,57 @@
# 决策记录
## 2026-09-21 本批自查(PR #441):三处修正
- 背景:推 PR 后按「局部到整体」自查这一批(三需求 + 验收修正),查出三条:①拖动到对话的落点在 `pointermove` 上每帧都 `setState` 一个新对象;②替换面板相对 **stage** 写死 `top: 8.5rem`(与刚修的任务开关同一类隐患:工具条换行会压上去),且它和「生成任务」面板抢画布右上角同一个位置;③替换面板不显示「在替换哪张源素材」,而非模态化之后那点线索(画布上的源素材光环)会被一次空白点击清掉。
- 决策①:落点状态改为**逐值比较**(`sameResourceCardReferenceDrop`:条数 + 对话栏矩形四值),只有真的变了才落 state——指针在对话栏上移动不再每帧重渲染整个工作台。
- 决策②:面板从 stage 顶层移进**画布容器** `.game-resource-book-manager`(它本身就是 `position: relative`,与左下角工具栏、右下角 Dock 同一套锚定口径),`top: 3.2rem` 排在任务开关下方;并在打开替换会话时**收起「生成任务」面板**——画布右上角同一时刻只留一块浮层(任务照旧在账本里推进,收起只影响这个视图)。
- 决策③:面板上方补一行「替换源素材:<显示名>」(投影显示名优先、manifest 资产名回落),源身份不再只靠画布光环。
- 验证:`resourceVersionReplacement.test.tsx` 20 passed(新增「面板锚在画布容器里、写明源素材、并与任务面板互斥」)、`resourceCardReferenceDropModel.test.ts` 6 passed(新增逐值比较)、`resourceCanvasChatReferenceDrop.test.tsx` 4 passed`tsc -p apps/ai-game-creator-shell/tsconfig.json --noEmit` 通过。
- 影响范围:`apps/ai-game-creator-shell/src/view/project-development/{index.tsx,resourceCardReferenceDropModel.ts}``.../features/resource-canvas/resourceCanvasChrome.css`、用例 3 个文件、验收用例 C9 注脚。未动 Rust / SpacetimeDB / 共享组件行为(只新增了宿主的源素材行)。
- 已知未覆盖:仍无真机目视;替换会话与任务面板的互斥是新行为,客户端需要复看一次(若产品希望两者能同时看,把这一句收紧即可)。
## 2026-09-21 验收现场修正:任务开关压在工具条上、开关与面板并排
- 背景:客户端验收截图两条:①画布右上角那枚「生成任务 · N」开关与工具条(打开项目目录 / 资源面板 / 整理画布 / 管理未完成编辑 / 依赖 · 类型)**重叠**;②「生成任务」面板展开时,开关与面板**并排**摆着,产品口径是「这俩不应该并排,出来详情以后入口就应该隐藏」。
- 决策(重叠):锚点不再用 `position: absolute` + 写死的 `top: 3.5rem`(工具条是 stage 第一行,高度随按钮换行变化,写死的偏移迟早压上去),改成 **stage 网格里与工作面同一个单元格的另一个条目**`grid-row``placement` 分档(资源画布 3 / UI 编辑器 2 / 运行表现层 `2 / -1`+ `grid-column: 1` + `justify-self: end` + `align-self: start` + `margin: 0.85rem`(运行那档上边距 3.5rem 让开右上角版本入口)。为此把同格的三处容器也显式钉住第 1 列(`.game-workbench-stage > .game-resource-manager``.…[data-resource-view-state='resources.ui-editor'] > .game-workbench-editor-shell``.game-run-surface`)——锚点是显式定位条目,画布若走自动列放置会被挤进隐式第二列、画布直接压窄一半。
- 决策(并排):开关只在**完全收起**时渲染(`!open && phase === 'idle'`,收起动画期间也不画,否则那 160ms 又会同框);展开态画布右上角只有面板,收起走面板头部那枚 × 或点画布外部。原来「点画布外部自动收起」的判据里对开合按钮的排除保留(收起态仍靠它开合)。
- 原因:验收口径优先于「照抄美术画布」——美术画布把开关常驻在面板旁边,但产品要的是「入口与详情不同时出现」。重叠那条的根因是**猜了一个绝对偏移量**:工具条高度不是常量,锚点必须由布局自己推导。
- 验证:`resourceCanvasAssetGenerationTasksPanel.test.tsx` 12 passed(新增「展开时开关让位:同一时刻只有面板那枚 ×」;计数用例改为收起态查开关、展开态查面板头部)、`resourceCanvasAssetGenerationTasksSidebarStyle.test.ts` 11 passed(锚点改成网格条目坐标:`grid-row` / `grid-column` / `justify-self` / `align-self` / `margin` 与运行档上边距;窄屏改判 `justify-self: stretch`)、`resourceCanvasGenerationTasksSidebarDismiss.test.tsx` 5 passed(收起改走面板 ×、收起后开关回来能再打开、锚点仍在 stage 里)、`resourceCanvasAssetGenerationBackgroundClose.test.tsx` 5 passed`appSurface.test.ts` 538 passed / 17 skipped / 0 failed。
- 影响范围:`apps/ai-game-creator-shell/src/features/resource-canvas/{ResourceCanvasAssetGenerationTasksPanelView.tsx,resourceCanvasAssetGenerationTasksSidebar.css}``apps/ai-game-creator-shell/src/styles.css`、三个同场景用例文件、PRD §3.10 与更新时间。
- 已知未覆盖:仍无真机目视(网格落点在 Tauri 里的实际观感、工具条换行时锚点是否仍贴画布顶边需要现场复核)。
## 2026-09-21 AGC 替换面板改为非模态浮层,支持在画布上点选目标
- 背景:`docs/project-memory/todos/【待办】画布验收后续修复-2026-09-18.md` 的「后续需求」要求「在替换面板中支持画布点选目标」。2026-09-13 那版做的是「候选弹窗 footer 一个『点选替换』按钮 → 关掉弹窗 → 进画布点选态 → 点中即提交」:面板与画布互斥(弹窗外壳是全屏遮罩,留着它画布上的卡点不到),用户要么在面板里筛、要么离开面板。
- 决策:候选面板改成**非模态浮层**(共享组件新增 opt-in `nonModal`:不铺遮罩、不做焦点陷阱、面板自身限高 + 内部滚动、Esc 在 `document` 阶段截断后取消;网页端美术画布不传,弹窗行为逐字不变)。AGC 把它锚在画布**右上角、任务开关下方**(`.game-resource-replacement-panel``top: 8.5rem; right: 0.85rem`,壳样式在共享样式表里,宿主只负责锚定)。**面板开着时画布照常可点**:点中合法候选即落成面板里的当前选择(面板里随之 `aria-selected`),写入仍然只由面板「确认」发起;点中非法目标在面板里说明原因、零写入。旧的「点选替换」入口、画布提示条 `.game-resource-canvas-pick-hint``resourceReplacementPickMode` 随之退役(同一个功能不留两条 UI 路径)。
- 决策(同步口径):`selectedAssetIds` 的**数组引用不能当同步信号**(调用方每次渲染都会重建它,放进依赖会清掉用户在面板里的选择——组件里原本就为此写过一段注释),所以新增显式序号 `initialSelectionRevision`:只有宿主真的换了目标才重同步选择,搜索词与分类筛选保持原样。`confirmResourceVersionReplacement` 另外拿 `resourceReplacementPick` 兜底:点完画布当帧就按确认时不该报「请选择一个替换素材」。
- 原因:面板与画布是同一屏的两半——目标本来就在画布上,「先把面板关掉再点」是弹窗外壳带来的妥协,不是产品意图。换成非模态之后,合法性判据仍是同一条 `resolveResourceReplacementPick`、写入仍是同一个 `confirm` 函数(载荷逐字一致),所以这是**外壳**的改动,不是第二套替换实现。
- 已知取舍(相对旧口径的行为变化,均已随测试钉住):面板开着时空白处点击不再被吞——画布恢复正常的清焦点 / 框选 / 平移语义(源身份在打开面板时就已冻结,替换不依赖画布选中);「点中即提交」不再存在,提交一律由「确认」触发;关闭面板后卡片单击语义原样(一次性抑制照旧收尾)。
- 验证:`resourceVersionReplacement.test.tsx` 19 passed(4 条旧点选用例改写为:非模态判据 + 点画布候选落进面板且零写入、面板确认才写入且载荷逐字一致+血缘、四类非法目标面板内报因零写入、Esc 只收面板不清选中;新增「画布点选不清掉面板搜索/分类」)、`projectAssetPickerDialogShellStyle.test.tsx` 7 passed(新增 nonModal 形态与浮层壳样式声明,含关掉即卸载)、`src/components/image-editor/ImageCanvasEditorView.test.tsx` 64 passed、`ImageCanvasEditorGenerationIntegration.test.tsx` 44 passed(网页端弹窗行为未变);连同 `appSurface.test.ts``projectResourceLiveIntegration.test.tsx` 共 6 个文件 694 passed / 17 skipped / 0 failed`tsc -p apps/ai-game-creator-shell/tsconfig.json --noEmit` 通过。
- 影响范围:`src/components/image-editor/ImageCanvasProjectAssetPickerDialog.tsx``packages/shared/src/components/styles.css``apps/ai-game-creator-shell/src/view/project-development/index.tsx``.../features/resource-canvas/resourceCanvasChrome.css``apps/ai-game-creator-shell/tests/{resourceVersionReplacement.test.tsx,projectAssetPickerDialogShellStyle.test.tsx}`、PRD §5.3 / §7.8 第 8 条、`docs/technical/【测试用例】AGC资源工作台V3端到端验收-2026-09-11.md` 的 S15a。未动 Rust、SpacetimeDB、external v1。
- 已知未覆盖:真机观感(面板在画布右上角的落点、与任务开关同时打开时的间距)未在 Tauri 目视确认;面板里不显示「本次替换的是哪张源素材」,源身份只靠画布上那张卡的选中光环表达——点空白清掉选中后就只剩面板自己的候选列表。
## 2026-09-21 AGC 资源卡拖到对话实现批量 @ 引用
- 背景:`docs/project-memory/todos/【待办】画布验收后续修复-2026-09-18.md` 的「后续需求」要求「聊天拖拽批量引用」。改前只有两条入口:聊天输入框里输入 `@` 或点 `@` 按钮开素材选择面板,以及资源卡选中工具条上那枚「引用」按钮——都要先把素材找出来再点,多选批量引用没有一次成型的路径。
- 决策:资源卡**按住拖到右侧 Agent 对话栏、松手即批量引用**。落点判据是「指针是否在对话栏矩形内」:pointermove 在对话栏上时语义从排版切成引用(卡片不再跟着指针走,改铺一层虚线落点浮层 + 「松手即可 @ 引用 N 项素材」),拖回画布内松手仍然是原来的排版语义(照旧写手动坐标)。批量范围与拖动位移**同一集合**(`drag.moves`):多选后拖任意一张 = 整批引用,拖未选中的卡 = 只引用它自己。
- 决策(引用构造与派发):引用构造收敛成纯函数 `resourceCardReferenceDropReferences`,口径与工具条「引用」按钮**逐字一致**——只认已登记 manifest 的素材(`manifestAssetId`)、`source: 'resource-card'`、kind / 分类 / 标签取自资源投影,于是同一素材从两处进来是同一枚引用(去重键同样一致)。派发走新增的批量事件 `RESOURCE_REFERENCE_INSERT_MANY_EVENT``dispatchResourceReferenceInsertMany`):N 条引用一次事务插进草稿、只聚焦一次,不逐条重建草稿。一条也构造不出来时(选中的素材都未登记)不静默:提示条说明原因。
- 原因:拖动本来就在指针捕获下走,指针跑到画布外仍回到卡片 handler,因此「落点」只能自己量;不落盘是因为对话栏那一段没有画布坐标可言(写下去会得到跑到画布外的坐标),而且用户在对话栏上松手的意图本来就不是排版。`0 x 0` 的对话栏矩形必须判成「没有落点」:零面积矩形会让任何点都命中,画布内正常拖动会被整段跳过。
- 验证:`resourceCardReferenceDropModel.test.ts` 5 passed(矩形/命中/构造/提示文案)、`resourceCanvasChatReferenceDrop.test.tsx` 4 passed(单卡拖到对话 → 1 条引用且零坐标写入、多选整批 → 2 条、拖回画布 → 照旧写手动坐标且零引用、pointercancel 收干净)、`resourceReferenceInput.test.tsx` 30 passed(新增批量事件一次派发且空批次不派发、一次 insertReferences 按序插入整批 chip);连同 `appSurface.test.ts` 在内 7 个用例文件 660 passed / 17 skipped / 0 failed`tsc -p apps/ai-game-creator-shell/tsconfig.json --noEmit` 通过。
- 影响范围:`apps/ai-game-creator-shell/src/view/project-development/{index.tsx,resourceCardReferenceDropModel.ts}``.../features/project-workspace/resourceReferences.ts``apps/ai-game-creator-shell/src/App.tsx``apps/ai-game-creator-shell/src/styles.css``apps/ai-game-creator-shell/tests/{resourceCardReferenceDropModel.test.ts,resourceCanvasChatReferenceDrop.test.tsx,resourceReferenceInput.test.tsx}``docs/【功能说明】AGC聊天素材引用-2026-09-08.md`。未动 Rust、SpacetimeDB、`packages/`、共享弹窗组件。
- 已知未覆盖:真实客户端里的手感(拖到对话栏的触发距离、提示条位置、多选整批的视觉反馈)未在 Tauri 目视确认;「复制对话保留有效引用」属于同一条后续需求里的另一半,本轮未做。
## 2026-09-21 AGC「生成任务」侧栏移到画布右上角(照抄美术画布,保留 AGC 样式)
- 背景:`docs/project-memory/todos/【待办】画布验收后续修复-2026-09-18.md` 的「后续需求」要求「生成任务列表移到画布右上角,进行中/已完成分组、失败可见、限高滚动及自动开合」。改前状态:侧栏本体是 `position: fixed; top: 4rem; bottom: 6rem; left: 0.75rem` 的左侧贴边面板,开合口只有工具条上那一枚「生成任务 · N」按钮(资源 / 运行两个页签各渲染一次),折叠态不留任何常驻入口;分组、失败可见、限高滚动、提交后自动展开这四条当时已经具备。
- 决策:侧栏改挂**画布右上角的锚点**(`.game-resource-generation-tasks-anchor`),形态照抄网页端美术画布的任务侧栏(`ImageCanvasTaskSidebarView.tsx` + `src/index.css:6072+``.image-canvas-editor__task-sidebar*`):**开关常驻右上角、面板在开关左侧展开**,收起态只剩那一枚开关;工具条上那两处重复入口删掉——同一个功能两个入口本身就是两处随时会漂移的状态。锚点是覆盖式的,仍然不 reflow 画布视口。颜色、圆角、字重、阴影**全部继续走 `--platform-*` token**,不照搬网页端的固定色值(该 CSS 文件头既有的口径)。
- 原因:右上角是画布上唯一「不被右侧『智能创作』对话面板占、也不与左下角栏目工具栏 / 右下角缩放 Dock 打架」的稳定空位;折叠态仍留一枚开关以后,用户不必先想起工具条在哪一行,也不再需要「关掉以后打不开」的兜底(原设计正是靠工具条入口常驻来解决这个问题)。
- 决策(分档坐标):锚点由 view 的 `placement``canvas | run | editor`)落成 `data-generation-tasks-placement`,坐标在样式里分档:资源栏目画布与 UI 编辑器用画布顶边那一档(`top: 3.5rem; right: 0.85rem`),**运行表现层下移到 `top: 7rem`**——它右上角 `top: 20px; right: 20px` 被 C7 版本入口 `game-run-version-picker` 占着,不去抢那一块。
- 实现要点:开关改由 view 自己渲染(`data-resource-generation-task-toggle` 与计数属性原样保留,可访问名 `生成任务` 唯一),在途计数只在 view 内算一次、开关与面板头部同源(宿主原先那份 `resourceAssetGenerationInFlightCount` 因此删除);面板改由 `max-height: min(30rem, calc(100vh - 12rem))` 封顶 + 内部滚动,自己不再定位(坐标只由锚点一处决定);进场 / 退场动画位移方向跟着锚点翻到正 X。宿主的「点外部收起」判据(排除侧栏本体与 `data-resource-generation-task-toggle`)与「提交受理后自动展开」均未改。
- 验证:`resourceCanvasAssetGenerationTasksPanel.test.tsx` 11 passed(新增「折叠只剩右上角开关」「开关计数与面板头部同源」「锚点按工作面分档」)、`resourceCanvasAssetGenerationTasksSidebarStyle.test.ts` 11 passed(锚点坐标 / 运行档下移 / 面板不再自定位 / 开关走 token 且无硬编码色 / 窄屏占满宽度)、`resourceCanvasGenerationTasksSidebarDismiss.test.tsx` 5 passed(入口位置改为右上角锚点、工具条不再有生成任务按钮)、`resourceCanvasAssetGenerationBackgroundClose.test.tsx` 5 passed`appSurface.test.ts` 538 passed / 17 skipped / 0 failed(与既有基线逐条一致);`tsc -p apps/ai-game-creator-shell/tsconfig.json --noEmit` 通过。
- 影响范围:`apps/ai-game-creator-shell/src/features/resource-canvas/{ResourceCanvasAssetGenerationTasksPanelView.tsx,resourceCanvasAssetGenerationTasksSidebar.css}``apps/ai-game-creator-shell/src/view/project-development/index.tsx``apps/ai-game-creator-shell/tests/{resourceCanvasAssetGenerationTasksPanel.test.tsx,resourceCanvasAssetGenerationTasksSidebarStyle.test.ts,resourceCanvasGenerationTasksSidebarDismiss.test.tsx}`、PRD §3.10。未动 Rust、SpacetimeDB、`packages/`、共享弹窗组件。
- 已知未覆盖:真实客户端观感(右上角坐标相对画布顶边的落点、与运行表现层版本入口的间距)未在 Tauri 里目视确认;窄屏(≤480px)只有声明级断言。
## Unity 与 Godot 常用操作指导
两种编辑器的操作指导复用客户端审核 Skill packDirectProject 通过原生 Skill 或既有审核资源读取入口按需取得,Agent Runtime 的对应执行工具说明嵌入同源参考。指南不改变插件可用性、执行授权或 Runner 回执;只读说明不能证明编辑器已连接。常用示例与执行失败/部分修改、保存、撤销边界在同一参考中维护,避免提示词和文档各存一份代码。
@@ -28,6 +80,7 @@ Godot 编辑器操控复用既有 AGC 插件宿主、EditorAdapter、Runner 和
- 捆绑 Codex 固定版本只在 `build_support/codex_bundle.rs` 声明一次(当前 0.155.1),构建期侧车清单、宿主补丁执行器身份、逐次审批协议允许列表和模型目录捕获共同引用;升级原生依赖时同步重取同一 tag 的 vendor 解析源码与 UPSTREAM 证据,并复跑真实目录、补丁往返与并发夹具。0.155 起原生执行入口改为统一 exec(`exec_command` + `write_stdin`,旧 `shell_command` 不再注册),宿主许可与预算照常覆盖。
- 付费许可按原回合原租约绑定并传递到实际提交点:容量与同动作锁等待可取消,每次新增 POST 前与封口共用短锁复核,封口/终止/耗尽后零新增提交;已越过提交边界的请求不丢弃,保留 operation ID 与不确定状态走 GET 对账。本地写入同理,等待项目锁后必须复核原许可,未结算或失败的写入围栏未恢复前不得封口。
- 权威合同:[AI 游戏创作智能体 App 实施计划](../../technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md)。
## 2026-09-20 最近项目检查保持项目级隔离
- 背景:最近项目刷新会重新检查所有路径。若其中一个目录损坏、超时或不可读,清空整张状态表会让已确认正常的项目暂时全部显示“检查中”,用户只能移除坏项目后看到列表恢复。
@@ -1,9 +1,5 @@
# 踩坑与排障记录
## Jenkins Windows 节点的 PATH 白名单决定 Godot 原生扩展能否构建
`Genarrative-Agc-Windows-Build` 在阶段里用 `AGC_WINDOWS_PATH` 整体替换 PATH、不继承节点机器的 PATH,所以 Godot C++ 引导需要的 CMake 与 Python 必须显式写进这份白名单,装在机器 PATH 上并不生效。2026-09-21 的 #97#99 连续失败都停在 `Get-Command cmake.exe`#93#96 是更早的手写 C ABI 在 MSVC C 模式下的对齐问题):节点只有 Visual Studio Build Tools`C:\BuildTools`)自带的 CMake 3.31,缺 Python 3。修复后白名单包含 `C:\BuildTools\Common7\IDE\CommonExtensions\Microsoft\CMake\CMake\bin``C:\Python312``C:\Python312\Scripts`preflight 校验 CMake ≥3.25、Python 3 和 Visual Studio 17 2022 生成器;把 `cmake.exe` 单独复制到别的目录会丢掉 `share/cmake-*/Modules`,不能替代加入安装目录。新节点的 Python 用 `python-3.12.10-amd64.exe /quiet InstallAllUsers=1 TargetDir=C:\Python312 PrependPath=1 Include_launcher=1 InstallLauncherAllUsers=1` 静默安装即可,CMake 不必另装。
## Tauri `--no-sign` 会连带跳过 updater 签名
AGC macOS 发布入口一度传入 `--no-sign`(目的是绕过没有 Apple 证书的代码签名),结果 Tauri 打印 `Warn Updater signing is skipped due to --no-sign flag.`,产物只有 `*.app.tar.gz` 而没有 `.sig`,发布入口按设计在「缺少更新包签名」处失败关闭(2026-09-20 首次 Jenkins 实跑命中)。正确做法是不传 `--no-sign`,改为剥离 `APPLE_*` 凭据让 Tauri 跳过 Apple 签名——minisign 更新包签名与 Apple 代码签名这两个开关在 Tauri 里并不独立。Apple 签名状态要按 `codesign -dv` 实测记录,不能硬编码。
@@ -55,11 +55,32 @@
- 范围核对:48 个改动文件全部落在 AGC 前端、3 个共享画布组件与 3 份文档;`server-rs` / `spacetime` / `docs/openapi` / `src-tauri` **零改动**(后端与契约无红线涉及),无 `.env`、密钥、构建产物或日志进入改动。
- 合并后仍**未复验**的只有两类,且都不阻塞合并:① 真实客户端观感(本批动了动画面板样式、生图浮层紧凑排布与卡片基类 token——声明级与结构级都有守卫,真机要客户端验收确认);② 远程 CI(runner/镜像问题仍在处理,见 Issue #431;按用户要求本批不追)。
### 2026-09-21 第四批(画布三项:生成任务侧栏右上角 / 拖到对话批量引用 / 替换面板画布点选,待客户端验收)
需求原文(本文件「后续需求」里那两条,三项一起做):
- 「生成任务列表移到画布右上角(照抄美术画布,但是保留当前的样式)」。
- 「聊天拖拽批量引用」→ 拖动素材到对话实现批量 @ 引用。
- 「画布点选替换」→ 在替换面板中支持画布点选目标。
- 改前:侧栏本体是 `position: fixed; top: 4rem; bottom: 6rem; left: 0.75rem` 的左侧贴边面板,开合口只有工具条上那一枚「生成任务 · N」按钮(资源 / 运行两个页签各渲染一次);分组、失败可见、限高滚动、提交受理后自动展开这四条本批之前已经具备,本轮只动**位置与开合形态**。
- 改后:新增右上角锚点 `.game-resource-generation-tasks-anchor``position: absolute; top: 3.5rem; right: 0.85rem; z-index: 40`,挂在 `.game-workbench-stage` 里),**开关常驻右上角、面板在开关左侧展开、收起只剩那一枚开关**(与美术画布同一形态);工具条上那两处重复入口删除;面板改由 `max-height: min(30rem, calc(100vh - 12rem))` 封顶 + 内部滚动,自己不再定位。
- 颜色 / 圆角 / 字重 / 阴影仍全部走 `--platform-*` token(开关沿用原工具条那枚次级胶囊的外观,只多一档面板投影),没有照搬网页端的固定色值。
- 分档坐标:view 的 `placement``canvas | run | editor`)落成 `data-generation-tasks-placement`;运行表现层下移到 `top: 7rem`,避开它右上角 `game-run-version-picker``top: 20px; right: 20px`)。
- 本机执行过:`resourceCanvasAssetGenerationTasksPanel.test.tsx` 12 passed(含验收修正后的「展开时开关让位」)、`resourceCanvasAssetGenerationTasksSidebarStyle.test.ts` 11 passed、`resourceCanvasGenerationTasksSidebarDismiss.test.tsx` 5 passed、`resourceCanvasAssetGenerationBackgroundClose.test.tsx` 5 passed;仓库根 `tests/appSurface.test.ts` **538 passed / 17 skipped / 0 failed**(与既有基线逐条一致);`tsc -p apps/ai-game-creator-shell/tsconfig.json --noEmit` 通过。
- **本批第二项(拖到对话批量引用)**:资源卡按住拖到右侧 Agent 对话栏、松手即按这次拖动真正参与位移的那批(多选整批、单选只引用按住的那张)插入 `@` 引用;落点铺虚线框 + 「松手即可 @ 引用 N 项素材」,拖回画布内松手仍是排版语义;引用口径与工具条「引用」按钮逐字一致(只认已登记素材、`source: resource-card`),派发走新增的批量事件一次事务插入。
- **本批第三项(替换面板画布点选)**:替换候选面板从全屏弹窗改为**画布右上角的非模态浮层**(共享组件新增 opt-in `nonModal`,网页端弹窗行为不变),面板开着时画布照常可点——点中合法候选即落成面板里的当前选择,写入仍只由「确认」发起;非法目标在面板里报因、零写入。旧的「点选替换」按钮、画布提示条与点选态随之退役(同一个功能不留两条 UI 路径)。
- **验收现场修正(第四批内)**:截图两条——右上角「生成任务」开关与工具条**重叠**、展开时开关与面板**并排**。锚点改挂**画布那一格网格**(不再用写死 `top` 的绝对定位,工具条换行也不会压上去),开关只在完全收起时渲染(展开后由面板头部 × 或点外部收起)。
- **PR #441 自查修正**:拖动落点改逐值比较(不再每帧重渲染工作台);替换面板从 stage 顶层移进画布容器并排在任务开关下方,打开替换会话时收起任务面板(同一个角只留一块浮层);面板上方补「替换源素材:<显示名>」一行(非模态之后源身份不再只靠画布光环)。
- 本机执行过(三项合计):`resourceCanvasAssetGenerationTasksPanel` / `…SidebarStyle` / `…SidebarDismiss` / `…BackgroundClose``resourceCardReferenceDropModel``resourceCanvasChatReferenceDrop``resourceReferenceInput``resourceVersionReplacement``projectAssetPickerDialogShellStyle``ImageCanvasEditorView``ImageCanvasEditorGenerationIntegration``projectResourceLiveIntegration``appSurface` 全绿(定向批次多项合计 694 passed / 17 skipped);`typecheck``check:encoding``git diff --check`、prettier / eslint 通过。
- 仍未做:真实客户端目视(三项的真机手感与落点)与远程 CI 都没跑;窄屏(≤480px)只有声明级断言;「复制对话保留有效引用」未做。
## 后续需求
- 资源卡顶部独立名称区与全类型统一样式,确认 assetName/用户素材名的正式来源。
- 生成任务列表移到画布右上角,进行中/已完成分组、失败可见、限高滚动及自动开合。
- 素材替换关系与画布点选替换;聊天拖拽批量引用、复制对话保留有效引用
- ~~生成任务列表移到画布右上角,进行中/已完成分组、失败可见、限高滚动及自动开合。~~ **2026-09-21 已处理**:侧栏改挂画布右上角锚点(开关常驻、面板在开关左侧展开,形态照抄美术画布,样式仍走 AGC 平台 token),工具条上的重复入口删除;`placement` 分档让运行表现层让开右上角版本入口。见下「2026-09-21 第四批」。
- ~~素材替换关系与画布点选替换;聊天拖拽批量引用~~ **2026-09-21 已处理**:替换面板改为画布右上角的非模态浮层、可直接在画布上点选目标素材;资源卡可拖到对话栏批量 @ 引用。见下「2026-09-21 第四批」。「复制对话保留有效引用」仍未做(同一条需求里的另一半)
## 边界与验收
@@ -34,7 +34,6 @@ DLL 原件随 AGC 安装包放在插件资源目录中;Godot Windows 加载器
## 分发与描述文件
- 原生引导使用官方 `godot-cpp` 的 C++ 类型和初始化接口,不自行声明 Variant 存储或直接装配 ABI 函数指针。绑定源码固定到 `godot-4.5-stable``e83fd0904c13356ed1d4c3d09f8bb9132bdc6b77`,以该版本的稳定 API 构建并在 Godot 4.7.2 验证;产品最低版本仍为 4.7,因为嵌入脚本使用该版本能力。Windows x64 构建使用 Visual Studio C++、CMake 和 Python,静态链接绑定及 C++ runtime,用户无需这些构建工具。
- Jenkins 的 `Genarrative-Agc-Windows-Build``AGC_WINDOWS_PATH` 整体替换阶段 PATH,因此 Godot 原生扩展所需的 CMake 与 Python 3 必须显式列入该白名单(当前为 Build Tools 自带的 `C:\BuildTools\Common7\IDE\CommonExtensions\Microsoft\CMake\CMake\bin``C:\Python312`);Toolchain preflight 校验 CMake ≥3.25、Python 3 和 Visual Studio 17 2022 生成器,缺失即失败关闭。
- 构建仅从已固定的官方归档获取绑定源码,并核对 SHA256;下载和生成内容只进入 `.build/`。CMake 构建包含引导源码、嵌入脚本、绑定版本/归档摘要和构建配置的身份指纹。许可证与来源继续随 DLL 分发,安装包不包含 SDK、源码缓存、生成绑定或构建工具。
- C++ 状态仅在扩展有效期间持有 GDScript 引用和桥节点身份;终止回调先停用仍存活的桥,再释放绑定对象,不能让静态 Godot 对象析构晚于绑定退出。延迟 bootstrap、原生卸载、Node 已退出及同 PID 重连均须实测。受管文件、会话身份、协议、执行回执与不确定阻断沿用现有合同。
- EDITOR 阶段晚加载不会取得 CORE 阶段终止回调;引导终止时须通过官方绑定接口解除 Node/GDScript 的实例包装回调,并完成单例包装清理。只移除 C++ 包装,不同步销毁仍在 GDScript 调用栈或等待 `queue_free` 的引擎对象;先用 Variant 保活脚本,再解除 Ref 和绑定,避免卸载 DLL 后跳到失效回调。
@@ -102,7 +101,6 @@ Windows x64、Godot 4.7.2 标准编辑器的本地实现验收通过。证据保
| 共享权限与前端 | `godot-web-final-tests.log`:28 项通过;Rust 命令权限契约通过,AGC typecheck 通过;Godot 命令继续使用默认 confirm |
| 现有宿主回归 | EditorAdapter、Unity、Runner 重启 fence 和缺失 endpoint 清理的定向测试通过;构建脚本、workspace、CI 路由和 rustfmt hook 定向测试通过 |
| 原生 GUI | `godot-plugin-verified-20260920/evidence/native-gui-smoke.log`:同一编辑器内返回 42/null、读取场景、增加节点后撤销、await、编译/运行错误、卸载、重连及再次卸载成功 |
| Jenkins Windows 节点 | 2026-09-21 在修复分支按 `Genarrative-Agc-Windows-Build` 等价流程实跑(dry-run):preflight 报告 `cmake=3.31.6-msvc6``python=3.12.10`Godot 原生载荷 staging 出 345600 字节 `agc_godot_editor.dll` 与元数据,NSIS `陶泥儿_0.1.88_x64-setup.exe`、签名、渠道清单和更新摘要全部生成 |
| 多实例与只读安装 | 同目录 `parallel-live-modules.json``parallel-verified-summary.json`:两个编辑器同时使用不同缓存路径的官方 `~DLL`,均返回 42、确认卸载,安装原件只读且未变 |
| 安装位置更新 | 同目录 `install-location-smoke.log`:旧连接卸载、新安装来源生成新代次和引用、返回 42、清理两代缓存;两个安装来源及原有工程文件均未变 |
| 真实 Runner | 同目录 `runner-smoke-psapi.log`standalone Runner 经正式执行/ACK 路径取得真实 Godot 回执,拒绝错误 ACK,接受正确 ACK,完成场景修改/撤销及断开;`runner-restart-before.log``runner-restart-after.log`:新 Runner 无需重新连接即可恢复原归属并清理旧桥 |
@@ -7,12 +7,12 @@
## 1. 验收基线的先决条件
| 事实 | 依据 | 对验收的影响 |
| --- | --- | --- |
| 基线 commit 为 `ea9668e4a`2026-09-11 15:37 | `git log -1``ea9668e4a`「资源总览「所有资源」卡补上资源预览」 | 验收记录表回填此 hash 作为冻结基线 |
| 事实 | 依据 | 对验收的影响 |
| --------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| 基线 commit 为 `ea9668e4a`2026-09-11 15:37 | `git log -1``ea9668e4a`「资源总览「所有资源」卡补上资源预览」 | 验收记录表回填此 hash 作为冻结基线 |
| 三个验收重点改动已入库,但**必须重新构建 / 重启客户端才会生效** | `2a157ea6f`「预览可见性门禁等 root 就绪再建 observer,并把登记表补挂齐」、`6bdc8bbd9`「AGC 资源分类面板改为「编辑素材标签」:只编辑标签、分类不再有手动入口」、`ea9668e4a`「资源总览「所有资源」卡补上资源预览」 | ① 可见卡停 `idle` 的修复;② 「编辑标签」只改 tags;③ 「所有资源」卡渲染真实预览。客户端若仍在跑旧构建(真机当前进程即属此列),这三条会得到**假失败** |
| 工作区仍有 1 个非本用例范围的文件处于修改态 | `git status --short``apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts`3 insertions / 2 deletions | 只影响 AGC 全量测试结果,不影响手工验收;该文件相关用例失败时先与基线比对再定性 |
| 真机项目落盘分类分布与旧构建指纹一致 | 真机 `.agent/manifest.json` 落盘 `category` = `unclassified 57 / ui-interaction 3 / scene 1` | 未用新构建复测时,卡片预览、标签面板、所有资源预览三条会得到**假失败** |
| 工作区仍有 1 个非本用例范围的文件处于修改态 | `git status --short``apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts`3 insertions / 2 deletions | 只影响 AGC 全量测试结果,不影响手工验收;该文件相关用例失败时先与基线比对再定性 |
| 真机项目落盘分类分布与旧构建指纹一致 | 真机 `.agent/manifest.json` 落盘 `category` = `unclassified 57 / ui-interaction 3 / scene 1` | 未用新构建复测时,卡片预览、标签面板、所有资源预览三条会得到**假失败** |
> 结论:验收开始前先固化"客户端构建版本 + api-server 是否重启 + 基线 commit"三件事,否则后续结论不可信。
@@ -28,72 +28,72 @@
### A 阶段 · 从零进项目
| 步骤 | 操作 | 期望结果 | 对应 PRD 条款 | 怎么判"过了"(可观察判据) | 已知例外 / 未做项 |
| --- | --- | --- | --- | --- | --- |
| **S1** 建项 | 首页「做游戏」→ 输入需求 → 选择目录 → 确认为新项目 | 建项成功并直接进入项目工作台,不新建第二套页面或资产系统 | L7(四区)、L23(创作链路)、L108 | 进入后四区同时在 DOM:左侧平台导航、`.game-workbench-stage``aria-label="项目主视窗"`)、右侧 Supervisor 对话栏、底部 Agent Dock | 普通用户「新增资源」入口禁用(L108、L587) |
| **S2** 落地视图 | 进项目后不动鼠标,看中央区 | 落 `resource-overview`manifest 里 `preview.status=running` 的**陈旧记录**不得把你直接丢进运行界面 | L145146、**L165**`run` 自动进入只认内存 registry 活体确认) | `document.querySelector('.game-workbench-stage')?.dataset.resourceViewState``resources.list` / `resources.selected.<category>` / `resources.ui-editor`**该属性为 `undefined` 即说明已在 run 视图** | 陈旧记录对齐:命中"记录 running 但 registry 没跑"时调 `stop_local_game_preview` 并按真相投影 `{status:'stopped'}``sessionPreview.ts:87-101` |
| **S3** 项目页面板(可跳过) | 「项目组」页:搜索、行尾更多菜单、Escape 关闭 | 表格只显示权威数据列;Escape 关菜单并把焦点还给触发按钮;移除只改本机最近项目记录、不动磁盘 | L135–139 | 无匹配状态提供「清除搜索」;document/body 无页面级横纵向溢出 | **L139 要求 populated fixture 截图 + 视频演示搜索/清除/行尾菜单** —— 视频条款,见 7.1 |
| 步骤 | 操作 | 期望结果 | 对应 PRD 条款 | 怎么判"过了"(可观察判据) | 已知例外 / 未做项 |
| --------------------------- | -------------------------------------------------- | --------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| **S1** 建项 | 首页「做游戏」→ 输入需求 → 选择目录 → 确认为新项目 | 建项成功并直接进入项目工作台,不新建第二套页面或资产系统 | L7(四区)、L23(创作链路)、L108 | 进入后四区同时在 DOM:左侧平台导航、`.game-workbench-stage``aria-label="项目主视窗"`)、右侧 Supervisor 对话栏、底部 Agent Dock | 普通用户「新增资源」入口禁用(L108、L587) |
| **S2** 落地视图 | 进项目后不动鼠标,看中央区 | 落 `resource-overview`manifest 里 `preview.status=running` 的**陈旧记录**不得把你直接丢进运行界面 | L145146、**L165**`run` 自动进入只认内存 registry 活体确认) | `document.querySelector('.game-workbench-stage')?.dataset.resourceViewState``resources.list` / `resources.selected.<category>` / `resources.ui-editor`**该属性为 `undefined` 即说明已在 run 视图** | 陈旧记录对齐:命中"记录 running 但 registry 没跑"时调 `stop_local_game_preview` 并按真相投影 `{status:'stopped'}``sessionPreview.ts:87-101` |
| **S3** 项目页面板(可跳过) | 「项目组」页:搜索、行尾更多菜单、Escape 关闭 | 表格只显示权威数据列;Escape 关菜单并把焦点还给触发按钮;移除只改本机最近项目记录、不动磁盘 | L135139 | 无匹配状态提供「清除搜索」;document/body 无页面级横纵向溢出 | **L139 要求 populated fixture 截图 + 视频演示搜索/清除/行尾菜单** —— 视频条款,见 7.1 |
### B 阶段 · 素材产生与登记
| 步骤 | 操作 | 期望结果 | 对应 PRD 条款 | 怎么判"过了" | 已知例外 / 未做项 |
| --- | --- | --- | --- | --- | --- |
| **S4** 首轮生成 | 在右侧 Supervisor 对话里发起首轮素材生成 | 生成由**对话启动**(不用画布按钮);素材写入对应栏目并登记进 manifest;未生成的素材不显示为可编辑对象 | L25、L94、L500;飞书 §4 首轮生成 | 对话完成后 `.agent/manifest.json``assets[]` 长度增加;新卡 `data-resource-card-id``asset:<id>` 形态 | 「首轮进度投影」只在 Issue 交付范围的"应该"清单出现、**无编码级判据**;现有可见性由底部 Agent 状态栏承载 |
| **S5** 栏目归属 | 看资源总览的栏目缩略卡与各栏目卡片 | 固定 7 栏:`UI 交互 → 角色与对象 → 场景与环境 → 音频 → 文档 → 待归类 → 项目版本`;分区口径是 manifest `assets[].category`。**用户可经「编辑标签」面板的类型选择器改分类**(选择器显示读显示口径;写回:没碰过控件回传落盘原值、主动选过写用户选的值),改完卡片应落到新分类对应栏目、角标同步 | L50、L56、L324、L358360、L539 | `[...document.querySelectorAll('.game-resource-book-thumbnail[data-resource-book-category]')].map(e=>e.dataset.resourceBookCategory)`;再数每栏 `.game-resource-card` 数量;角标读 `.game-resource-card-type-badge``data-resource-type` | **读时自愈**:落盘 `unclassified``kind` 能明确分类时按派生值显示(L360)。真机 57 条落盘 `unclassified` 在 UI 上应显示到正确栏目,**判定必须看 UI 计数,不能看 manifest 文件计数**。⚠ 反过来**不能**把自愈值当落盘值:判定「只改标签时分类有没有被改」必须比 manifest 文件(自愈只在读显示层,写回用 `gameCreationAppAssetPersistedCategory`)。⚠ 显式把 `kind` 已能明确分类的资产设为「待归类」会被自愈覆盖回派生栏目——已知盲区,不作为本用例的通过判据 |
| **S6** 卡片预览台账 | 逐栏目看卡片是否显示真实主体 | 图片与安全 SVG 直接显示主体并保留透明棋盘底;视频、音频、文档、项目版本用稳定固定卡;失败显示类型占位、不挂破图、不降级为项目外 URL | L55、L6162、**L67**、L347349 | 见 5.1 的四条脚本:区分"没读 / 读失败 / 解码为空 / 不适用" | **可见卡停 `idle` 的真 bug 已修但未提交**`useProjectResourceCardPreviews.ts`);未用新构建时会出现假失败 |
| 步骤 | 操作 | 期望结果 | 对应 PRD 条款 | 怎么判"过了" | 已知例外 / 未做项 |
| ------------------- | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **S4** 首轮生成 | 在右侧 Supervisor 对话里发起首轮素材生成 | 生成由**对话启动**(不用画布按钮);素材写入对应栏目并登记进 manifest;未生成的素材不显示为可编辑对象 | L25、L94、L500;飞书 §4 首轮生成 | 对话完成后 `.agent/manifest.json``assets[]` 长度增加;新卡 `data-resource-card-id``asset:<id>` 形态 | 「首轮进度投影」只在 Issue 交付范围的"应该"清单出现、**无编码级判据**;现有可见性由底部 Agent 状态栏承载 |
| **S5** 栏目归属 | 看资源总览的栏目缩略卡与各栏目卡片 | 固定 7 栏:`UI 交互 → 角色与对象 → 场景与环境 → 音频 → 文档 → 待归类 → 项目版本`;分区口径是 manifest `assets[].category`。**用户可经「编辑标签」面板的类型选择器改分类**(选择器显示读显示口径;写回:没碰过控件回传落盘原值、主动选过写用户选的值),改完卡片应落到新分类对应栏目、角标同步 | L50、L56、L324、L358360、L539 | `[...document.querySelectorAll('.game-resource-book-thumbnail[data-resource-book-category]')].map(e=>e.dataset.resourceBookCategory)`;再数每栏 `.game-resource-card` 数量;角标读 `.game-resource-card-type-badge``data-resource-type` | **读时自愈**:落盘 `unclassified``kind` 能明确分类时按派生值显示(L360)。真机 57 条落盘 `unclassified` 在 UI 上应显示到正确栏目,**判定必须看 UI 计数,不能看 manifest 文件计数**。⚠ 反过来**不能**把自愈值当落盘值:判定「只改标签时分类有没有被改」必须比 manifest 文件(自愈只在读显示层,写回用 `gameCreationAppAssetPersistedCategory`)。⚠ 显式把 `kind` 已能明确分类的资产设为「待归类」会被自愈覆盖回派生栏目——已知盲区,不作为本用例的通过判据 |
| **S6** 卡片预览台账 | 逐栏目看卡片是否显示真实主体 | 图片与安全 SVG 直接显示主体并保留透明棋盘底;视频、音频、文档、项目版本用稳定固定卡;失败显示类型占位、不挂破图、不降级为项目外 URL | L55、L6162、**L67**、L347349 | 见 5.1 的四条脚本:区分"没读 / 读失败 / 解码为空 / 不适用" | **可见卡停 `idle` 的真 bug 已修但未提交**`useProjectResourceCardPreviews.ts`);未用新构建时会出现假失败 |
### C 阶段 · 资源管理闭环
| 步骤 | 操作 | 期望结果 | 对应 PRD 条款 | 怎么判"过了" | 已知例外 / 未做项 |
| --- | --- | --- | --- | --- | --- |
| **S7** 栏目导航 | 点总览第 0 张「所有资源」卡 → 栏目缩略卡片进栏目 → 页内底部「下一页」 | 「所有资源」是入口(第 0 张);进入后**与栏目页是同一套画本场景**——卡片宿主同为 `.game-resource-book-scene-card`、每张卡可拖、可滚轮缩放平移,各栏目按**竖向分带**平铺在同一张画布上(展开态**不出每栏一条标题栏**,整个场景只有 `all` 那一条钉在视口上的标题栏,与栏目页"一条钉住的标题栏 + 一张平铺画布"同形);全部栏目(含空栏目)都可到达;左侧悬浮栏目大纲导航已删除;滚轮不切栏目 | L56、L512、L539541 | `[data-resource-book-view="main"\|"child"]`、当前栏目 `.game-resource-book-scene-titlebar.is-active[data-resource-book-category]`(展开态这一条是 `data-resource-book-category="all"`,带「资源总览」收起入口;**展开态 `data-resource-book-category``all` 的标题栏一个都不该有**——空栏目曾因拿不到分带矩形而集体回落到世界原点叠成一摞)、卡片宿主 `.game-resource-book-scene-card[data-resource-book-category="<真实栏目>"]`(每张资源**恰好一个**宿主)、`[data-resource-book-section-scroll]`。**旧的独立网格判据已随分支删除**:`[data-resource-book-all-page="true"]` / `[data-resource-book-all-host="true"]` / `.game-resource-all-*` 都不应再出现。拖动的判据:在展开态拖一张卡,再进它所属栏目页看同一张卡**落在新位置**(两页共用同一份 `resource-layouts/{dependency,type}.json` 的栏目内局部坐标) | 「所有资源」卡渲染真实预览(`game-resource-book-preview-card`**已提交**`ea9668e4a`);旧构建只有占位摞。展开态的视口是内存态(不落盘),只有卡片坐标落盘 |
| **S8** 搜索 / 筛选 | 点右下角 `搜索资源`(放大镜)按钮或按 `Ctrl/Cmd+F` → 看到关键词 / 所在区域 / 自定义标签三字段同屏 → 输入 → Esc | Dock 上只有这一颗按钮,叫出的是**唯一**的筛选浮层(不存在第二个只放关键词的搜索浮层);浮层收起**不清筛选条件**;`type=search` 原生 Esc 清空被 `preventDefault` 挡掉;条件生效时 Dock 按钮高亮 | L55、L322(筛选只隐藏卡片、不重排坐标)、L329–330、L336337 | `button[aria-label="搜索资源"]``aria-expanded``title="搜索资源(Ctrl/Cmd+F"``.is-active`;浮层 `.game-resource-filter-panel``role="dialog"` 名「筛选资源」)内三字段 `查找素材` / `所在区域` / `自定义标签`;浮层容器 `id` 等于按钮的 `aria-controls`;Esc 后浮层不在 DOM、条件仍生效、焦点回到该按钮 | 匹配字段 = **名称 / 路径 / 媒体类型 / 任务标题**,不含 `sourceLabel` |
| **S9** 选择与多选 | 单击卡片 → `Shift`/`Ctrl`/`Meta` 再点 → 空白处拖拽框选 | 点卡 = 选中并在卡片旁浮出工具条;三种修饰键等效追加;框选可见选框 | L51、L6162、L318319 | `data-resource-card-id` + class 含 `is-selected` + `aria-pressed="true"`;浮出容器 `role="toolbar"`(图片 `aria-label="图片工具栏"`,音频 `"素材工具栏"` | ⚠️ **PRD L61–62 要求的「打开详情」按钮在代码中不存在**(测试显式断言为 `null`),按 Issue #309 C3「取消资源详情面板与全屏编辑路由」判,不要判为缺失;真正入口是 `aria-label="选中资源:<栏目> <名>"` 按钮。**只读信息浮层已回归**(2026-09-11,工具条「信息」动作,见 `decision-log`):**C3 取消的仍只是可编辑详情面板与全屏编辑路由** |
| **S10** 快速编辑(图片) | 选中 PNG/JPEG/WEBP 卡 → 工具条「快速编辑」→ 输入 → 提交(提示词下方另有「AI 润色」) | 先 `normalize_local_project_raster_resource`,再 `derive_local_project_resource(editKind='image-reference')` → 服务端 `/api/editor/images/edits`;**产出一张新素材**,源素材与源文件不变,新资源带 `referenceResourceIds` 血缘 | L123、L193、L476478、L557#309 C3/C10 | 提交后 `assets[]` +1、卡片 +1 且 `data-resource-card-id` 为新 id;**源卡仍在且内容未变**;工具条真渲染的只有 `quick-edit``download`;「AI 润色」`aria-label="AI 润色"` + `aria-busy`,成功回填并出现「恢复原文」,失败保留原文并给「AI 润色失败,可重试」 | ① 服务端白名单 17 项(`editor_project.rs:4120-4141`),`character-animation`/`video`/`audio`/`document`/`code` 等**仍会 400**;② 工具条 7 个动作(重绘 / 裁剪扩图 / 去背景 / 像素完美 / 切图集 / 提取 UI 素材 / 角色动画)按 opt-in **不渲染**;③ 润色结果超 `resourceEditPromptMaxLength` 会**截断并提示「已按长度上限截断」**;④ 提示词变了(改写或润色回填)必须换 `operationId`/幂等键,否则 Rust `request_fingerprint` 判「已绑定到不同资源编辑请求」 |
| **S11** 画布生成入口(视频) | 画布级入口 → 只呈现视频 → 填名与提示词 → 提交(提示词下方另有「AI 润色」) | 无源新建视频 → 新卡产出并定位(音效 / 背景音乐已移到栏目工具栏,见 S11a) | 飞书 §4(首轮由对话启动)、L122 | 提交后 `assets[]` +1;面板是独立浮层(非在当前面板下方追加);「AI 润色」走 `polish_local_project_prompt` 并带上「这是素材生成提示词(类型)」的场景约束,成功回填、失败保留原文 | ⚠️ **Issue #309 写「画布生成入口未接」,但当前代码已接三类**`resourceCanvasGenerationModel.ts``video`/`sound-effect`/`background-music`);图片等类型会直接报「当前资源类型不支持无源生成」;润色结果超该类型上限(背景音乐 140 / 音效 1900 / 视频 4000)会截断并提示「已按长度上限截断」 |
| **S12** 编辑标签 | 选中已登记素材 → 工具条「编辑标签」→ 增删标签 → 保存 | 面板**只编辑 `manifest.assets[].tags`**`category` 读一次权威值后**原样回传**;顶部没有分类 chip 排 | L3602026-09-11 收口)、#309 C2 | 面板标题「编辑素材标签」,底部只有「取消 / 保存标签」;保存后 `assets[n].tags` 变化而 `assets[n].category` **逐字不变** | 面板内的「删除资源」按钮保留(删除入口另见 S15 的选中卡工具条);「用户手动设置 category」不再放在本面板,改为独立入口(工具条「素材类型」与信息浮层「分类 → 设置」,选中即落盘,见 decision-log 2026-09-14 |
| **S13** 重命名 | 工具条「重命名」→ 新文件名 → 确认 | 磁盘文件名与 manifest `localPath` 更新;**asset id 不变**;写盘失败回滚文件 | L362(资源身份稳定)、L515 | 弹窗 `ariaLabel="重命名素材"`;改名后「选中资源:…」按钮的无障碍名同步变化;引用 chip 与 @ 候选列表显示名刷新 | ⚠️ **不改游戏源码里对旧 `assets/<name>` 的引用**,后果由用户自负 |
| **S14** 下载 | 选中可下载卡 → 工具条「下载」 | 弹出**原生保存对话框**选目标路径 → 分块复制;取消即停止 | L507、飞书 §11.1.2 | 出现 `title="保存素材"` 的原生对话框;成功后状态提示含 `已保存 <路径>` | 语义是"另存为",不是静默直下;虚拟版本条目没有文件、不放行 |
| **S15** 删除 | 工具条 / 标签面板「删除」→ 确认弹窗 | 三分支:① 无引用→直接删;② 被引用未勾选→只删素材、版本保留**悬空绑定**、界面不合成幽灵资源卡;③ 勾选→素材与该批版本在**同一次 manifest 写入**内一起删 | L413(版本只追加的唯一例外)、L532–533;#309 C5 | 弹窗 `ariaLabel="确认删除资源"`;被引用时出现 `被 N 个游戏版本使用` + 版本列表 + checkbox `把相关游戏版本一并删除`;无引用时该 body 整块不渲染 | **只摘登记、不删磁盘文件**;读引用命令精确名是 `read_local_project_asset_references` |
| **S15a** 替换素材(含点选替换) | 选中被**当前版本**绑定的素材 → 工具条「替换素材」→ 候选弹窗(弹窗内可筛可选);再点 footer「点选替换」→ 弹窗关闭、进入画布点选态 → 在画布上直接点目标素材 | 弹窗确认与点选是**同一条**写入链路(`replace_local_project_version_resource`,载荷逐字一致):改该版本绑定、不建新版本;点选态下合法目标直接提交并自动退出点选态,非法目标零写入、**留在**点选态并在提示条上说明原因 | PRD §5.3 / §7.8 第 8 条;#309「直接替换」口径 | 点选态判据:候选弹窗 `role="dialog"` 已卸载;画布出现 `.game-resource-canvas-pick-hint`,文案含「在画布上点选要替换成的素材」与「点击空白处不会退出」,并有「取消」按钮;非法目标后提示条仍在且出现 `role="alert"`(「替换素材不兼容:分类不同」/「替换素材与源素材相同」/「替换素材未登记或已被删除」);点选期间卡片 `aria-pressed` 不变(单击只用于点选)、源素材选中与工具条不因点空白或点非法目标而丢Esc 退出点选(画布全局 Esc 的清选中不随之触发);点选态下滚轮平移与缩放照常可用 | 点选只能点**当前画布上可见**的卡:目标在别的栏目时先切栏目(点选会话跨栏目存活,不因「收起资源」或切栏目结束);空白点击不退出、也不清画布选中 |
| 步骤 | 操作 | 期望结果 | 对应 PRD 条款 | 怎么判"过了" | 已知例外 / 未做项 |
| -------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **S7** 栏目导航 | 点总览第 0 张「所有资源」卡 → 栏目缩略卡片进栏目 → 页内底部「下一页」 | 「所有资源」是入口(第 0 张);进入后**与栏目页是同一套画本场景**——卡片宿主同为 `.game-resource-book-scene-card`、每张卡可拖、可滚轮缩放平移,各栏目按**竖向分带**平铺在同一张画布上(展开态**不出每栏一条标题栏**,整个场景只有 `all` 那一条钉在视口上的标题栏,与栏目页"一条钉住的标题栏 + 一张平铺画布"同形);全部栏目(含空栏目)都可到达;左侧悬浮栏目大纲导航已删除;滚轮不切栏目 | L56、L512、L539541 | `[data-resource-book-view="main"\|"child"]`、当前栏目 `.game-resource-book-scene-titlebar.is-active[data-resource-book-category]`(展开态这一条是 `data-resource-book-category="all"`,带「资源总览」收起入口;**展开态 `data-resource-book-category``all` 的标题栏一个都不该有**——空栏目曾因拿不到分带矩形而集体回落到世界原点叠成一摞)、卡片宿主 `.game-resource-book-scene-card[data-resource-book-category="<真实栏目>"]`(每张资源**恰好一个**宿主)、`[data-resource-book-section-scroll]`。**旧的独立网格判据已随分支删除**:`[data-resource-book-all-page="true"]` / `[data-resource-book-all-host="true"]` / `.game-resource-all-*` 都不应再出现。拖动的判据:在展开态拖一张卡,再进它所属栏目页看同一张卡**落在新位置**(两页共用同一份 `resource-layouts/{dependency,type}.json` 的栏目内局部坐标) | 「所有资源」卡渲染真实预览(`game-resource-book-preview-card`**已提交**`ea9668e4a`);旧构建只有占位摞。展开态的视口是内存态(不落盘),只有卡片坐标落盘 |
| **S8** 搜索 / 筛选 | 点右下角 `搜索资源`(放大镜)按钮或按 `Ctrl/Cmd+F` → 看到关键词 / 所在区域 / 自定义标签三字段同屏 → 输入 → Esc | Dock 上只有这一颗按钮,叫出的是**唯一**的筛选浮层(不存在第二个只放关键词的搜索浮层);浮层收起**不清筛选条件**;`type=search` 原生 Esc 清空被 `preventDefault` 挡掉;条件生效时 Dock 按钮高亮 | L55、L322(筛选只隐藏卡片、不重排坐标)、L329–330、L336337 | `button[aria-label="搜索资源"]``aria-expanded``title="搜索资源(Ctrl/Cmd+F"``.is-active`;浮层 `.game-resource-filter-panel``role="dialog"` 名「筛选资源」)内三字段 `查找素材` / `所在区域` / `自定义标签`;浮层容器 `id` 等于按钮的 `aria-controls`;Esc 后浮层不在 DOM、条件仍生效、焦点回到该按钮 | 匹配字段 = **名称 / 路径 / 媒体类型 / 任务标题**,不含 `sourceLabel` |
| **S9** 选择与多选 | 单击卡片 → `Shift`/`Ctrl`/`Meta` 再点 → 空白处拖拽框选 | 点卡 = 选中并在卡片旁浮出工具条;三种修饰键等效追加;框选可见选框 | L51、L6162、L318319 | `data-resource-card-id` + class 含 `is-selected` + `aria-pressed="true"`;浮出容器 `role="toolbar"`(图片 `aria-label="图片工具栏"`,音频 `"素材工具栏"` | ⚠️ **PRD L61–62 要求的「打开详情」按钮在代码中不存在**(测试显式断言为 `null`),按 Issue #309 C3「取消资源详情面板与全屏编辑路由」判,不要判为缺失;真正入口是 `aria-label="选中资源:<栏目> <名>"` 按钮。**只读信息浮层已回归**(2026-09-11,工具条「信息」动作,见 `decision-log`):**C3 取消的仍只是可编辑详情面板与全屏编辑路由** |
| **S10** 快速编辑(图片) | 选中 PNG/JPEG/WEBP 卡 → 工具条「快速编辑」→ 输入 → 提交(提示词下方另有「AI 润色」) | 先 `normalize_local_project_raster_resource`,再 `derive_local_project_resource(editKind='image-reference')` → 服务端 `/api/editor/images/edits`;**产出一张新素材**,源素材与源文件不变,新资源带 `referenceResourceIds` 血缘 | L123、L193、L476478、L557#309 C3/C10 | 提交后 `assets[]` +1、卡片 +1 且 `data-resource-card-id` 为新 id;**源卡仍在且内容未变**;工具条真渲染的只有 `quick-edit``download`;「AI 润色」`aria-label="AI 润色"` + `aria-busy`,成功回填并出现「恢复原文」,失败保留原文并给「AI 润色失败,可重试」 | ① 服务端白名单 17 项(`editor_project.rs:4120-4141`),`character-animation`/`video`/`audio`/`document`/`code` 等**仍会 400**;② 工具条 7 个动作(重绘 / 裁剪扩图 / 去背景 / 像素完美 / 切图集 / 提取 UI 素材 / 角色动画)按 opt-in **不渲染**;③ 润色结果超 `resourceEditPromptMaxLength` 会**截断并提示「已按长度上限截断」**;④ 提示词变了(改写或润色回填)必须换 `operationId`/幂等键,否则 Rust `request_fingerprint` 判「已绑定到不同资源编辑请求」 |
| **S11** 画布生成入口(视频) | 画布级入口 → 只呈现视频 → 填名与提示词 → 提交(提示词下方另有「AI 润色」) | 无源新建视频 → 新卡产出并定位(音效 / 背景音乐已移到栏目工具栏,见 S11a) | 飞书 §4(首轮由对话启动)、L122 | 提交后 `assets[]` +1;面板是独立浮层(非在当前面板下方追加);「AI 润色」走 `polish_local_project_prompt` 并带上「这是素材生成提示词(类型)」的场景约束,成功回填、失败保留原文 | ⚠️ **Issue #309 写「画布生成入口未接」,但当前代码已接三类**`resourceCanvasGenerationModel.ts``video`/`sound-effect`/`background-music`);图片等类型会直接报「当前资源类型不支持无源生成」;润色结果超该类型上限(背景音乐 140 / 音效 1900 / 视频 4000)会截断并提示「已按长度上限截断」 |
| **S12** 编辑标签 | 选中已登记素材 → 工具条「编辑标签」→ 增删标签 → 保存 | 面板**只编辑 `manifest.assets[].tags`**`category` 读一次权威值后**原样回传**;顶部没有分类 chip 排 | L3602026-09-11 收口)、#309 C2 | 面板标题「编辑素材标签」,底部只有「取消 / 保存标签」;保存后 `assets[n].tags` 变化而 `assets[n].category` **逐字不变** | 面板内的「删除资源」按钮保留(删除入口另见 S15 的选中卡工具条);「用户手动设置 category」不再放在本面板,改为独立入口(工具条「素材类型」与信息浮层「分类 → 设置」,选中即落盘,见 decision-log 2026-09-14 |
| **S13** 重命名 | 工具条「重命名」→ 新文件名 → 确认 | 磁盘文件名与 manifest `localPath` 更新;**asset id 不变**;写盘失败回滚文件 | L362(资源身份稳定)、L515 | 弹窗 `ariaLabel="重命名素材"`;改名后「选中资源:…」按钮的无障碍名同步变化;引用 chip 与 @ 候选列表显示名刷新 | ⚠️ **不改游戏源码里对旧 `assets/<name>` 的引用**,后果由用户自负 |
| **S14** 下载 | 选中可下载卡 → 工具条「下载」 | 弹出**原生保存对话框**选目标路径 → 分块复制;取消即停止 | L507、飞书 §11.1.2 | 出现 `title="保存素材"` 的原生对话框;成功后状态提示含 `已保存 <路径>` | 语义是"另存为",不是静默直下;虚拟版本条目没有文件、不放行 |
| **S15** 删除 | 工具条 / 标签面板「删除」→ 确认弹窗 | 三分支:① 无引用→直接删;② 被引用未勾选→只删素材、版本保留**悬空绑定**、界面不合成幽灵资源卡;③ 勾选→素材与该批版本在**同一次 manifest 写入**内一起删 | L413(版本只追加的唯一例外)、L532–533;#309 C5 | 弹窗 `ariaLabel="确认删除资源"`;被引用时出现 `被 N 个游戏版本使用` + 版本列表 + checkbox `把相关游戏版本一并删除`;无引用时该 body 整块不渲染 | **只摘登记、不删磁盘文件**;读引用命令精确名是 `read_local_project_asset_references` |
| **S15a** 替换素材(面板内选 + 画布点选目标) | 选中被**当前版本**绑定的素材 → 工具条「替换素材」→ 画布右上角出现非模态候选面板(面板内可筛可选)→ 直接在画布上点目标素材(或点面板里的候选)→ 面板「确认」 | 面板确认与画布点选是**同一条**写入链路(`replace_local_project_version_resource`,载荷逐字一致):改该版本绑定、不建新版本;画布点选只把目标落成面板的当前选择,确认后才写入 | PRD §5.3 / §7.8 第 8 条;#309「直接替换」口径 | 面板判据:`role="dialog"` 名为「选择替换素材」、挂 `.image-canvas-editor__project-asset-picker--floating`、**没有** `.platform-overlay` 遮罩;点画布候选后面板里该候选 `aria-selected="true"`、面板里的搜索词与分类筛选保持原样、零写入;非法目标(源素材本身 / 不在权威候选里 / 分类不同 / 未登记资源)在面板里出现 `role="alert"`(「替换素材与源素材相同」/「替换素材未登记或已被删除」/「替换素材不兼容:分类不同」)且零写入;面板关闭 = 卸载Esc 只收面板(画布全局 Esc 的清选中不随之触发) | 点选只能点**当前画布上可见**的卡:目标在别的栏目时先切栏目(替换会话跨栏目存活,不因「收起资源」或切栏目结束);面板开着时空白处仍可框选 / 平移,卡片单击语义的恢复发生在面板关闭之后 |
| **S11a** 栏目画布底部工具栏 | 进「UI 交互 / 角色与对象 / 场景与环境 / 音频」任一栏目 → 点左下角工具栏里的入口生成 → 「收起资源」回总览看工具栏消失 | 工具栏只在矩阵四个栏目(功能画布)渲染;图片类入口走 `start_local_project_asset_generation`(提交即返回、生成在后台跑),音频入口走同一条命令的音频载荷,上传复用 `upload_local_asset`;生成 / 上传成功后走既有 manifest 刷新与资源定位 | PRD §3.10、§7.9 | DOM 判据 `[data-resource-bottom-toolbar="<category>"]`(资源总览、「所有资源」展开态、文档、待归类、项目版本都**没有**这个节点);工具栏是 `.game-resource-book-manager` 的直接子节点(`closest('.game-resource-book-scene')``null`);每个入口一次 `start_local_project_asset_generation`:图片类载荷逐字为 `{ projectPath, projectId, taskId, kind, prompt, aspectRatio, imageSize, assetName, outputPath }``kind` 映射见 PRD §7.9 第 3 条;`taskId` 是前端每次提交新铸的本地任务 id),音频类载荷为 `{ projectPath, projectId, taskId, kind, prompt, assetName, idempotencyKey }``taskId` 即该次生成的 operation id,不发图片类那套比例 / 尺寸 / 参考 / 落点参数);该命令提交即返回,之后有 `list_local_project_asset_generations` 轮询与 `get_local_game_project_revision` + `get_local_game_manifest` 的配对读;点「生成」即把这次输入交给后台账本并**同步关闭**面板(不等 IPC、不等排队、不等生成,画布立即恢复可交互;面板 DOM 里没有阶段文案与「后台运行并关闭」这类在途按钮),关闭不等于取消,关闭后任务仍出现在「生成任务」面板(入口按钮 `aria-label="生成任务"`,非模态浮层、无 `aria-modal`)并显示后端 `phaseDetail`;第一条未终态时提交第二条 → 第二条显示「排队中。」且生成提交 IPC 次数仍为 1,第一条终态后自动补发(次数变 2);缺 `assets/art-spec.png` 时「生成图标素材 / 生成 UI 设计图」仍可点击(`aria-disabled="true"` 但**不是**原生 disabled)并给出含该路径的 `role="alert"` 原因、零生成请求;音频入口面板标题即「生成背景音乐」/「生成音效」且没有类型选择器 | ① 本地通道没有 `model` / `specType` / `replaceExisting` 入参:面板不渲染模型选择器,角色规范与自定义规范共用 `spec` 通道(靠 `assetName` 与提示词区分),「图标规范」在项目已有权威规范图时不再指向 `assets/art-spec.png`(否则会被 Rust 的防覆盖校验硬拒);② 本轮不做生成视频 / 宣发素材 / 生成游戏场景 / 选择工具 / 抓手工具;③ 既有「生成素材」浮层入口只保留生成视频,音频入口只在音频栏目工具栏出现 |
### D 阶段 · 版本与运行
| 步骤 | 操作 | 期望结果 | 对应 PRD 条款 | 怎么判"过了" | 已知例外 / 未做项 |
| --- | --- | --- | --- | --- | --- |
| **S16** 版本切换 | 运行模块**右上角**版本选择器 → 切到另一版本 | 触发钮显示当前版本;切换后资源卡「当前使用」高亮与 **@ 面板「当前版本素材」同步更新**,并重载当前预览 | L165、L190、L324、L530#309 C7 | `aria-label="当前版本:<label>"`、菜单 `role="listbox" aria-label="切换游戏版本"`、选项 `role="option" aria-selected``[data-used-by-current-version="true"]` 集合随切换变化 | **C7 只做记录层 + UI 层**;"历史版本独有资源集合被运行时按版本加载"需新增版本化资源解析机制,**本轮未实现**;版本卡卡面**不渲染**「项目修订 / 父版本」文字 |
| **S17** 播放 | 顶部「播放」 | 直接切运行视图并启动本地预览;**不再弹 `game.run_local` 二次确认**;预览占满中央可用区、无 iframe 滚动条 | L16、L7172、L122、L493 | `data-resource-view-state` 变为 `undefined``await window.__TAURI__.core.invoke('get_local_game_preview_status',{projectPath})``status==='running'` 且有 `url`(形如 `http://127.0.0.1:<随机端口>/` | 播放按钮 `disabled={!runAvailable \|\| !onPlay \|\| uiEditorRoute!==null}`,**不可用时点不动**;不可用提示实现文案为「首个可运行原型尚未完成,运行视图暂不可用」,与 PRD L165 表述不同(见 7.4 |
| **S18** 停止与退出收尾 | 聊天侧工程操作组点「停止预览」;或关闭客户端 | 预览停止、manifest `preview` 对齐 `stopped`(清 url/port);退出时也统一收尾 | L165 | 停止后 `get_local_game_preview_status` 非 running`.agent/logs/preview.log` 追加 `stopped`;退出后重开同一项目应落回 `resource-overview` | 停止按钮**不在**运行视图工具栏,在工作台右侧工程操作组;退出失败会打 `preview.gui_exit.stop_failed` |
| 步骤 | 操作 | 期望结果 | 对应 PRD 条款 | 怎么判"过了" | 已知例外 / 未做项 |
| ---------------------- | -------------------------------------------- | -------------------------------------------------------------------------------------------------------- | ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **S16** 版本切换 | 运行模块**右上角**版本选择器 → 切到另一版本 | 触发钮显示当前版本;切换后资源卡「当前使用」高亮与 **@ 面板「当前版本素材」同步更新**,并重载当前预览 | L165、L190、L324、L530#309 C7 | `aria-label="当前版本:<label>"`、菜单 `role="listbox" aria-label="切换游戏版本"`、选项 `role="option" aria-selected``[data-used-by-current-version="true"]` 集合随切换变化 | **C7 只做记录层 + UI 层**;"历史版本独有资源集合被运行时按版本加载"需新增版本化资源解析机制,**本轮未实现**;版本卡卡面**不渲染**「项目修订 / 父版本」文字 |
| **S17** 播放 | 顶部「播放」 | 直接切运行视图并启动本地预览;**不再弹 `game.run_local` 二次确认**;预览占满中央可用区、无 iframe 滚动条 | L16、L7172、L122、L493 | `data-resource-view-state` 变为 `undefined``await window.__TAURI__.core.invoke('get_local_game_preview_status',{projectPath})``status==='running'` 且有 `url`(形如 `http://127.0.0.1:<随机端口>/` | 播放按钮 `disabled={!runAvailable \|\| !onPlay \|\| uiEditorRoute!==null}`,**不可用时点不动**;不可用提示实现文案为「首个可运行原型尚未完成,运行视图暂不可用」,与 PRD L165 表述不同(见 7.4 |
| **S18** 停止与退出收尾 | 聊天侧工程操作组点「停止预览」;或关闭客户端 | 预览停止、manifest `preview` 对齐 `stopped`(清 url/port);退出时也统一收尾 | L165 | 停止后 `get_local_game_preview_status` 非 running`.agent/logs/preview.log` 追加 `stopped`;退出后重开同一项目应落回 `resource-overview` | 停止按钮**不在**运行视图工具栏,在工作台右侧工程操作组;退出失败会打 `preview.gui_exit.stop_failed` |
### E 阶段 · 对话侧
| 步骤 | 操作 | 期望结果 | 对应 PRD 条款 | 怎么判"过了" | 已知例外 / 未做项 |
| --- | --- | --- | --- | --- | --- |
| **S19** @ 引用 | 输入区「@」→ 两个页签 + 筛选(功能分类 + 多标签)+ 搜索 + 多选 → 「添加到对话」 | 两页签**各自独立**的搜索与筛选状态(含各自一份标签库),**不影响**资源画布筛选;已选素材不因筛选或搜索隐藏而被取消;chip 整体插入 | L187、L420426、L453461#309 C4 | `role="tablist"``aria-label="素材范围"`)内两个 `role="tab"``当前版本素材` / `全部画布素材`;输入框 aria-label 分别为 `搜索当前版本素材` / `搜索全部画布素材`;标签行是共享筛选条里 `aria-label="素材筛选标签"` 的分组,chip 用 `aria-pressed` 表达选中;chip = `span.resource-reference-chip[data-resource-reference-id]` | **chip 原子性有正式定义**chip 是 Lexical `DecoratorNode`,纯文本表示固定占 **1 个字符** `U+FFFC` ⇒ 一次 Backspace 只能整体删掉一个 chip,不可能删半截;引用身份是稳定 `resourceId`,改名后按 id 刷新显示名。**多标签是 AND**(口径来自 `packages/shared``assetTagsMatchSelection`,标签取自 manifest `assets[].tags`),并与搜索词、功能分类三者叠加 |
| **S19a** 快速编辑内 @ 引用 | 资源卡「快速编辑」→ 提示词输入区「插入素材引用」或直接输入 `@` → 选素材 → 「修改」 | 快速编辑提示词输入区与聊天输入区**同一套**引用输入区:候选、插入动作、引用模型一致;提示词文本里的引用与聊天同字面量(`@显示名`),提交后该文本原样进入 `derive_local_project_resource``prompt` | #309 C4 / C10 | 快速编辑面板内存在 `aria-label="插入素材引用"``aria-label="快速编辑提示词"`;插入后编辑区出现 `[data-resource-reference-id]` chip;派生请求 `prompt` 等于 `文本@显示名` | 该输入区不渲染内置「AI 润色」(润色仍由面板的「AI 润色」承担,带资源编辑场景上下文与长度上限);多行输入区的 Enter 只换行、不提交;点 `@` 选择器里的候选不会被判成「点外部」而收起面板 |
| **S20** 润色与发送前提醒 | ① 点「AI 润色」→ 看回填与「恢复原文」;② 直接发送(≥40 字)→ 处理提醒弹窗三条动作与「不再提醒」 | 润色只优化文本、不发送不提交;第一次成功润色落下原文快照、可重复润色、失败保留原文;未润色直接发送时弹「发送前提醒」 | L120(气泡对比度)、飞书 §12.1/12.2;#309 C8 | 润色按钮 `aria-label="AI 润色"` + `aria-busy`;出现「恢复原文」按钮;提醒弹窗为 portal `role="dialog"`、标题「发送前提醒」、动作 `关闭` / `使用原文提交` / `AI 润色` + checkbox `不再提醒`;偏好存 `localStorage['agc.chat.prompt-polish-reminder.disabled']` | 触发门禁:trim 后 ≥40 字、不以 `/` 开头、本轮未润色未确认、未勾「不再提醒」。计费不自建,走 server-rs `/api/llm/chat/completions``/api/llm/responses`;「1 泥点」这个数字源码中未硬编码 |
| 步骤 | 操作 | 期望结果 | 对应 PRD 条款 | 怎么判"过了" | 已知例外 / 未做项 |
| -------------------------- | ----------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **S19** @ 引用 | 输入区「@」→ 两个页签 + 筛选(功能分类 + 多标签)+ 搜索 + 多选 → 「添加到对话」 | 两页签**各自独立**的搜索与筛选状态(含各自一份标签库),**不影响**资源画布筛选;已选素材不因筛选或搜索隐藏而被取消;chip 整体插入 | L187、L420426、L453461#309 C4 | `role="tablist"``aria-label="素材范围"`)内两个 `role="tab"``当前版本素材` / `全部画布素材`;输入框 aria-label 分别为 `搜索当前版本素材` / `搜索全部画布素材`;标签行是共享筛选条里 `aria-label="素材筛选标签"` 的分组,chip 用 `aria-pressed` 表达选中;chip = `span.resource-reference-chip[data-resource-reference-id]` | **chip 原子性有正式定义**chip 是 Lexical `DecoratorNode`,纯文本表示固定占 **1 个字符** `U+FFFC` ⇒ 一次 Backspace 只能整体删掉一个 chip,不可能删半截;引用身份是稳定 `resourceId`,改名后按 id 刷新显示名。**多标签是 AND**(口径来自 `packages/shared``assetTagsMatchSelection`,标签取自 manifest `assets[].tags`),并与搜索词、功能分类三者叠加 |
| **S19a** 快速编辑内 @ 引用 | 资源卡「快速编辑」→ 提示词输入区「插入素材引用」或直接输入 `@` → 选素材 → 「修改」 | 快速编辑提示词输入区与聊天输入区**同一套**引用输入区:候选、插入动作、引用模型一致;提示词文本里的引用与聊天同字面量(`@显示名`),提交后该文本原样进入 `derive_local_project_resource``prompt` | #309 C4 / C10 | 快速编辑面板内存在 `aria-label="插入素材引用"``aria-label="快速编辑提示词"`;插入后编辑区出现 `[data-resource-reference-id]` chip;派生请求 `prompt` 等于 `文本@显示名` | 该输入区不渲染内置「AI 润色」(润色仍由面板的「AI 润色」承担,带资源编辑场景上下文与长度上限);多行输入区的 Enter 只换行、不提交;点 `@` 选择器里的候选不会被判成「点外部」而收起面板 |
| **S20** 润色与发送前提醒 | ① 点「AI 润色」→ 看回填与「恢复原文」;② 直接发送(≥40 字)→ 处理提醒弹窗三条动作与「不再提醒」 | 润色只优化文本、不发送不提交;第一次成功润色落下原文快照、可重复润色、失败保留原文;未润色直接发送时弹「发送前提醒」 | L120(气泡对比度)、飞书 §12.1/12.2;#309 C8 | 润色按钮 `aria-label="AI 润色"` + `aria-busy`;出现「恢复原文」按钮;提醒弹窗为 portal `role="dialog"`、标题「发送前提醒」、动作 `关闭` / `使用原文提交` / `AI 润色` + checkbox `不再提醒`;偏好存 `localStorage['agc.chat.prompt-polish-reminder.disabled']` | 触发门禁:trim 后 ≥40 字、不以 `/` 开头、本轮未润色未确认、未勾「不再提醒」。计费不自建,走 server-rs `/api/llm/chat/completions``/api/llm/responses`;「1 泥点」这个数字源码中未硬编码 |
### F 阶段 · 台账核对
| 步骤 | 操作 | 期望结果 | 对应 PRD 条款 | 怎么判"过了" | 已知例外 / 未做项 |
| --- | --- | --- | --- | --- | --- |
| 步骤 | 操作 | 期望结果 | 对应 PRD 条款 | 怎么判"过了" | 已知例外 / 未做项 |
| ---------------- | -------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
| **S21** 全程台账 | 不刷新、不重开项目,回看资源画布与布局 | 新增资源即时投影;两套布局(dependency / type)各自坐标独立、历史 `manuallyPlaced=true` 保留;搜索与详情开关不重置 viewport | L4751、L262265、L320323、L327329、L500 | 两份 sidecar 都在 `.agent/workbench/resource-layouts/{dependency,type}.json`;拖动超过 5px 阈值才提交一次 CAS,成功后 `revision` 递增;**存量项目「第一次打开」时读路径也会写回一次**(旧 `art` / `code` 分区坐标归并到新分区,`revision` +1),并给一次性提示条,条数含**无法对齐被跳过的坐标**(`data-resource-canvas-layout-*`),所以「`revision` 从 1 开始」不是异常 | 双窗口同 revision 的 CAS 冲突(L509)需开两个客户端窗口才可验;`A→B→A` 复用 epoch 的取消语义只能靠定向测试 |
## 4. 前置条件清单
| 项 | 结论 | 依据 |
| --- | --- | --- |
| **是否要重启客户端 / 重新构建** | dev 态 `npm run agc``devUrl`(默认 3080,运行时被覆盖为实际端口),前端改动走 Vite HMR、**不需要 build****Rust 侧改动必须重启客户端**;未提交改动若不在当前进程里,也必须重启一次客户端 | `apps/ai-game-creator-shell/src-tauri/tauri.conf.json:6-11``scripts/start-tauri-dev.mjs:26-44` |
| **是否要重新构建产物** | 只有 `agc:build` / `ai-game-creator-shell:build` 才产出 `../dist`;若验的是打包后的客户端,改前端必须重新 build 并重装 | `tauri.conf.json``frontendDist: "../dist"` |
| **是否要重启 api-server** | **服务端改动需要重启**。本地 `.env` / `.env.local` / `.env.secrets.local` 修改后必须重启 `api-server`;已在 `npm run dev` 终端里可直接敲 `rs api-server`provider / worker / fallback 配置改动要按角色重启对应进程 | `docs/【开发运维】本地开发验证与生产运维-2026-05-15.md:114/136/209/215/809` |
| **启动命令** | 客户端:`npm run agc`(等价 `agc:dev` / `ai-game-creator-shell:dev`);只起前后端不起窗口:`npm run agc:serve`;后端:`npm run dev:api-server` | `package.json:20/155-159/169/22` |
| **端口与健康检查** | api-server 默认 `127.0.0.1:8082`,健康检查 `GET /healthz`(先查 BgFilter worker `/readyz`);**端口会漂移,实际值以 `.app/dev-stack.json` 为准** | `scripts/dev.mjs:237``scripts/start-dev-stack.mjs:28-29/351-353` |
| **登录与余额** | 需要登录;**不需要余额也能进工作台与资源画布**,消耗泥点的是付费生成与 Agent 请求 | `src/app/AuthenticatedClient.tsx:546/568-574``index.tsx:500` |
| **测试项目** | 真机 `What do u wanna do kitten` 已有 61 assets / 5 versions、两份布局 sidecar 齐全,适合当**存量数据**验收对象(只读);要验删除与版本三分支时建议另建新项目 | 只读统计 |
| **门禁命令** | `npm run ai-game-creator-shell:typecheck``npm run test -- apps/ai-game-creator-shell/tests``npm run check:encoding``git diff --check`Rust 侧 `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -- --test-threads=1` | `package.json:123/164/193-194/63` |
| **build-gate 代理假红** | 本地带系统代理跑 `npm run build` 必假红:`NODE_USE_ENV_PROXY=1``HTTP_PROXY/https_proxy` 会触发 `[UNDICI-EHPA] Warning: EnvHttpProxyAgent is experimental`,不被 `ExperimentalWarning` 忽略规则放行。跑前清空 `NODE_USE_ENV_PROXY/HTTP_PROXY/http_proxy/HTTPS_PROXY/https_proxy/ALL_PROXY` | `scripts/build-gate.mjs:33-37/57-67` |
| 项 | 结论 | 依据 |
| ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
| **是否要重启客户端 / 重新构建** | dev 态 `npm run agc``devUrl`(默认 3080,运行时被覆盖为实际端口),前端改动走 Vite HMR、**不需要 build****Rust 侧改动必须重启客户端**;未提交改动若不在当前进程里,也必须重启一次客户端 | `apps/ai-game-creator-shell/src-tauri/tauri.conf.json:6-11``scripts/start-tauri-dev.mjs:26-44` |
| **是否要重新构建产物** | 只有 `agc:build` / `ai-game-creator-shell:build` 才产出 `../dist`;若验的是打包后的客户端,改前端必须重新 build 并重装 | `tauri.conf.json``frontendDist: "../dist"` |
| **是否要重启 api-server** | **服务端改动需要重启**。本地 `.env` / `.env.local` / `.env.secrets.local` 修改后必须重启 `api-server`;已在 `npm run dev` 终端里可直接敲 `rs api-server`provider / worker / fallback 配置改动要按角色重启对应进程 | `docs/【开发运维】本地开发验证与生产运维-2026-05-15.md:114/136/209/215/809` |
| **启动命令** | 客户端:`npm run agc`(等价 `agc:dev` / `ai-game-creator-shell:dev`);只起前后端不起窗口:`npm run agc:serve`;后端:`npm run dev:api-server` | `package.json:20/155-159/169/22` |
| **端口与健康检查** | api-server 默认 `127.0.0.1:8082`,健康检查 `GET /healthz`(先查 BgFilter worker `/readyz`);**端口会漂移,实际值以 `.app/dev-stack.json` 为准** | `scripts/dev.mjs:237``scripts/start-dev-stack.mjs:28-29/351-353` |
| **登录与余额** | 需要登录;**不需要余额也能进工作台与资源画布**,消耗泥点的是付费生成与 Agent 请求 | `src/app/AuthenticatedClient.tsx:546/568-574``index.tsx:500` |
| **测试项目** | 真机 `What do u wanna do kitten` 已有 61 assets / 5 versions、两份布局 sidecar 齐全,适合当**存量数据**验收对象(只读);要验删除与版本三分支时建议另建新项目 | 只读统计 |
| **门禁命令** | `npm run ai-game-creator-shell:typecheck``npm run test -- apps/ai-game-creator-shell/tests``npm run check:encoding``git diff --check`Rust 侧 `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -- --test-threads=1` | `package.json:123/164/193-194/63` |
| **build-gate 代理假红** | 本地带系统代理跑 `npm run build` 必假红:`NODE_USE_ENV_PROXY=1``HTTP_PROXY/https_proxy` 会触发 `[UNDICI-EHPA] Warning: EnvHttpProxyAgent is experimental`,不被 `ExperimentalWarning` 忽略规则放行。跑前清空 `NODE_USE_ENV_PROXY/HTTP_PROXY/http_proxy/HTTPS_PROXY/https_proxy/ALL_PROXY` | `scripts/build-gate.mjs:33-37/57-67` |
## 5. 每步"失败怎么看"
@@ -129,13 +129,13 @@
.reduce((m, e) => { const k = e.dataset.previewHasAlpha ?? '(无 alpha 判据)'; m[k] = (m[k] || 0) + 1; return m; }, {})
```
| 现象 | 说明 | 第一眼做什么 |
| --- | --- | --- |
| 可读类型 + `idle` | 该卡**从未发出预览请求**(可见性门禁没挂上,或卡片在被裁切的容器里) | 先确认跑的是包含修复的新构建(见第 1 节),再核对 observer 补挂是否生效 |
| `failed` + `data-preview-error` | 读取或解码真失败,读 `data-preview-error` 原文 | `retryable=false` 表示永久失败(超尺寸、损坏、类型不支持、不安全 SVG);可见性预取遇 failed 会直接放弃,不会自愈 |
| `loaded` 但无主体节点 | 原生返回的 dataUrl 为空、或解码不出画面 | 这类**不报错**,只能靠脚本 ④ 抓 |
| `placeholder` + `idle` | **不适用**(游戏代码、无法识别的二进制产物等),正常 | 不要当 bug 报 |
| 卡面有棋盘格但 `data-preview-has-alpha` 缺失 | **这张图其实不透明**:棋盘格是图里画进去的(旧构建会额外再铺一层卡面棋盘格,两者叠在一起) | 脚本 ⑤ 分组确认;真透明卡才允许有 `'true'` |
| 现象 | 说明 | 第一眼做什么 |
| -------------------------------------------- | ------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------- |
| 可读类型 + `idle` | 该卡**从未发出预览请求**(可见性门禁没挂上,或卡片在被裁切的容器里) | 先确认跑的是包含修复的新构建(见第 1 节),再核对 observer 补挂是否生效 |
| `failed` + `data-preview-error` | 读取或解码真失败,读 `data-preview-error` 原文 | `retryable=false` 表示永久失败(超尺寸、损坏、类型不支持、不安全 SVG);可见性预取遇 failed 会直接放弃,不会自愈 |
| `loaded` 但无主体节点 | 原生返回的 dataUrl 为空、或解码不出画面 | 这类**不报错**,只能靠脚本 ④ 抓 |
| `placeholder` + `idle` | **不适用**(游戏代码、无法识别的二进制产物等),正常 | 不要当 bug 报 |
| 卡面有棋盘格但 `data-preview-has-alpha` 缺失 | **这张图其实不透明**:棋盘格是图里画进去的(旧构建会额外再铺一层卡面棋盘格,两者叠在一起) | 脚本 ⑤ 分组确认;真透明卡才允许有 `'true'` |
**IPC 侧**:预览只走 4 条命令 `read_local_project_image_preview``read_local_project_media_preview``read_local_project_text_preview``cancel_local_project_resource_preview_scope`。媒体那条的 `category` **只接受 `art` / `audio`**,传栏目值会被原生拒绝,表现是卡片全空、没有缩略图。
@@ -153,26 +153,26 @@ window.__TAURI__.core.invoke = (cmd, args) => {
### 5.2 按步骤的排障索引
| 步骤 | 出问题时第一眼看什么 |
| --- | --- |
| S2 | `.game-workbench-stage``data-resource-view-state`;缺失说明误进 run。再调 `get_local_game_preview_status` 看活体,不要看 `manifest.preview` |
| S4 | 对话后 `.agent/manifest.json``assets[]` 是否增长;底部 Agent Dock 的状态文案 |
| S5 | 各 `[data-resource-book-category]` 下的卡片数量;某栏全空而 manifest 中明显有该类资产时,通常不是分区轴坏了 |
| S6 | 5.1 的四条脚本 |
| S7 | `[data-resource-book-view]` 是否为预期值,`.game-resource-book-scene-titlebar.is-active` 的 category 是否为预期栏目 |
| S8 | `button[aria-label="搜索资源"]``aria-expanded``.game-resource-filter-panel` 是否真在 DOM(三字段同屏)。注意 jsdom 不校验可见性,只有真机截图或 `getBoundingClientRect` 才算数 |
| S9 | `.is-selected` 数量与 `aria-pressed`;工具条容器 `role="toolbar"` 是否存在 |
| S10 | 快速编辑失败文案;若为「当前素材类型不支持图片快速编辑」,说明源资源权威 `assetKind` 不在 17 项白名单里,这不是 V3 新缺陷 |
| S11 | 生成入口在"没有 invoke 桥或项目未就绪"时**整体不渲染**,而不是渲染了但点了没反应 |
| S12 | 保存后对比 `assets[n].tags``assets[n].category`,分类应逐字不变 |
| S13 | 弹窗内 `role="alert"` 的错误文案;写盘失败时应看到文件已回滚原名 |
| S14 | 是否有原生保存对话框;用户取消时是否停止后续批量保存 |
| S15 | 弹窗是否出现 `被 N 个游戏版本使用`;不勾选时版本记录仍在(可读但悬空) |
| S16 | `aria-label="当前版本:…"` 是否随切换更新;`[data-used-by-current-version="true"]` 集合是否变化 |
| S17 | `get_local_game_preview_status` 返回的 `status/url/port``.agent/logs/preview.log``running` 行 |
| S18 | 退出后重开项目是否落回资源总览;Rust 日志中的 `preview.gui_exit.stop_failed` |
| S19 | chip 的 `data-resource-reference-id`;两页签 `role="tab"``aria-selected`;空态文案(`当前版本还没有绑定素材` / `当前项目还没有已登记素材` / `没有匹配的素材` |
| S20 | 弹窗标题「发送前提醒」;`localStorage['agc.chat.prompt-polish-reminder.disabled']` 的值;`润色中…``AI 润色失败,可重试` 状态行 |
| 步骤 | 出问题时第一眼看什么 |
| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| S2 | `.game-workbench-stage``data-resource-view-state`;缺失说明误进 run。再调 `get_local_game_preview_status` 看活体,不要看 `manifest.preview` |
| S4 | 对话后 `.agent/manifest.json``assets[]` 是否增长;底部 Agent Dock 的状态文案 |
| S5 | 各 `[data-resource-book-category]` 下的卡片数量;某栏全空而 manifest 中明显有该类资产时,通常不是分区轴坏了 |
| S6 | 5.1 的四条脚本 |
| S7 | `[data-resource-book-view]` 是否为预期值,`.game-resource-book-scene-titlebar.is-active` 的 category 是否为预期栏目 |
| S8 | `button[aria-label="搜索资源"]``aria-expanded``.game-resource-filter-panel` 是否真在 DOM(三字段同屏)。注意 jsdom 不校验可见性,只有真机截图或 `getBoundingClientRect` 才算数 |
| S9 | `.is-selected` 数量与 `aria-pressed`;工具条容器 `role="toolbar"` 是否存在 |
| S10 | 快速编辑失败文案;若为「当前素材类型不支持图片快速编辑」,说明源资源权威 `assetKind` 不在 17 项白名单里,这不是 V3 新缺陷 |
| S11 | 生成入口在"没有 invoke 桥或项目未就绪"时**整体不渲染**,而不是渲染了但点了没反应 |
| S12 | 保存后对比 `assets[n].tags``assets[n].category`,分类应逐字不变 |
| S13 | 弹窗内 `role="alert"` 的错误文案;写盘失败时应看到文件已回滚原名 |
| S14 | 是否有原生保存对话框;用户取消时是否停止后续批量保存 |
| S15 | 弹窗是否出现 `被 N 个游戏版本使用`;不勾选时版本记录仍在(可读但悬空) |
| S16 | `aria-label="当前版本:…"` 是否随切换更新;`[data-used-by-current-version="true"]` 集合是否变化 |
| S17 | `get_local_game_preview_status` 返回的 `status/url/port``.agent/logs/preview.log``running` |
| S18 | 退出后重开项目是否落回资源总览;Rust 日志中的 `preview.gui_exit.stop_failed` |
| S19 | chip 的 `data-resource-reference-id`;两页签 `role="tab"``aria-selected`;空态文案(`当前版本还没有绑定素材` / `当前项目还没有已登记素材` / `没有匹配的素材` |
| S20 | 弹窗标题「发送前提醒」;`localStorage['agc.chat.prompt-polish-reminder.disabled']` 的值;`润色中…``AI 润色失败,可重试` 状态行 |
**通用日志面**WebView 的 `console.*` 会被镜像进 Rust `application.log`(标记 `__agcWebviewLogBridgeInstalled`),因此 F12 里打的日志事后也能在日志文件里复核。与本次验收最相关的两条是 `preview.gui_exit.stop_failed``[chat-markdown] render failed`
@@ -185,32 +185,32 @@ api-server 是否本次重启:□ 是 □ 否
测试项目:□ 真机 What do u wanna do kitten(只读) □ 新建项目 ____________________
```
| 步骤 | 结果 | 备注(现象 / 证据 / 截图名) |
| --- | --- | --- |
| S1 建项并进工作台 | ☐ 过 ☐ 不过 | |
| S2 落 `resource-overview` | ☐ 过 ☐ 不过 | |
| S3 项目页面板(可选) | ☐ 过 ☐ 不过 ☐ 跳过 | |
| S4 对话生成 → manifest 登记 | ☐ 过 ☐ 不过 | assets___ → ___ |
| S5 7 栏目归属正确 | ☐ 过 ☐ 不过 | 各栏计数:______ |
| S6 卡片预览台账(idle / failed / 空) | ☐ 过 ☐ 不过 | idle__ failed__ 空:__ |
| S7 栏目导航(含「所有资源」/ 下一页 / 空栏目) | ☐ 过 ☐ 不过 | |
| S8 单一筛选浮层(三字段)+ 不清条件 | ☐ 过 ☐ 不过 | |
| S9 选择 / 多选 / 框选 / 工具条 | ☐ 过 ☐ 不过 | 实际渲染的动作:______ |
| S10 图片快速编辑(新素材 + 血缘) | ☐ 过 ☐ 不过 | 是否 400:☐ 否 ☐ 是(原因:____ |
| S11 生成入口(视频 / 音效 / 背景音乐) | ☐ 过 ☐ 不过 ☐ 未验 | |
| S12 编辑标签(只改 tags) | ☐ 过 ☐ 不过 | category 是否原样:☐ 是 ☐ 否 |
| S13 重命名(id 不变) | ☐ 过 ☐ 不过 | |
| S14 下载(另存为) | ☐ 过 ☐ 不过 | |
| S15 删除三分支 | ☐ 无引用 ☐ 引用未勾选 ☐ 勾选连带 | |
| S16 版本切换 + 当前使用高亮 + @ 同步 | ☐ 过 ☐ 不过 | |
| S17 播放 → 运行视图 + 本地预览 | ☐ 过 ☐ 不过 | url______________ |
| S18 停止预览 / 退出收尾对齐 stopped | ☐ 过 ☐ 不过 | |
| S19 @ 引用两页签 + 独立筛选 + chip 原子性 | ☐ 过 ☐ 不过 | |
| S19a @ 面板标签筛选(多标签 AND,叠加关键字与功能分类) | ☐ 过 ☐ 不过 | |
| S19b 快速编辑提示词内 @ 引用(同聊天引用模型与字面量) | ☐ 过 ☐ 不过 | |
| S20 润色 + 发送前提醒 + 不再提醒 | ☐ 过 ☐ 不过 | |
| S21 台账(即时同步 / 双布局 / 无溢出) | ☐ 过 ☐ 不过 | 1280×800 ☐ 1280×720 ☐ |
| 门禁:typecheck / AGC tests / check:encoding / git diff --check | ☐ 过 ☐ 不过 | |
| 步骤 | 结果 | 备注(现象 / 证据 / 截图名) |
| --------------------------------------------------------------- | -------------------------------- | ------------------------------------- |
| S1 建项并进工作台 | ☐ 过 ☐ 不过 | |
| S2 落 `resource-overview` | ☐ 过 ☐ 不过 | |
| S3 项目页面板(可选) | ☐ 过 ☐ 不过 ☐ 跳过 | |
| S4 对话生成 → manifest 登记 | ☐ 过 ☐ 不过 | assets**_ → _** |
| S5 7 栏目归属正确 | ☐ 过 ☐ 不过 | 各栏计数:**\_\_** |
| S6 卡片预览台账(idle / failed / 空) | ☐ 过 ☐ 不过 | idle** failed** 空:\_\_ |
| S7 栏目导航(含「所有资源」/ 下一页 / 空栏目) | ☐ 过 ☐ 不过 | |
| S8 单一筛选浮层(三字段)+ 不清条件 | ☐ 过 ☐ 不过 | |
| S9 选择 / 多选 / 框选 / 工具条 | ☐ 过 ☐ 不过 | 实际渲染的动作:**\_\_** |
| S10 图片快速编辑(新素材 + 血缘) | ☐ 过 ☐ 不过 | 是否 400:☐ 否 ☐ 是(原因:\_\_\_\_ |
| S11 生成入口(视频 / 音效 / 背景音乐) | ☐ 过 ☐ 不过 ☐ 未验 | |
| S12 编辑标签(只改 tags) | ☐ 过 ☐ 不过 | category 是否原样:☐ 是 ☐ 否 |
| S13 重命名(id 不变) | ☐ 过 ☐ 不过 | |
| S14 下载(另存为) | ☐ 过 ☐ 不过 | |
| S15 删除三分支 | ☐ 无引用 ☐ 引用未勾选 ☐ 勾选连带 | |
| S16 版本切换 + 当前使用高亮 + @ 同步 | ☐ 过 ☐ 不过 | |
| S17 播放 → 运行视图 + 本地预览 | ☐ 过 ☐ 不过 | url******\_\_****** |
| S18 停止预览 / 退出收尾对齐 stopped | ☐ 过 ☐ 不过 | |
| S19 @ 引用两页签 + 独立筛选 + chip 原子性 | ☐ 过 ☐ 不过 | |
| S19a @ 面板标签筛选(多标签 AND,叠加关键字与功能分类) | ☐ 过 ☐ 不过 | |
| S19b 快速编辑提示词内 @ 引用(同聊天引用模型与字面量) | ☐ 过 ☐ 不过 | |
| S20 润色 + 发送前提醒 + 不再提醒 | ☐ 过 ☐ 不过 | |
| S21 台账(即时同步 / 双布局 / 无溢出) | ☐ 过 ☐ 不过 | 1280×800 ☐ 1280×720 ☐ |
| 门禁:typecheck / AGC tests / check:encoding / git diff --check | ☐ 过 ☐ 不过 | |
## 7. 风险与盲区
@@ -218,30 +218,30 @@ api-server 是否本次重启:□ 是 □ 否
需求源头文档《GameAgent客户端画布与资源工作台 V3.0》(飞书 wiki 文档,文档 token 以 `N4Kiw…` 开头)的文本层共 **796 行文本、5 段视频、2 张图片**。以下条款**只能靠视频或截图验收**,文本层只有"标题式"描述,缺可判定细节:
| # | 位置 | 媒体 | 文本只到哪 | 缺什么(无法从文本验收的部分) |
| --- | --- | --- | --- | --- |
| 1 | §3.1 默认视图 | **截图**(图注「资源管理视图」) | "默认进入'全部资源'视图……功能画布分类:UI 与交互、角色与对象、场景与环境、音频、文档、待归类" | 总览的**版式**:缩略卡片排布、层叠张数、缩略图尺寸与间距、标题栏位置 |
| 2 | §3.2 筛选 | **视频** `20260907-0309-29.2729736.mp4`"增加自定义标签" | "点击已有标签 / 输入新标签 / 保存后同步进全局标签库" | 面板**长什么样**、pill 排布、输入框提示文案、保存按钮位置 |
| 3 | §3.2 筛选 | **视频** `20260907-0321-46.9223141.mp4`"查看素材标签" | "标签不默认显示在卡片上,需在'编辑标签'中查看" | 查看入口与展示形态 |
| 4 | §3.2 筛选 | **视频** `20260907-0322-33.1365605.mp4`("按照标签筛选素材") | 文本给了筛选逻辑(条件即时生效 / 清空恢复 / 不影响归属) | 筛选面板的交互与视觉:多标签是 AND 还是 OR 的**界面表达**、标签汇总列表样式 |
| 5 | §3.2 筛选 | **视频** `20260907-0323-34.3948031.mp4`"删除标签" | "删除标签时从所有素材和筛选条件中移除;'管理全部标签'里可删" | 「管理全部标签」这个入口 / 面板本身**只存在于视频**,AGC 侧未找到对应实现 |
| 6 | §10.1 / §10.2 @ 引用 | **视频** `20260906-0812-23.9790394.mp4` + **截图**(提示框"请把@开始按钮并保持原位置",截图注明"参考 loveart" | "资源卡工具栏提供 @引用 工具""所有@后产生的标签都在对话框当中,用户可以在标签前后编辑上下文" | chip 的**外观**(是否带图标 / 缩略图 / 颜色)、插入后光标落点、与 Lovart 的对照样式 |
| 7 | PRD L139(项目管理页) | 文档**明确要求**提交截图 + 视频 | "截图必须使用含 Web、Godot 和无效状态的 populated fixture;视频必须演示搜索、清除、行尾菜单及可观察的项目操作结果" | 这条本身就是"必须交视频"的要求,不是可由文本判定的功能条款 |
| # | 位置 | 媒体 | 文本只到哪 | 缺什么(无法从文本验收的部分) |
| --- | ---------------------- | --------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------- |
| 1 | §3.1 默认视图 | **截图**(图注「资源管理视图」) | "默认进入'全部资源'视图……功能画布分类:UI 与交互、角色与对象、场景与环境、音频、文档、待归类" | 总览的**版式**:缩略卡片排布、层叠张数、缩略图尺寸与间距、标题栏位置 |
| 2 | §3.2 筛选 | **视频** `20260907-0309-29.2729736.mp4`"增加自定义标签" | "点击已有标签 / 输入新标签 / 保存后同步进全局标签库" | 面板**长什么样**、pill 排布、输入框提示文案、保存按钮位置 |
| 3 | §3.2 筛选 | **视频** `20260907-0321-46.9223141.mp4`"查看素材标签" | "标签不默认显示在卡片上,需在'编辑标签'中查看" | 查看入口与展示形态 |
| 4 | §3.2 筛选 | **视频** `20260907-0322-33.1365605.mp4`("按照标签筛选素材") | 文本给了筛选逻辑(条件即时生效 / 清空恢复 / 不影响归属) | 筛选面板的交互与视觉:多标签是 AND 还是 OR 的**界面表达**、标签汇总列表样式 |
| 5 | §3.2 筛选 | **视频** `20260907-0323-34.3948031.mp4`"删除标签" | "删除标签时从所有素材和筛选条件中移除;'管理全部标签'里可删" | 「管理全部标签」这个入口 / 面板本身**只存在于视频**,AGC 侧未找到对应实现 |
| 6 | §10.1 / §10.2 @ 引用 | **视频** `20260906-0812-23.9790394.mp4` + **截图**(提示框"请把@开始按钮并保持原位置",截图注明"参考 loveart" | "资源卡工具栏提供 @引用 工具""所有@后产生的标签都在对话框当中,用户可以在标签前后编辑上下文" | chip 的**外观**(是否带图标 / 缩略图 / 颜色)、插入后光标落点、与 Lovart 的对照样式 |
| 7 | PRD L139(项目管理页) | 文档**明确要求**提交截图 + 视频 | "截图必须使用含 Web、Godot 和无效状态的 populated fixture;视频必须演示搜索、清除、行尾菜单及可观察的项目操作结果" | 这条本身就是"必须交视频"的要求,不是可由文本判定的功能条款 |
**补齐方式**:① 由产品口述 2–3 句关键视觉约束后改写为判据;② 用 `lark-cli docs +media-download` 取出这 5 段视频与 2 张图并抽取静帧后逐帧写判据;③ 降级为"人工目视,不计入自动化判据",记录表里只打勾。
### 7.2 本地环境无法验证 / 需要额外条件的项
| 项 | 为什么本地不可验 | 建议 |
| --- | --- | --- |
| 双窗口布局 CAS 冲突(L509) | 需同一项目同时开两个客户端窗口、基于同一 revision 同时写入 | 标为"需专项构造",或只跑 `resourceCanvasLayoutContract` 定向测试 |
| 4096 资源链式 fixture 性能(L522 | 需构造大规模 fixture | 交给定向测试,不进人工主线 |
| `1280×720` / `1280×800` 视觉验收(L512、L577) | 必须真实窗口 + 截图或测量 `document/body``client``scroll` | 每条视觉结论附截图;jsdom 不做布局,vitest 中的可见性断言是假守卫 |
| Windows 平台性断言 | 非 Linux 平台本地预览用**随机临时端口**;端口池耗尽回退、`GENARRATIVE_DEV_PORT_RANGE`、命令沙箱模块**只在 Linux 生效**;文件锁 Unix 用 `flock`、Windows 不共享句柄 | 涉及这些的判据不能反推平台行为。反之"delete-pending 等行为级 Windows 用例 CI 没有 runner,只在 Windows 本地跑"——本地反而能验到 CI 验不到的 |
| `build-gate` 代理假红 | 见第 4 节 | 跑前清代理变量,或用 `build:raw` |
| worktree 专属假红 | worktree 里 `check-repository-ci.sh` 第一步即"比较基线不可用"jsdom 拿不到 fit key | 改用 Windows git 逐条等价执行;这两类红不当缺陷 |
| 既有失败用例 | 技术方案记录过 AGC 前端 5 条既有失败(C3 交互改造后待更新用例);Rust 并行跑 `project::` 约 10 条环境依赖失败,仓库口径是 `--test-threads=1` | 预置"已知红名单",避免重复排查 |
| F12 看不到 Rust → api-server 的 HTTP | 见 5.1 更正 | 用 api-server 日志或 `.agent/logs` |
| 项 | 为什么本地不可验 | 建议 |
| ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------ |
| 双窗口布局 CAS 冲突(L509) | 需同一项目同时开两个客户端窗口、基于同一 revision 同时写入 | 标为"需专项构造",或只跑 `resourceCanvasLayoutContract` 定向测试 |
| 4096 资源链式 fixture 性能(L522 | 需构造大规模 fixture | 交给定向测试,不进人工主线 |
| `1280×720` / `1280×800` 视觉验收(L512、L577) | 必须真实窗口 + 截图或测量 `document/body``client``scroll` | 每条视觉结论附截图;jsdom 不做布局,vitest 中的可见性断言是假守卫 |
| Windows 平台性断言 | 非 Linux 平台本地预览用**随机临时端口**;端口池耗尽回退、`GENARRATIVE_DEV_PORT_RANGE`、命令沙箱模块**只在 Linux 生效**;文件锁 Unix 用 `flock`、Windows 不共享句柄 | 涉及这些的判据不能反推平台行为。反之"delete-pending 等行为级 Windows 用例 CI 没有 runner,只在 Windows 本地跑"——本地反而能验到 CI 验不到的 |
| `build-gate` 代理假红 | 见第 4 节 | 跑前清代理变量,或用 `build:raw` |
| worktree 专属假红 | worktree 里 `check-repository-ci.sh` 第一步即"比较基线不可用"jsdom 拿不到 fit key | 改用 Windows git 逐条等价执行;这两类红不当缺陷 |
| 既有失败用例 | 技术方案记录过 AGC 前端 5 条既有失败(C3 交互改造后待更新用例);Rust 并行跑 `project::` 约 10 条环境依赖失败,仓库口径是 `--test-threads=1` | 预置"已知红名单",避免重复排查 |
| F12 看不到 Rust → api-server 的 HTTP | 见 5.1 更正 | 用 api-server 日志或 `.agent/logs` |
### 7.3 已知未做 / 已取消(不要报成缺陷)
@@ -252,7 +252,7 @@ api-server 是否本次重启:□ 是 □ 否
5. **「首轮进度投影」无编码级判据**,可见性由底部 Agent 状态栏承载。
6. **浮出工具条的视觉位置未做真机核验**jsdom 不执行 Web Animations,只覆盖结构与 class)。
7. **重命名不改游戏源码中的旧 `assets/<name>` 引用**
8. **C9 参考图弹窗在 AGC 侧入口未确认**:全仓检索 `添加参考图` 只命中共享 `src/components/image-editor/*`AGC 的 `ResourceCanvasGenerationPanelView` 未引用它,可能处于"组件已实现、AGC 入口未接"状态。建议真机点一次生成 / 编辑面板确认;若无入口,C9 不计入本轮主线。**注**:资源替换复用同一个 `ImageCanvasProjectAssetPickerDialog`,它在 AGC 侧是首次使用(同一个弹窗、不同的 opt-in 参数)。
8. **C9 参考图弹窗在 AGC 侧入口未确认**:全仓检索 `添加参考图` 只命中共享 `src/components/image-editor/*`AGC 的 `ResourceCanvasGenerationPanelView` 未引用它,可能处于"组件已实现、AGC 入口未接"状态。建议真机点一次生成 / 编辑面板确认;若无入口,C9 不计入本轮主线。**注**:资源替换复用同一个 `ImageCanvasProjectAssetPickerDialog`,它在 AGC 侧是首次使用(2026-09-21 起走该组件的 `nonModal` 非模态形态:不铺遮罩、面板锚在画布容器右上角,见 S15a)。
9. **运行视图存在「点选素材」按钮**,但 #309 的"不做"清单包含运行画面点选,口径冲突待定。
10. **替换成功后运行画面不会立刻变**:按 C7 与本轮口径只做记录层 + UI 层,可见变化是资源卡"当前使用"高亮移到替换素材与 `@` 面板"当前版本素材"更新;**不会新增版本卡、也不切换版本**,这不等于替换失败。
11. **替换候选弹窗不加载缩略图**:AGC 的素材预览要经带 scope 的原生读取器拿 Blob URL,弹窗内没有同步 `src`,因此候选行只渲染类型占位(不给 `<img>` 喂空串、不挂破图)。
@@ -261,15 +261,15 @@ api-server 是否本次重启:□ 是 □ 否
### 7.4 文档与代码的偏差(需明确按哪个判)
| 偏差 | 文档表述 | 代码实际 | 建议 |
| --- | --- | --- | --- |
| 资源卡「打开详情」 | PRD L6162 要求"打开详情"与"播放 / 暂停"是可分别键盘聚焦的同级按钮 | 不存在该按钮(测试显式断言为 `null`);真正入口是 `选中资源:<栏目> <名>`,播放按钮是 `播放/暂停 <名>`;**只读信息浮层已回归**(2026-09-11 工具条「信息」动作) | 按 #309 C3(取消资源详情面板与全屏编辑路由)判,**C3 取消的仍只是可编辑详情面板与全屏编辑路由**;PRD L61–62 建议同步修订 |
| 运行入口不可用文案 | PRD L165 显示"当前无可运行版本",但仍允许点击 | 源码中无此字符串;实现为「首个可运行原型尚未完成,运行视图暂不可用」;且播放按钮 `disabled`,只有"运行"tab 不 disabled、用 `data-unavailable` 表达 | 建议按"tab 可点 + 播放按钮置灰"判,并把 PRD L165 文案对齐实现 |
| 画布生成入口 | #309「画布生成入口未接」 | 已接两条:浮层入口只做视频;音频与图片类在栏目画布底部工具栏(见 S11a / PRD §3.10 | 按代码与当前产品口径判,并更新 #309 进度段 |
| 分类手设入口 | 旧版 PRD「用户可在分类与标签面板手动设置 category」 | 已移除;面板只编辑 tags(已随 `6bdc8bbd9` 入库) | 按新口径判 |
| 真机 manifest 分类分布 | — | 落盘 `unclassified 57 / ui-interaction 3 / scene 1` | **读时自愈**会把 kind 可明确分类的资产显示到正确栏目,判定必须看 UI 栏目计数,不能看 manifest 文件,否则必然误报"分类不生效" |
| 偏差 | 文档表述 | 代码实际 | 建议 |
| ---------------------- | ------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| 资源卡「打开详情」 | PRD L6162 要求"打开详情"与"播放 / 暂停"是可分别键盘聚焦的同级按钮 | 不存在该按钮(测试显式断言为 `null`);真正入口是 `选中资源:<栏目> <名>`,播放按钮是 `播放/暂停 <名>`;**只读信息浮层已回归**(2026-09-11 工具条「信息」动作) | 按 #309 C3(取消资源详情面板与全屏编辑路由)判,**C3 取消的仍只是可编辑详情面板与全屏编辑路由**;PRD L61–62 建议同步修订 |
| 运行入口不可用文案 | PRD L165 显示"当前无可运行版本",但仍允许点击 | 源码中无此字符串;实现为「首个可运行原型尚未完成,运行视图暂不可用」;且播放按钮 `disabled`,只有"运行"tab 不 disabled、用 `data-unavailable` 表达 | 建议按"tab 可点 + 播放按钮置灰"判,并把 PRD L165 文案对齐实现 |
| 画布生成入口 | #309「画布生成入口未接」 | 已接两条:浮层入口只做视频;音频与图片类在栏目画布底部工具栏(见 S11a / PRD §3.10 | 按代码与当前产品口径判,并更新 #309 进度段 |
| 分类手设入口 | 旧版 PRD「用户可在分类与标签面板手动设置 category」 | 已移除;面板只编辑 tags(已随 `6bdc8bbd9` 入库) | 按新口径判 |
| 真机 manifest 分类分布 | — | 落盘 `unclassified 57 / ui-interaction 3 / scene 1` | **读时自愈**会把 kind 可明确分类的资产显示到正确栏目,判定必须看 UI 栏目计数,不能看 manifest 文件,否则必然误报"分类不生效" |
## 8. 验收边界
- 本用例覆盖:资源总览 / 栏目分页画布、资源卡预览与本地受控读取、资源卡选择与多选、快速编辑派生(含提示词内 @ 引用)、画布生成入口(浮层视频 + 栏目工具栏图片类与音频)、编辑标签、重命名、下载、删除三分支、版本切换与当前使用高亮、**资源替换(入口放行判据 / 候选禁用与原因 / 格式提示 / 写入载荷 / 拒绝零副作用 / 成功后不切版本不重载预览 / 弹窗与画布点选两条入口同一条写入链路)**、聊天 @ 引用(两页签 + 功能分类 + 多标签筛选 + 搜索)与原子 chip、AI 润色与发送前提醒、本地预览启动与退出收尾、两份布局 sidecar 台账。
- 本用例覆盖:资源总览 / 栏目分页画布、资源卡预览与本地受控读取、资源卡选择与多选、快速编辑派生(含提示词内 @ 引用)、画布生成入口(浮层视频 + 栏目工具栏图片类与音频)、编辑标签、重命名、下载、删除三分支、版本切换与当前使用高亮、**资源替换(入口放行判据 / 候选禁用与原因 / 格式提示 / 写入载荷 / 拒绝零副作用 / 成功后不切版本不重载预览 / 非模态面板 + 画布点选目标同一条写入链路)**、聊天 @ 引用(两页签 + 功能分类 + 多标签筛选 + 搜索)与原子 chip、AI 润色与发送前提醒、本地预览启动与退出收尾、两份布局 sidecar 台账。
- 本用例不覆盖(另走专项或定向测试):双窗口 CAS 冲突、大规模 fixture 性能、素材创作无限画布阶段一至五的草稿 / 事务 / 恢复矩阵(见 `【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md` 与配套专题)、UI 编辑器子路由、主站图片编辑器回归(见 PRD §7.7)。资源替换的后端矩阵(版本绑定改写放行的六条不变式与两组互斥、绑定改写两条路径、硬门禁与提示、CAS、四条拒绝路径、读时自愈口径、审计留痕)见 `apps/ai-game-creator-shell/src-tauri/src/project/manifest/version_binding_rewrite_tests.rs``apps/ai-game-creator-shell/src-tauri/src/tests/version_resource_replacement.rs`,前端矩阵见 `apps/ai-game-creator-shell/tests/resourceVersionReplacement*.test.ts(x)`
@@ -1,6 +1,6 @@
# AGC 聊天素材引用
更新时间:2026-09-08
更新时间:2026-09-21
AGC 聊天输入框支持以结构化引用标记当前项目已登记素材。输入 `@` 会按素材名称、资源 ID 和类型过滤候选项;也可以点击输入框右侧的 `@` 按钮打开素材选择面板。
@@ -15,6 +15,14 @@ AGC 聊天输入框支持以结构化引用标记当前项目已登记素材。
提交时前端把 Lexical 草稿直接编码为受限 Response API user `message` item`input_text` 与 AGC 引用 part 按编辑顺序内联在同一个 `content[]` 中。资源引用只携带稳定 `resourceId`;运行画面引用携带区域语义摘要及关联资源 ID。Rust 是唯一 schema source(通过 `ts-rs` 生成 TypeScript 绑定),在发起回合前完成 item 白名单、字段边界、manifest 归属和路径安全校验;校验失败时本轮不持久化、不发送。通过校验的 canonical item 以 `response_item` envelope 写入项目历史,随后由 Rust 将 AGC part 临时转换为 Codex 可接受的 `input_text`,保持原始 content 顺序。已有标准 `response_item` 原样读取与复用;旧 legacy conversation 行不再提供 fallback。
## 拖拽引用(2026-09-21
除了 `@` 输入与「引用」按钮,资源卡还支持**拖到对话**:在资源画布上按住一张卡拖到右侧 Agent 对话栏,松手即把这次拖动真正参与位移的那批素材整批 `@` 进输入框(框选多选后拖任意一张 = 整批引用;拖未选中的卡 = 只引用它自己)。
- 拖动期间落点会铺一层虚线框与「松手即可 @ 引用 N 项素材」提示;拖回画布内松手仍是原来的排版语义(写手动坐标),两条语义由落点决定。
- 只认**已登记到 manifest** 的素材,引用身份、来源标记 `resource-card` 与「引用」按钮逐字一致,所以同一素材两处进来是同一枚引用(去重键也一样);一条都引用不了时(素材都未登记)用提示条说明原因。
- 一次松手只派发一次批量事件(`RESOURCE_REFERENCE_INSERT_MANY_EVENT`),草稿只重建一次、插入顺序即拖动集合顺序。
当前已完成:
- 三个聊天入口共用 `ResourceReferenceInput`
@@ -22,6 +30,7 @@ AGC 聊天输入框支持以结构化引用标记当前项目已登记素材。
- `@` 按钮打开素材选择面板;
- 支持搜索、类型筛选和多选;
- 素材芯片可插入、编辑和删除;
- 资源画布支持把资源卡拖到对话栏批量引用(2026-09-21,见上一节);
- 资源画布素材卡的选中工具条提供「引用」入口:图标本身就是 `@`,可见文案与 `title` 都只写「引用」,插入对话里的仍是 `@素材名` 芯片;
- 运行画面提供“点选素材”,可选中 HTML 区域并生成 `runtime-region` 引用;
- 提交请求携带 canonical user message item
@@ -15,7 +15,7 @@ pipeline {
GENARRATIVE_NPM_VERSION = '10.9.7'
AGC_OSS_BUCKET = 'agc-dev'
AGC_OSS_ENDPOINT = 'oss-rg-china-mainland.aliyuncs.com'
AGC_WINDOWS_PATH = 'C:\\Tools\\Git\\cmd;C:\\Program Files\\nodejs;C:\\Program Files\\dotnet;C:\\Users\\Administrator\\.cargo\\bin;C:\\Tools\\ossutil;C:\\BuildTools\\Common7\\IDE\\CommonExtensions\\Microsoft\\CMake\\CMake\\bin;C:\\Python312;C:\\Python312\\Scripts;C:\\Windows\\system32;C:\\Windows;C:\\Windows\\System32\\Wbem;C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\;C:\\Windows\\System32\\OpenSSH\\'
AGC_WINDOWS_PATH = 'C:\\Tools\\Git\\cmd;C:\\Program Files\\nodejs;C:\\Program Files\\dotnet;C:\\Users\\Administrator\\.cargo\\bin;C:\\Tools\\ossutil;C:\\Windows\\system32;C:\\Windows;C:\\Windows\\System32\\Wbem;C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\;C:\\Windows\\System32\\OpenSSH\\'
}
parameters {
@@ -82,31 +82,15 @@ pipeline {
if ($actualNpm -ne $env:GENARRATIVE_NPM_VERSION) {
throw "npm 版本不匹配:期望 $($env:GENARRATIVE_NPM_VERSION),实际 $actualNpm"
}
foreach ($commandName in @('node', 'npm', 'rustc', 'cargo', 'dotnet', 'cmake', 'python')) {
foreach ($commandName in @('node', 'npm', 'rustc', 'cargo', 'dotnet')) {
if (-not (Get-Command $commandName -ErrorAction SilentlyContinue)) {
throw "Windows AGC 节点缺少 $commandName,请安装并加入 AGC_WINDOWS_PATH。"
throw "Windows AGC 节点缺少 $commandName,请安装并加入 Jenkins Agent PATH。"
}
}
$dotnetSdks = @(dotnet --list-sdks)
if ($LASTEXITCODE -ne 0 -or -not ($dotnetSdks -match '^10[.]')) {
throw 'Windows AGC 节点需要 .NET 10 SDK 才能构建 Unity Attach helper。'
}
$cmakeVersion = ((cmake --version) | Select-Object -First 1).Trim()
if ($cmakeVersion -notmatch '([0-9]+)[.]([0-9]+)[.]([0-9]+)') {
throw "无法解析 CMake 版本:$cmakeVersion"
}
$cmakeMajor = [int]$Matches[1]
$cmakeMinor = [int]$Matches[2]
if ($cmakeMajor -lt 3 -or ($cmakeMajor -eq 3 -and $cmakeMinor -lt 25)) {
throw "Godot 原生扩展需要 CMake 3.25 及以上,当前:$cmakeVersion"
}
if (-not (@(cmake --help) -match 'Visual Studio 17 2022')) {
throw "Windows AGC 节点缺少 Visual Studio 2022 C++ 生成器,无法构建 Godot 原生扩展。"
}
$pythonVersion = ((python --version 2>&1) | Select-Object -First 1).Trim()
if ($pythonVersion -notmatch '^Python 3[.]') {
throw "Godot 原生扩展需要 Python 3,当前:$pythonVersion"
}
$ossutil = ([string]$env:OSSUTIL_BIN).Trim()
if (-not $ossutil) { $ossutil = 'ossutil' }
if (-not (Get-Command $ossutil -ErrorAction SilentlyContinue)) {
@@ -117,8 +101,6 @@ pipeline {
Write-Host "rustc=$((rustc --version).Trim())"
Write-Host "cargo=$((cargo --version).Trim())"
Write-Host "dotnet-sdk=$((dotnet --version).Trim())"
Write-Host "cmake=$cmakeVersion"
Write-Host "python=$pythonVersion"
Write-Host "jenkins-user=$([System.Security.Principal.WindowsIdentity]::GetCurrent().Name)"
Write-Host "local-app-data=$env:LOCALAPPDATA"
$targetTools = Join-Path $PWD 'apps/ai-game-creator-shell/src-tauri/target/.tauri'

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