合并 fix/agc-resource-layout-manual:资源画布改为手动整理口径

- 合并 WS2 分支(新素材不再自动重排 + 显式「整理画布」+ 生成后自动聚焦)
- index.tsx 与 useProjectResourceCanvasLayout.ts 自动合并成功,无需手工介入
- decision-log.md / pitfalls.md 冲突按「两侧语义都保住」解:保留 origin/master 新增的
  「AGC 壳 Rust 套件分片并行」与「根门禁 label 字面量契约」两条,同时保留 WS2 新增的
  「AGC 资源画布改为手动整理」与「不要恢复无条件重派生」两条,只删除冲突标记行
- 新增 tests/resourceCanvasManualLayout.test.tsx
This commit is contained in:
2026-09-14 17:16:58 +08:00
5 changed files with 1032 additions and 6 deletions
@@ -2399,7 +2399,8 @@ export default function ProjectDevelopmentView({
topology: resourceLayoutTopology,
initializationReady: resourceGraphInitializationReady,
renderFallbackWhileBlocked: resourceGraphFailed,
rederiveAutomaticPositions: resourceGraphReady,
// 画布不再自动重派生:新增素材只补位,整张重排只能由「整理画布」显式发起。
rederiveAutomaticPositions: false,
});
const typeLayout = useProjectResourceCanvasLayout({
projectPath,
@@ -2407,8 +2408,38 @@ export default function ProjectDevelopmentView({
mode: 'type',
resources: resourcesWithCanvasCardSize,
initializationReady: true,
rederiveAutomaticPositions: true,
rederiveAutomaticPositions: false,
});
/**
* sidecar
*
* `rederiveAutomaticPositions` `resourceGraphReady`
*
*
* ref "这一次已经做过"
* `dependencyLayout.ready` "关系图先就绪、sidecar 后读完"
* tab
*/
const dependencyRederiveScopeRef = useRef<string | null>(null);
const dependencyLayoutReady = dependencyLayout.ready;
const rederiveDependencyLayout = dependencyLayout.rederiveNow;
useEffect(() => {
if (!resourceGraphReady || !dependencyLayoutReady) {
return;
}
const scopeKey = JSON.stringify([projectPath, manifest.projectId]);
if (dependencyRederiveScopeRef.current === scopeKey) {
return;
}
dependencyRederiveScopeRef.current = scopeKey;
rederiveDependencyLayout();
}, [
dependencyLayoutReady,
manifest.projectId,
projectPath,
rederiveDependencyLayout,
resourceGraphReady,
]);
const activeResourceLayout =
sortMode === 'dependency' ? dependencyLayout : typeLayout;
const resourceLayout = activeResourceLayout.layout;
@@ -5141,6 +5172,72 @@ export default function ProjectDevelopmentView({
[advanceFocusGeneration, manifest.assets, openUiDesignEditor],
);
/**
* `manifest.assets` **** id
*
* "刚生成" / 线
* id
*
* `pendingResourceFocusRef` +
* `advanceFocusGeneration()` DOM
* 沿
*
* /
*
*/
const seenManifestAssetIdsRef = useRef<{
scopeKey: string;
ids: Set<string>;
} | null>(null);
useEffect(() => {
const scopeKey = JSON.stringify([projectPath, manifest.projectId]);
const assetIds = manifest.assets.map((asset) => asset.id);
const previous = seenManifestAssetIdsRef.current;
if (!previous || previous.scopeKey !== scopeKey) {
seenManifestAssetIdsRef.current = { scopeKey, ids: new Set(assetIds) };
return;
}
const addedIds = assetIds.filter((id) => !previous.ids.has(id));
for (const id of assetIds) {
previous.ids.add(id);
}
if (addedIds.length === 0) {
return;
}
// 一次可能进多条(例如一次派生产出多个产物):聚焦清单里最后落地的那一条。
const addedAssetId = addedIds[addedIds.length - 1]!;
const resourceId = `asset:${addedAssetId}`;
// 资源投影是同步的:这里查不到对应资源说明这条素材不会出现在画布上,
// 不为它挂聚焦意图,免得裁决链一直停在"等待投影"。
if (!resources.some((resource) => resource.id === resourceId)) {
return;
}
if (pendingResourceFocusRef.current?.resourceId === resourceId) {
return;
}
const flowId = crypto.randomUUID();
const focusGeneration = advanceFocusGeneration();
activeFocusFlowIdRef.current = flowId;
pendingResourceFocusRef.current = {
flowId,
saveAttemptId: flowId,
sessionId: flowId,
draftId: flowId,
commitId: `asset-added:${addedAssetId}`,
projectPath,
projectId: manifest.projectId,
focusGeneration,
resourceId,
completed: false,
};
}, [
advanceFocusGeneration,
manifest.assets,
manifest.projectId,
projectPath,
resources,
]);
useEffect(() => {
if (uiEditorRoute) return;
const completed = manifest.assets.find(
@@ -7139,6 +7236,21 @@ export default function ProjectDevelopmentView({
<ListFilter size={15} aria-hidden="true" />
</button>
{/*
/
/
*/}
<button
type="button"
aria-label="整理画布"
disabled={!resourceLayoutReady}
aria-busy={resourceLayoutSaving}
onClick={() => activeResourceLayout.rederiveNow()}
>
<LayoutGrid size={15} aria-hidden="true" />
</button>
</div>
</>
) : null}
@@ -67,6 +67,15 @@ type ResourceLayoutWriteIntent = {
scopeEpoch: number;
resourceSignature: string;
conflictRetries: number;
/**
* 显式「整理画布」写意图:这一笔写回按 `rederive` 策略丢掉全部自动坐标重算。
*
* 仍然是"坐标真的变了才落盘"(沿用既有 `changed` 门):已经整齐的画布按一下不该产生
* 一次无意义的 CAS / revision 推进,也不该在关系图被截断这类"重算结果同样可信但不能
* 声称变过"的场景里凭空写一笔。自动(签名变化触发)的资源同步永远是 `false`,
* 只补新卡、不动既有坐标。
*/
rederive: boolean;
};
type LayoutWriteIntent = ManualLayoutWriteIntent | ResourceLayoutWriteIntent;
@@ -74,9 +83,10 @@ type LayoutWriteIntent = ManualLayoutWriteIntent | ResourceLayoutWriteIntent;
const MAX_RESOURCE_SYNC_CONFLICT_RETRIES = 2;
/**
* 自动坐标策略。`rederive` 在每次协调时丢弃全部自动坐标按当前资源与拓扑重算
* PRD 要求的「关系图首次就绪 / `dependencyDepth` / 拓扑身份签名变化后按最终拓扑
* 重算」依赖它;`preserve` 只补新资源 ID,不重排任何已存在的坐标
* 自动坐标策略。`rederive` 丢掉全部自动坐标按当前资源与拓扑重算(手动坐标原样保留);
* 它现在只由显式动作触发——用户的「整理画布」按钮,以及「关系图首次就绪」那一次
* `rederiveNow()`。`preserve` 只补新资源 ID,不重排任何已存在的坐标,是画布默认口径:
* 新增一张素材不再牵动整张画布。
*/
type AutomaticPositionPolicy = 'rederive' | 'preserve';
@@ -532,6 +542,7 @@ export function useProjectResourceCanvasLayout({
scopeEpoch,
resourceSignature: signature,
conflictRetries,
rederive: false,
});
}
if (mountedRef.current) {
@@ -565,7 +576,9 @@ export function useProjectResourceCanvasLayout({
const writePolicy =
intent.kind === 'manual'
? MANUAL_WRITE_AUTOMATIC_POSITION_POLICY
: automaticPositionPolicy(rederiveAutomaticPositions);
: intent.rederive
? 'rederive'
: automaticPositionPolicy(rederiveAutomaticPositions);
const reconciled = reconcileLayout(
persistedLayoutRef.current,
resourcesRef.current,
@@ -686,6 +699,9 @@ export function useProjectResourceCanvasLayout({
if (intent.kind === 'manual') {
redragRequiredScopeEpochRef.current = null;
setNotice('布局已保存');
} else if (intent.rederive) {
// 显式整理复用同一条提示:用户按了按钮,就必须看到"这次重算真的落盘了"。
setNotice('布局已保存');
}
if (needsResourceSync) {
enqueueResourceSyncRef.current(currentScope.epoch);
@@ -1004,6 +1020,48 @@ export function useProjectResourceCanvasLayout({
[applyLayout, initializationReady, scopeKey],
);
/**
* 显式「整理画布」:丢掉全部自动坐标、按当前资源与拓扑重算一次;坐标确有变化时写回
* sidecar(沿用既有 `changed` 门),画布本身先乐观按重算结果显示。
*
* 这是整张画布重排的唯一入口——画布不再因为资源协调签名变化自动重派生。走的是与自动
* 同步同一条写队列与写回链路,只把策略换成 `rederive`;用户可见反馈仍由既有的
* `notice` / `saving` 状态位承担,不另造一套状态。
*/
const rederiveNow = useCallback(() => {
const scope = scopeRef.current;
if (
scope.key !== scopeKey ||
!initializationReady ||
initializedScopeEpochRef.current !== scope.epoch
) {
return;
}
const queued = writeQueueRef.current.find(
(intent): intent is ResourceLayoutWriteIntent =>
intent.kind === 'resources' &&
intent.scopeEpoch === scope.epoch &&
intent.rederive &&
intent !== activeWriteIntentRef.current,
);
if (queued) {
queued.resourceSignature = resourceSignatureRef.current;
queued.conflictRetries = 0;
} else {
writeQueueRef.current.push({
kind: 'resources',
scopeEpoch: scope.epoch,
resourceSignature: resourceSignatureRef.current,
conflictRetries: 0,
rederive: true,
});
}
// 乐观视图:立刻把重算结果显示出来,别让用户以为按钮没反应。
rebuildOptimisticLayout(scope.epoch, 'rederive');
setSaving(true);
pumpWritesRef.current();
}, [initializationReady, rebuildOptimisticLayout, scopeKey]);
const scopeMatches =
initializationReady &&
scopeRef.current.key === scopeKey &&
@@ -1035,5 +1093,6 @@ export function useProjectResourceCanvasLayout({
readReport,
scopeIdentity: scopeKey,
commitPosition,
rederiveNow,
};
}
File diff suppressed because it is too large Load Diff
@@ -14,6 +14,15 @@
- 影响范围:`.gitea/workflows/project-ci.yml`(七个 job)、`scripts/check-native-shells.mjs`(分组由五个变六个:新增 `agc-rust-crates``agc-rust-shell`,移除 `agc-rust`)、根 `package.json``scripts/project-ci-workflow.test.ts`(新增纯 cargo job 免 `npm ci` 与分片运行器断言)、开发运维文档与共享记忆。Gitea `master` 分支保护的 required context 是追加式的,需补上 `Project CI / AI game creator shell Rust crates (pull_request)`
- 验证方式:`npx vitest run scripts/project-ci-workflow.test.ts`;分片运行器本地以 `agent-runtime-core`7 条)与 `platform-llm`146 条)验证分片、`--exact` 与片 TMPDIR 隔离;`node scripts/check-native-shells.mjs --groups=contract` 回归。预期 `AI game creator shell Rust tests` 收敛到 6 分钟左右(前置 1m30s + 编译 1m39s + 分片约 2 分钟 + smoke),整轮 wall clock 由 `Backend tests`8 分 36 秒)与 runner 并发(4)决定。
- 关联文档:[开发运维](../../【开发运维】本地开发验证与生产运维-2026-05-15.md)、[踩坑记录](pitfalls.md)。
## 2026-09-14 AGC 资源画布改为「手动整理」:新素材不再自动重排,整张重排只由「整理画布」发起
- 背景:生成一张新素材会让整张资源画布重排。两个 layout hook 都把 `rederiveAutomaticPositions` 打开(type 侧无条件 `true`dependency 侧长期等于 `resourceGraphReady`),而该开关的语义是「每次资源协调签名变化就丢掉全部 `manuallyPlaced=false` 坐标、按当前资源与拓扑整体重算」;新增一张素材必然改签名,于是既有自动卡全部跟着挪位,用户刚记住的位置就没了。画布上也没有任何显式整理入口(`复位资源视图` 只复位视口)。
- 决策一(默认口径):两个 mode 的 `rederiveAutomaticPositions` 固定 `false`——画布默认只补新卡,不动任何既有坐标(`preserve`)。整张重排改为显式动作:依赖/类型 tab 组新增「整理画布」按钮,调用 hook 新暴露的 `rederiveNow()`,复用既有写队列与 sidecar 写回链路,只把策略换成 `rederive`;用户可见反馈继续用既有 `resourceLayoutNotice`(成功即「布局已保存」)与 `resourceLayoutSaving`,不新增状态位。重算结果与当前坐标一致时**不落盘**(沿用既有 `changed` 门):已经整齐的画布按一下不该白推进一次 CAS / revision,关系图 `producerMappingTruncated` 时更不该把一份来自不完整关系图的自动布局写进 sidecar——既有用例 `keeps trusted truncated-graph depths through the workbench without persisting a flat automatic layout` 就是钉这条。
- 决策二(依赖图首次就绪的那一次):`dependencyDepth` 仍要在关系图就绪后按最终拓扑排一次列。这一层不再靠「让布尔长期为真」,而是按**项目作用域的一次性 flag**(`dependencyRederiveScopeRef`):关系图就绪且该侧 sidecar `ready` 的那一刻调用一次 `rederiveNow()`,之后一律 `preserve`;切排序 tab 不重新武装,切项目才重新记一次。
- 决策三(生成后自动聚焦):新增 `seenManifestAssetIdsRef` + effect,按 `manifest.assets` 的**新增 id**(不是 diff 位置、也不是文件名)把新卡交给既有 `pendingResourceFocusRef` + `advanceFocusGeneration()` 裁决链。首次打开项目 / 切项目只登记基线、不聚焦;重命名不改 id、天然不触发;已有指向同一资源的聚焦意图时不重复挂(显式生成链路在提交时就已挂好)。被搜索条件挡住时继续复用既有的「清除搜索并定位」提示与动作。
- 影响范围:`apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts`(写意图增 `rederive` 标记、写策略分支、`rederiveNow`)、`.../index.tsx`(两个 hook 配置 + 一次性重派生 effect + 新素材聚焦 effect + 「整理画布」按钮)、`apps/ai-game-creator-shell/tests/resourceCanvasManualLayout.test.tsx`。不改 `manuallyPlaced` 语义、不改 sidecar 的 CAS / revision 协议与字段形状、不改卡片尺寸模型、不改「搜索不重排」合同、不改 Rust 资源图与 `dependencyDepth` 权威。
- 验证方式:新增 `resourceCanvasManualLayout.test.tsx` 8 条:新素材入库后除新卡外坐标逐值不变、依赖侧首次就绪重算一次后同样不再重排、新素材自动聚焦、被搜索挡住走既有提示与动作、「整理画布」按 `rederive` 重算并保留手动坐标且给出一次可见反馈、已经整齐时再按一次不产生第二次落盘、首次打开与切项目都不聚焦。变异验证(均已实测):① type 侧改回 `true`、dependency 侧改回 `resourceGraphReady` → 3 条红;② 去掉新素材基线的首次登记 → 3 条红(既有卡被当成"刚生成"选中);③ 去掉 hook 里的 `rederive` 写策略分支 → 3 条红;④ 把显式整理改成强制写回(去掉 `changed` 门)→ 既有「截断关系图」用例红。
- 关联文档:[踩坑记录](pitfalls.md)、[项目开发工作台 PRD](../../prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md)。
## 2026-09-14 客户端 CI 按门禁组拆成三个 jobAGC 的 web / rust 两段并行
@@ -16,6 +16,14 @@
- **处理**`runNativeShellGate` 收窄为 `(group, gate)`,label 回到各门禁执行体里以字面量打印;分组能力与 `--groups=` 语义不变。脚本内已注明"不要把 label 抽成变量",外部壳的 `check-config` 是它的消费者。
- **验证**`node apps/mobile-shell/scripts/check-config.mjs` exit 0`node apps/desktop-shell/scripts/check-config.mjs` 已越过第 740–777 行的根脚本断言段(本机随后卡在本机不存在的 Tauri 生成产物目录,与本次改动无关);`--groups=contract`、vitest `scripts/project-ci-workflow.test.ts`、eslint 均通过。修复后 run 2097 六个 job 全绿。
- **关联**`scripts/check-native-shells.mjs``runNativeShellGate`)、`apps/desktop-shell/scripts/check-config.mjs``apps/mobile-shell/scripts/check-config.mjs`
## 2026-09-14 AGC 资源画布不要恢复「无条件重派生」,否则新增一张素材就整张重排
- **现象**:用户生成一张新素材后,画布上既有卡片全部移位,刚摆好的位置失效。
- **原因**`useProjectResourceCanvasLayout``rederiveAutomaticPositions=true` 会让**每一次资源协调签名变化**(新增 / 删除素材、改标签、改分类、拓扑签名变化)都丢掉全部 `manuallyPlaced=false` 坐标整体重算。签名里必然包含新素材,所以「新增一张素材」就等于「整张画布重排」;两个 hook 当时都开着它(type 侧无条件 `true`dependency 侧长期等于 `resourceGraphReady`)。
- **处理(现行口径)**:默认一律 `preserve`(只补新卡)。整张重排只由「整理画布」按钮调用 hook 的 `rederiveNow()` 发起,或由「关系图首次就绪」那一次按项目作用域的一次性 flag 发起。**不要**把 `rederiveAutomaticPositions` 改回长期 `true` / `resourceGraphReady`,也不要为「拓扑变了要立刻重排」再加自动触发点——那正是本条要修掉的行为。
- **易错点**:① 一致性判据是 `reconcileResourceCanvasLayout` 输出里**每个分区按 `(y, x, resourceId)` 排序**后的数组与来源逐项比较,所以手工构造 sidecar 夹具时要按同序写,否则会被判成「变了」而多写一次,用例里会看到意料之外的写回;② 关系图首次就绪那一次重算要等该侧 sidecar `ready` 之后再发(关系图可能先就绪),否则这一次会被吃掉;③ 依赖侧的一次性 flag 按**项目作用域**记账,不要挂到 `resourceGraphReady` 这类会随排序 tab 反复翻转的值上,否则每次切回依赖视图都会重排一次;④ 新素材聚焦按 `manifest.assets` 的新增 id 判定,首次打开 / 切项目必须先登记基线,否则一进工作台就跳到最后的卡上;⑤ 不要为了让「整理画布」按钮"一定有反馈"而把 `changed` 门拿掉——重算结果与当前坐标一致时不写盘是既有合同,关系图 `producerMappingTruncated` 时强行写回等于把一份来自不完整关系图的自动布局持久化(`appSurface.test.ts``keeps trusted truncated-graph depths through the workbench without persisting a flat automatic layout` 会红)。
- **验证**`npx vitest run apps/ai-game-creator-shell/tests/resourceCanvasManualLayout.test.tsx`(8 条);把两个 hook 配置改回旧口径会红 3 条。
- **关联**`apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts``apps/ai-game-creator-shell/src/view/project-development/index.tsx`
## 2026-09-14 客户端 CI 拆分后,选组运行会跳过未选分组,且必须同步分支保护