完美像素请求账本移出项目布局
Project CI / Repository checks (pull_request) Failing after 11s
Project CI / Backend tests (pull_request) Failing after 10s
Project CI / Frontend tests (pull_request) Failing after 20s
Project CI / Native shell tests (pull_request) Failing after 8m36s

账本记的是「本机发出过哪一次 POST」,属于对账凭据而非画布内容。它此前写在
用户的画布布局里,为此派生出一条严格布局保存通道:发 POST 前必须拿到布局
保存的 revision ack。任何布局校验失败因此都会升级成完美像素的硬阻断。

改为存在本机 localStorage,按 owner + project 双键隔离;布局里只留
perfectPixelOperationId 标记,用来把这类占位与队列型占位区分开。本机写入是
同步的、不过网络、不受服务端校验影响,因此它能提供严格保存想提供的那个保证
——请求可被追溯——却不引入阻断点。严格保存通道整体删除,只保留一个不改变
失败语义的 preferLatestGenerationDialogs。

由此新出现的「账本有、占位没写进布局」窗口,由恢复 effect 覆盖:它同时遍历
内存占位与孤儿账本条目,对后者照常 GET 对账,终态给出 asset-only 提示并清
账本。

本机账本是明确设计,缺失只降级、不得构成阻断:换设备、清缓存、隐私模式、
配额写满都会读不到账本,此时带标记的占位一律收口成可删除的失败占位,用户
删掉重来即可。跨设备不再自动收口是已知且接受的代价。

布局内联账本作为 legacy 形状继续被读取,滚动部署期间的在途操作不会被一次性
判死;写入侧不再产生新的内联账本。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-05 05:55:06 +00:00
parent 06a6c33ea6
commit 2fa2f245c1
11 changed files with 910 additions and 987 deletions
@@ -6381,3 +6381,14 @@
- 影响范围:`src/components/image-editor/useCanvasGenerationDialogs.ts`(删除 `isUnsettledPerfectPixelOperationDialog`,新增 `requiresGenerationDeleteConfirmation`)、`ImageCanvasEditorView.tsx``requestRemoveCanvasGenerationDialog``useImageCanvasLayerCommands.ts``deleteSelectedLayer``useImageCanvasGenerationWorkflow.ts` 的恢复失效分支。不修改服务端、契约或数据。
- 验证方式:覆盖三种状态下按 id 删除与随源图层删除均真正移除、完美像素占位任何状态都不要求确认而普通 `generating` 占位仍要求、快捷键与混合选择删除会写入历史并触发副作用、在途删除后已知失败退回全局提示、删除后 applied verdict 走 asset-only 提示且不回填画布、标记失效时快照被丢弃。运行 `npx vitest run src/components/image-editor``npx vitest run src/components/platform-entry``npm run typecheck``npm run check:encoding`
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`
## 2026-08-05 完美像素请求账本移出项目布局,严格布局保存整体删除
- 背景:完美像素是唯一没有 durable job 的生成路径——免费、同步、不走 `enqueue_editor_generation_job`,服务端没有任何一行记录「这次请求发出过」。为了让刷新后还能 GET-only 对账,请求账本 `perfectPixelOperation` 被写进了**用户的画布布局**,并由此派生出一条严格布局保存通道:发 POST 前必须拿到布局保存的 revision ack,否则整条链路中止。该耦合直接造成两类缺陷:一是账本寄生在用户数据上,占位一度被禁止删除(已由同日「完美像素占位恢复为可删除」作废);二是任何布局校验失败都会升级成完美像素的硬阻断,「画布图层元数据以资源行为准」那条缺陷正是因为严格保存才从静默重试变成用户可见的死锁。
- 决策:账本改由 `src/components/image-editor/perfectPixelOperationStore.ts` 存在本机 localStorage,按 owner + project 双键隔离;布局里只留 `perfectPixelOperationId` 标记,用来把这类占位与队列型占位区分开。发 POST 前先同步写本机账本,再**尽力而为**地保存布局;布局保存失败不再拦 POST,只是把结果降级为「只进素材库」。严格布局保存通道(`strictCompletion` 全套机制、`flushProjectPersistence``requireSuccess` / `requiredDialogId` / `deadlineAt` 选项、`PERFECT_PIXEL_STRICT_LAYOUT_SAVE_BUDGET_MS`)整体删除,只保留一个不改变失败语义的 `preferLatestGenerationDialogs`,用于取到刚创建、尚未回流到 ref 的占位。
- 为什么这样仍然安全:本机写入是同步的、不过网络、不受服务端校验影响,因此它能提供严格布局保存想提供的那个保证——「请求可被追溯」——却不引入任何阻断点。由此新出现的「账本有、占位没写进布局」窗口,由恢复 effect 覆盖:它同时遍历内存占位与孤儿账本条目,对后者照常 GET 对账,终态给出 asset-only 提示并清账本。
- **本机账本是明确设计,缺失只降级、不得构成阻断**:换设备、换浏览器、清缓存、隐私模式、配额写满都会读不到账本。这些情况下带标记的占位一律 hydrate 成 `failed + perfectPixelOperationInvalid` 的**可删除**占位,用户删掉重来即可。任何代码路径都不得因为「读不到账本」而拒绝用户发起、重试或删除,也不得把占位停在无从收口的处理中态。跨设备不再自动收口是本决策已知且接受的代价:完美像素免费且同步,重做成本极低,用它换掉「用户数据里混着系统对账状态」的耦合是划算的。后续复查发现该行为时,应对照本条确认为设计,不得判定为回归。
- 兼容与生命周期:布局内联账本是 legacy 形状,hydrate 侧继续认,滚动部署期间的在途操作不会被一次性判死;写入侧不再产生新的内联账本。本机账本按 7 天保留期与 32 条上限裁剪,终态(applied / dialog-missing / 快照与项目不匹配 / 无占位可挂错误)立即清除。读取沿用与布局快照相同的 v1 白名单校验,任何字段漂移失败关闭,绝不据一份可疑账本重放 POST。
- 影响范围:新增 `perfectPixelOperationStore.ts``ImageCanvasEditorTypes.ts` 新增 `perfectPixelOperationId``ImageCanvasEditorModel.ts``serializeDialogReferences` / `hydrateCanvasGenerationDialog` / `splitCanvasLayoutItems` / `dropDeadInlineGenerationPlaceholders``useImageCanvasProjectPersistence.ts` 删除严格保存机制并在 hydrate 时读账本;`useImageCanvasGenerationWorkflow.ts` 的提交、重试与恢复 effect`useImageCanvasGenerationSurface.tsx` 的 props 类型。不修改服务端、SpacetimeDB schema 或对外契约——服务端从来不认识这个字段。
- 验证方式:账本单测覆盖往返、owner / project 隔离、跨账号整条丢弃、被篡改条目失败关闭、保留期与条数裁剪、终态清除、以及存储不可用时静默降级;模型层覆盖「布局只留标记且不含源图地址」「标记在而账本缺失时收口为可删除失败态」「账本 id 与占位不符时失败关闭」;工作流覆盖「布局保存失败仍照发 POST 并保留可重试的 operation」与「孤儿账本条目照常对账并在终态清账本」;持久化层覆盖「布局保存 400 / 403 与缺 authority 时 flush 均不抛、下游照常执行」。运行 `npx vitest run src/components/image-editor``npm run typecheck``npm run lint:eslint``npm run check:encoding`
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`
@@ -34,10 +34,7 @@ import type {
EditorAsset,
PerfectPixelOperationSnapshot,
} from './ImageCanvasEditorTypes';
import {
PERFECT_PIXEL_SOURCE_PREPARATION_BUDGET_MS,
PERFECT_PIXEL_STRICT_LAYOUT_SAVE_BUDGET_MS,
} from './useImageCanvasGenerationWorkflow';
import { PERFECT_PIXEL_SOURCE_PREPARATION_BUDGET_MS } from './useImageCanvasGenerationWorkflow';
function buildPerfectPixelOperation(
dialogId: string,
@@ -990,7 +987,7 @@ describe('ImageCanvasEditorModel', () => {
});
});
it('strictly round-trips a pending perfect-pixel operation snapshot', () => {
it('keeps the operation ledger out of the layout and restores it from the local ledger', () => {
const dialogId = 'dialog-perfect-pixel-round-trip';
const operation = buildPerfectPixelOperation(dialogId);
const hydrated = hydrateCanvasGenerationDialog({
@@ -1006,15 +1003,25 @@ describe('ImageCanvasEditorModel', () => {
expect(hydrated).toMatchObject({
id: dialogId,
status: 'pending-confirmation',
perfectPixelOperationId: dialogId,
perfectPixelOperation: operation,
});
expect(hydrated).not.toHaveProperty('perfectPixelOperationInvalid');
const layout = serializeCanvasLayout({
layers: [],
canvasGenerationDialogs: [hydrated as CanvasGenerationDialogState],
});
const serializedLayout = JSON.stringify(layout);
expect(serializedLayout).toContain('"perfectPixelOperationId"');
expect(serializedLayout).not.toContain('"perfectPixelOperation"');
expect(serializedLayout).not.toContain(operation.request.sourceImageSrc);
const { generationDialogs } = splitCanvasLayoutItems(
serializeCanvasLayout({
layers: [],
canvasGenerationDialogs: [hydrated as CanvasGenerationDialogState],
}),
layout,
new Map(),
undefined,
new Map([[dialogId, operation]]),
);
expect(generationDialogs).toHaveLength(1);
@@ -1024,6 +1031,67 @@ describe('ImageCanvasEditorModel', () => {
);
});
it('settles a perfect-pixel placeholder as deletable failure when the local ledger is absent', () => {
const dialogId = 'dialog-perfect-pixel-other-device';
const operation = buildPerfectPixelOperation(dialogId);
const hydrated = hydrateCanvasGenerationDialog({
id: dialogId,
mode: 'quick-edit',
prompt: '完美像素',
status: 'generating',
composerOpen: false,
perfectPixelOperation: operation,
});
const layout = serializeCanvasLayout({
layers: [],
canvasGenerationDialogs: [hydrated as CanvasGenerationDialogState],
});
// 中文注释:换设备 / 清缓存 / 隐私模式——布局里标记还在,本机账本读不到。这是明确
// 设计:占位收口成可删除的失败态,绝不停在无从收口的处理中态。
const { generationDialogs } = splitCanvasLayoutItems(layout);
expect(generationDialogs).toHaveLength(1);
expect(generationDialogs[0]).toMatchObject({
id: dialogId,
status: 'failed',
perfectPixelOperationInvalid: true,
});
expect(generationDialogs[0]).not.toHaveProperty('perfectPixelOperation');
});
it('ignores a local ledger entry whose id does not match the placeholder', () => {
const dialogId = 'dialog-perfect-pixel-ledger-mismatch';
const operation = buildPerfectPixelOperation('dialog-perfect-pixel-other');
const { generationDialogs } = splitCanvasLayoutItems(
[
{
itemType: 'generation-dialog',
layerId: `generation-dialog:${dialogId}`,
resourceId: `generation-dialog:${dialogId}`,
dialog: {
id: dialogId,
mode: 'quick-edit',
prompt: '完美像素',
status: 'generating',
perfectPixelOperationId: dialogId,
},
} as unknown as EditorProjectLayerSnapshot,
],
new Map(),
undefined,
new Map([['dialog-perfect-pixel-other', operation]]),
);
expect(generationDialogs).toHaveLength(1);
expect(generationDialogs[0]).toMatchObject({
id: dialogId,
status: 'failed',
perfectPixelOperationInvalid: true,
});
expect(generationDialogs[0]).not.toHaveProperty('perfectPixelOperation');
});
it('preserves legacy 240-second operation identity while clamping its deadline on round-trip', () => {
vi.useFakeTimers();
const now = 1_700_000_010_000;
@@ -1061,6 +1129,9 @@ describe('ImageCanvasEditorModel', () => {
layers: [],
canvasGenerationDialogs: [hydrated as CanvasGenerationDialogState],
}),
new Map(),
undefined,
new Map([[dialogId, expectedOperation]]),
);
expect(generationDialogs).toHaveLength(1);
@@ -1172,6 +1243,9 @@ describe('ImageCanvasEditorModel', () => {
layers: [],
canvasGenerationDialogs: [hydrated as CanvasGenerationDialogState],
}),
new Map(),
undefined,
new Map([[dialogId, expectedOperation]]),
);
expect(generationDialogs).toHaveLength(1);
@@ -1466,20 +1540,15 @@ describe('ImageCanvasEditorModel', () => {
});
});
it('keeps the legacy placeholder window above source preparation and strict journaling', () => {
// operation 首次 strict journal 成功后会退出 legacy requiresLiveSession 清理;窗口只需
// 覆盖源准备与该次 journalPOST 对账由 durable operation 自己保护。
const durableOperationJournalUpperBoundMs =
PERFECT_PIXEL_SOURCE_PREPARATION_BUDGET_MS +
PERFECT_PIXEL_STRICT_LAYOUT_SAVE_BUDGET_MS;
expect(durableOperationJournalUpperBoundMs).toBe(150_000);
it('keeps the legacy placeholder window above source preparation', () => {
// 中文注释:占位一旦挂上 perfectPixelOperationId 就退出 legacy requiresLiveSession 清理
// 所以这个窗口只需覆盖标记写入之前的那一段——源图解析/直传。POST 对账由账本自己保护。
expect(INLINE_GENERATION_PLACEHOLDER_LIVE_WINDOW_MS).toBeGreaterThan(
durableOperationJournalUpperBoundMs,
PERFECT_PIXEL_SOURCE_PREPARATION_BUDGET_MS,
);
expect(
INLINE_GENERATION_PLACEHOLDER_LIVE_WINDOW_MS -
durableOperationJournalUpperBoundMs,
PERFECT_PIXEL_SOURCE_PREPARATION_BUDGET_MS,
).toBeGreaterThanOrEqual(90_000);
});
@@ -724,11 +724,22 @@ function serializeGenerationReferences(
});
}
/**
* 中文注释:布局里只写 `perfectPixelOperationId` 标记,不写请求账本本身。
*
* 账本记的是「本机发出过哪一次 POST」,属于对账凭据而非画布内容,改由
* `perfectPixelOperationStore` 存在本机(见那里的长注释)。布局仍需要一个标记,否则
* 换设备打开时无法把这类占位与队列型占位区分开,只能当成普通占位一直转下去。
*/
function serializeDialogReferences(
dialog: CanvasGenerationDialogState,
): CanvasGenerationDialogState {
const { perfectPixelOperation, ...persistedDialog } = dialog;
const perfectPixelOperationId =
dialog.perfectPixelOperationId ?? perfectPixelOperation?.operationId;
return {
...dialog,
...persistedDialog,
...(perfectPixelOperationId ? { perfectPixelOperationId } : {}),
specReference: serializeGenerationReference(dialog.specReference),
generationReferences: serializeGenerationReferences(
dialog.generationReferences,
@@ -999,8 +1010,14 @@ export function dropDeadInlineGenerationPlaceholders(
const operationBackedDialogKeys = new Set(
mirroredLayers.flatMap((item) => {
const dialog = canvasGenerationDialogRecord(item);
// 中文注释:两种形状都算 operation-backed——`perfectPixelOperationId` 是账本移出
// 布局后的新标记,`perfectPixelOperation` 是内联账本的 legacy 形状。
return dialog &&
Object.prototype.hasOwnProperty.call(dialog, 'perfectPixelOperation')
(Object.prototype.hasOwnProperty.call(
dialog,
'perfectPixelOperationId',
) ||
Object.prototype.hasOwnProperty.call(dialog, 'perfectPixelOperation'))
? [canvasGenerationDialogMirrorKey(item)]
: [];
}),
@@ -1045,6 +1062,10 @@ export function splitCanvasLayoutItems(
items: EditorProjectLayerSnapshot[],
resourcesById: Map<string, CanvasLayerResourceMetadata> = new Map(),
currentUserId?: string | null,
localPerfectPixelOperations?: ReadonlyMap<
string,
PerfectPixelOperationSnapshot
>,
): {
layerItems: EditorProjectLayerSnapshot[];
generationDialogs: CanvasGenerationDialogState[];
@@ -1070,6 +1091,7 @@ export function splitCanvasLayoutItems(
item.dialog,
resourcesById,
currentUserId,
localPerfectPixelOperations,
);
if (dialog) {
generationDialogs.push(dialog);
@@ -1086,6 +1108,10 @@ export function hydrateCanvasGenerationDialog(
value: unknown,
resourcesById: Map<string, CanvasLayerResourceMetadata> = new Map(),
currentUserId?: string | null,
localPerfectPixelOperations?: ReadonlyMap<
string,
PerfectPixelOperationSnapshot
>,
): CanvasGenerationDialogState | null {
if (!value || typeof value !== 'object') {
return null;
@@ -1096,20 +1122,36 @@ export function hydrateCanvasGenerationDialog(
if (!id || !isCanvasGenerationDialogMode(snapshot.mode)) {
return null;
}
const hasPerfectPixelOperation = Object.prototype.hasOwnProperty.call(
snapshot,
'perfectPixelOperation',
);
const perfectPixelOperation = hasPerfectPixelOperation
// 中文注释:布局内联快照是 legacy 形状——账本改存本机之前写下的占位仍在库里,
// 必须继续认,否则滚动部署会把所有在途操作一次性判死。新写入只有 id 标记。
const hasLegacyInlinePerfectPixelOperation =
Object.prototype.hasOwnProperty.call(snapshot, 'perfectPixelOperation');
const legacyInlinePerfectPixelOperation = hasLegacyInlinePerfectPixelOperation
? hydratePerfectPixelOperation(snapshot.perfectPixelOperation, id)
: undefined;
: null;
const declaredPerfectPixelOperationId = Object.prototype.hasOwnProperty.call(
snapshot,
'perfectPixelOperationId',
);
const isPerfectPixelPlaceholder =
hasLegacyInlinePerfectPixelOperation || declaredPerfectPixelOperationId;
const perfectPixelOperation =
legacyInlinePerfectPixelOperation ??
// 中文注释:只认 id 与占位一致的账本。id 漂移一律当账本不可用失败关闭,绝不按
// 当前画布状态猜一条请求出来重放。
(declaredPerfectPixelOperationId &&
stringOrNull(snapshot.perfectPixelOperationId) === id
? (localPerfectPixelOperations?.get(id) ?? null)
: null);
const hasPersistedInvalidPerfectPixelOperationMarker =
Object.prototype.hasOwnProperty.call(
snapshot,
'perfectPixelOperationInvalid',
);
// 中文注释:标记在、账本不在,正是「换设备 / 清缓存 / 隐私模式」这条明确设计的路径。
// 收口为可删除的失败占位即可,不得阻断用户删除或从源图重做。
const hasInvalidPerfectPixelOperation =
(hasPerfectPixelOperation && !perfectPixelOperation) ||
(isPerfectPixelPlaceholder && !perfectPixelOperation) ||
hasPersistedInvalidPerfectPixelOperationMarker ||
(snapshot.status === 'pending-confirmation' && !perfectPixelOperation);
const trustedPerfectPixelOperation = hasInvalidPerfectPixelOperation
@@ -1137,6 +1179,7 @@ export function hydrateCanvasGenerationDialog(
// 不会被 dropDeadInlineGenerationPlaceholders 误清。
requiresLiveSession:
snapshot.requiresLiveSession === true ? true : undefined,
...(isPerfectPixelPlaceholder ? { perfectPixelOperationId: id } : {}),
...(trustedPerfectPixelOperation
? { perfectPixelOperation: trustedPerfectPixelOperation }
: {}),
@@ -257,6 +257,10 @@ export type GenerateDialogState = {
// 图片生成默认队列)不得置位:它们的 job 在服务端继续跑,worker 会替换占位,刷新后
// 必须原样恢复。
requiresLiveSession?: boolean;
// 中文注释:请求账本只存在于本机(见 perfectPixelOperationStore),布局里只留这个 id
// 标记「该占位是一次完美像素操作」。换设备打开时标记还在、账本读不到,占位收口为可删除
// 的失败态——这是明确设计,不得据此阻断用户删除或重做。
perfectPixelOperationId?: string;
perfectPixelOperation?: PerfectPixelOperationSnapshot;
perfectPixelOperationInvalid?: boolean;
generationStartedAt?: number;
@@ -0,0 +1,228 @@
/**
* @vitest-environment jsdom
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { PerfectPixelOperationSnapshot } from './ImageCanvasEditorTypes';
import {
forgetPerfectPixelOperation,
PERFECT_PIXEL_OPERATION_RETENTION_LIMIT,
PERFECT_PIXEL_OPERATION_RETENTION_MS,
readPerfectPixelOperations,
savePerfectPixelOperation,
} from './perfectPixelOperationStore';
const OWNER_USER_ID = 'user-a';
const PROJECT_ID = 'project-1';
const NOW = 1_785_715_270_000;
function buildOperation(
dialogId: string,
overrides: Partial<PerfectPixelOperationSnapshot> = {},
): PerfectPixelOperationSnapshot {
return {
version: 1,
kind: 'perfect-pixel',
operationId: dialogId,
taskId: `pixel-art-snap-${dialogId}`,
request: {
sourceImageSrc: 'ref:project-resource:resource-source',
projectId: PROJECT_ID,
sourceResourceId: 'resource-source',
assetKind: 'character',
assetLabel: '角色 · 完美像素',
canvasCompletion: {
dialogId,
title: '角色 · 完美像素',
placeholder: {
x: 100,
y: 120,
width: 320,
height: 320,
originalWidth: 640,
originalHeight: 640,
},
},
},
submittedAt: NOW,
reconcileUntil: NOW + 75_000,
...overrides,
};
}
describe('perfectPixelOperationStore', () => {
beforeEach(() => {
window.localStorage.clear();
vi.useFakeTimers();
vi.setSystemTime(NOW);
});
afterEach(() => {
vi.useRealTimers();
});
it('round-trips an operation for the same owner and project', () => {
const operation = buildOperation('dialog-1');
savePerfectPixelOperation(OWNER_USER_ID, PROJECT_ID, operation);
const ledger = readPerfectPixelOperations(OWNER_USER_ID, PROJECT_ID);
expect(ledger.get('dialog-1')).toEqual(operation);
});
it('keeps ledgers separated by project and drops another owner entirely', () => {
savePerfectPixelOperation(
OWNER_USER_ID,
PROJECT_ID,
buildOperation('dialog-1'),
);
expect(readPerfectPixelOperations(OWNER_USER_ID, 'project-2').size).toBe(0);
// 中文注释:同一台机器换账号后,上一个账号的请求(含源图直传地址)必须整条丢弃。
expect(readPerfectPixelOperations('user-b', PROJECT_ID).size).toBe(0);
expect(readPerfectPixelOperations(OWNER_USER_ID, PROJECT_ID).size).toBe(0);
});
it('refuses to store an operation whose request targets another project', () => {
savePerfectPixelOperation(
OWNER_USER_ID,
'project-2',
buildOperation('dialog-1'),
);
expect(readPerfectPixelOperations(OWNER_USER_ID, 'project-2').size).toBe(0);
});
it('fails closed on a tampered ledger entry instead of replaying it', () => {
savePerfectPixelOperation(
OWNER_USER_ID,
PROJECT_ID,
buildOperation('dialog-1'),
);
savePerfectPixelOperation(
OWNER_USER_ID,
PROJECT_ID,
buildOperation('dialog-2'),
);
const key = `genarrative.imageCanvas.perfectPixelOperations.${PROJECT_ID}`;
const stored = JSON.parse(window.localStorage.getItem(key)!) as {
ownerUserId: string;
operations: Record<string, PerfectPixelOperationSnapshot>;
};
stored.operations['dialog-1']!.taskId = 'pixel-art-snap-somewhere-else';
window.localStorage.setItem(key, JSON.stringify(stored));
const ledger = readPerfectPixelOperations(OWNER_USER_ID, PROJECT_ID);
expect(ledger.has('dialog-1')).toBe(false);
expect(ledger.has('dialog-2')).toBe(true);
});
it('drops entries past the retention window', () => {
savePerfectPixelOperation(
OWNER_USER_ID,
PROJECT_ID,
buildOperation('dialog-old', {
submittedAt: NOW - PERFECT_PIXEL_OPERATION_RETENTION_MS - 1,
reconcileUntil: NOW - PERFECT_PIXEL_OPERATION_RETENTION_MS + 74_999,
}),
);
savePerfectPixelOperation(
OWNER_USER_ID,
PROJECT_ID,
buildOperation('dialog-fresh'),
);
const ledger = readPerfectPixelOperations(OWNER_USER_ID, PROJECT_ID);
expect([...ledger.keys()]).toEqual(['dialog-fresh']);
});
it('caps the ledger size by keeping the newest submissions', () => {
for (
let index = 0;
index <= PERFECT_PIXEL_OPERATION_RETENTION_LIMIT;
index += 1
) {
savePerfectPixelOperation(
OWNER_USER_ID,
PROJECT_ID,
buildOperation(`dialog-${index}`, {
submittedAt: NOW - (PERFECT_PIXEL_OPERATION_RETENTION_LIMIT - index),
reconcileUntil:
NOW - (PERFECT_PIXEL_OPERATION_RETENTION_LIMIT - index) + 75_000,
}),
);
}
const ledger = readPerfectPixelOperations(OWNER_USER_ID, PROJECT_ID);
expect(ledger.size).toBe(PERFECT_PIXEL_OPERATION_RETENTION_LIMIT);
expect(ledger.has('dialog-0')).toBe(false);
expect(
ledger.has(`dialog-${PERFECT_PIXEL_OPERATION_RETENTION_LIMIT}`),
).toBe(true);
});
it('forgets a settled operation and clears the key once empty', () => {
savePerfectPixelOperation(
OWNER_USER_ID,
PROJECT_ID,
buildOperation('dialog-1'),
);
forgetPerfectPixelOperation(OWNER_USER_ID, PROJECT_ID, 'dialog-1');
expect(readPerfectPixelOperations(OWNER_USER_ID, PROJECT_ID).size).toBe(0);
expect(
window.localStorage.getItem(
`genarrative.imageCanvas.perfectPixelOperations.${PROJECT_ID}`,
),
).toBeNull();
});
it('degrades to an empty ledger without throwing when storage is unavailable', () => {
const setItem = vi
.spyOn(Storage.prototype, 'setItem')
.mockImplementation(() => {
throw new Error('QuotaExceededError');
});
const getItem = vi
.spyOn(Storage.prototype, 'getItem')
.mockImplementation(() => {
throw new Error('SecurityError');
});
try {
// 中文注释:隐私模式 / 配额写满时账本读写都会抛。这里必须静默降级——账本缺失只
// 意味着刷新后不能自动收口,绝不能反过来阻断发起、重试或删除。
expect(() =>
savePerfectPixelOperation(
OWNER_USER_ID,
PROJECT_ID,
buildOperation('dialog-1'),
),
).not.toThrow();
expect(() =>
forgetPerfectPixelOperation(OWNER_USER_ID, PROJECT_ID, 'dialog-1'),
).not.toThrow();
expect(readPerfectPixelOperations(OWNER_USER_ID, PROJECT_ID).size).toBe(
0,
);
} finally {
setItem.mockRestore();
getItem.mockRestore();
}
});
it('returns an empty ledger without an owner or project', () => {
savePerfectPixelOperation(
OWNER_USER_ID,
PROJECT_ID,
buildOperation('dialog-1'),
);
expect(readPerfectPixelOperations(null, PROJECT_ID).size).toBe(0);
expect(readPerfectPixelOperations(OWNER_USER_ID, ' ').size).toBe(0);
});
});
@@ -0,0 +1,203 @@
import { hydratePerfectPixelOperation } from './ImageCanvasEditorModel';
import type { PerfectPixelOperationSnapshot } from './ImageCanvasEditorTypes';
/**
* 中文注释:完美像素操作账本的本机存储。
*
* **这是明确设计,不是降级方案**:账本记录的是「本机这次会话发出过哪一次 POST」,
* 它是对账凭据,不是用户的画布内容,因此不进项目布局。由此得到两条硬性质:
*
* 1. **写入同步、不依赖网络、不依赖服务端校验。** 发 POST 前先落本机账本即可获得
* 「请求可被追溯」的保证,不必再用严格布局保存去换同一个保证。布局校验(例如
* 资源元数据读写不对称)从此不可能阻断完美像素的发起或重试。
* 2. **账本缺失只降级、绝不阻断。** 换设备、换浏览器、清缓存、隐私模式、配额写满,
* 都会读不到账本。那种情况下占位收口为可删除的失败态,用户可以删掉重来;
* 任何路径都不得因为「读不到账本」而拒绝用户发起、重试或删除。
*
* 代价是跨设备不再自动收口:在 A 机发起、到 B 机打开同一项目时,B 机看到的是失败占位
* 而不是对账中的占位。完美像素是免费同步操作,重做成本极低,用这点换掉「用户数据里
* 混着系统对账状态」的耦合是划算的。
*/
const PERFECT_PIXEL_OPERATION_STORAGE_KEY_PREFIX =
'genarrative.imageCanvas.perfectPixelOperations';
/**
* 中文注释:账本保留期。对账窗口只有 75 秒,但 `pending-confirmation` 占位允许用户在很久
* 之后手动重试同一次 operation,那条路径同样需要账本,所以保留期必须远长于对账窗口。
*/
export const PERFECT_PIXEL_OPERATION_RETENTION_MS = 7 * 24 * 60 * 60 * 1_000;
/**
* 中文注释:单个项目最多保留的账本条数,超出时丢弃最旧的。防止长期使用把 localStorage
* 配额吃满——配额写满会连带影响同域下其它本地缓存,而账本本身是可丢弃的。
*/
export const PERFECT_PIXEL_OPERATION_RETENTION_LIMIT = 32;
type PerfectPixelOperationLedger = Map<string, PerfectPixelOperationSnapshot>;
function getPerfectPixelOperationStorage() {
if (typeof window === 'undefined') {
return null;
}
try {
return window.localStorage;
} catch {
return null;
}
}
function perfectPixelOperationStorageKey(projectId: string | null | undefined) {
const normalizedProjectId = projectId?.trim();
if (!normalizedProjectId) {
return null;
}
return `${PERFECT_PIXEL_OPERATION_STORAGE_KEY_PREFIX}.${normalizedProjectId}`;
}
/**
* 中文注释:读账本时同时校验归属。同一台机器可能先后登录不同账号,账本里带着上一个
* 账号的请求(含源图直传地址),换人后必须整条丢弃而不是原样返回。
*/
function readLedgerEntries(
storage: Storage,
key: string,
ownerUserId: string,
): PerfectPixelOperationLedger {
const ledger: PerfectPixelOperationLedger = new Map();
const rawValue = storage.getItem(key);
if (!rawValue) {
return ledger;
}
const parsedValue: unknown = JSON.parse(rawValue);
if (!parsedValue || typeof parsedValue !== 'object') {
return ledger;
}
const record = parsedValue as Record<string, unknown>;
const recordOwnerUserId =
typeof record.ownerUserId === 'string' ? record.ownerUserId.trim() : '';
if (recordOwnerUserId !== ownerUserId) {
storage.removeItem(key);
return ledger;
}
const operations = record.operations;
if (!operations || typeof operations !== 'object') {
return ledger;
}
const now = Date.now();
for (const [operationId, value] of Object.entries(
operations as Record<string, unknown>,
)) {
// 中文注释:本机账本与布局快照走同一套 v1 白名单校验。存储可被用户或其它脚本改写,
// 任何字段漂移都必须失败关闭——绝不能据一份可疑账本重放 POST。
const operation = hydratePerfectPixelOperation(value, operationId);
if (!operation) {
continue;
}
if (now - operation.submittedAt > PERFECT_PIXEL_OPERATION_RETENTION_MS) {
continue;
}
ledger.set(operationId, operation);
}
return ledger;
}
function writeLedgerEntries(
storage: Storage,
key: string,
ownerUserId: string,
ledger: PerfectPixelOperationLedger,
) {
if (ledger.size === 0) {
storage.removeItem(key);
return;
}
const retained = [...ledger.values()]
.sort((left, right) => right.submittedAt - left.submittedAt)
.slice(0, PERFECT_PIXEL_OPERATION_RETENTION_LIMIT);
storage.setItem(
key,
JSON.stringify({
ownerUserId,
operations: Object.fromEntries(
retained.map((operation) => [operation.operationId, operation]),
),
}),
);
}
/**
* 中文注释:读取某项目在本机的全部有效账本。任何异常都返回空账本——读不到账本只意味着
* 「刷新后不能自动收口」,调用方必须能在空账本下继续工作。
*/
export function readPerfectPixelOperations(
currentUserId: string | null | undefined,
projectId: string | null | undefined,
): PerfectPixelOperationLedger {
const ownerUserId = currentUserId?.trim();
const key = perfectPixelOperationStorageKey(projectId);
const storage = getPerfectPixelOperationStorage();
if (!ownerUserId || !key || !storage) {
return new Map();
}
try {
return readLedgerEntries(storage, key, ownerUserId);
} catch {
return new Map();
}
}
/**
* 中文注释:写入一条账本。必须在发 POST 之前调用——这是整条链路里唯一「请求已发出」的
* 本地证据。写入失败(配额、隐私模式)同样不阻断:调用方照常发 POST,只是丢掉刷新后
* 自动收口的能力。
*/
export function savePerfectPixelOperation(
currentUserId: string | null | undefined,
projectId: string | null | undefined,
operation: PerfectPixelOperationSnapshot,
) {
const ownerUserId = currentUserId?.trim();
const key = perfectPixelOperationStorageKey(projectId);
const storage = getPerfectPixelOperationStorage();
if (!ownerUserId || !key || !storage) {
return;
}
if (operation.request.projectId !== projectId?.trim()) {
return;
}
try {
const ledger = readLedgerEntries(storage, key, ownerUserId);
ledger.set(operation.operationId, operation);
writeLedgerEntries(storage, key, ownerUserId, ledger);
} catch {
// 中文注释:账本是尽力而为的本机便利,写失败不得影响本次提交。
}
}
/**
* 中文注释:操作收口(结果已套用 / 只落素材库 / 快照判定失效)后清账本,避免过期条目
* 在下次加载时再发一次无谓的对账 GET。
*/
export function forgetPerfectPixelOperation(
currentUserId: string | null | undefined,
projectId: string | null | undefined,
operationId: string,
) {
const ownerUserId = currentUserId?.trim();
const key = perfectPixelOperationStorageKey(projectId);
const storage = getPerfectPixelOperationStorage();
const normalizedOperationId = operationId.trim();
if (!ownerUserId || !key || !storage || !normalizedOperationId) {
return;
}
try {
const ledger = readLedgerEntries(storage, key, ownerUserId);
if (!ledger.delete(normalizedOperationId)) {
return;
}
writeLedgerEntries(storage, key, ownerUserId, ledger);
} catch {
// 中文注释:清理失败最多留下一条过期账本,保留期会兜底。
}
}
@@ -113,8 +113,7 @@ type ImageCanvasGenerationSurfaceOptions = {
project: EditorProjectSnapshot,
) => boolean | void;
flushProjectPersistence?: (options?: {
requireSuccess?: boolean;
requiredDialogId?: string;
preferLatestGenerationDialogs?: boolean;
}) => Promise<void>;
refreshAssetLibrary?: () => Promise<unknown> | void;
onWalletBalanceMayHaveChanged?: () => void;
@@ -25,6 +25,10 @@ import type {
SidebarPanel,
} from './ImageCanvasEditorTypes';
import { IMAGE_MODEL_GPT_IMAGE_2 } from './ImageCanvasGenerationModel';
import {
readPerfectPixelOperations,
savePerfectPixelOperation,
} from './perfectPixelOperationStore';
import { useCanvasGenerationDialogs } from './useCanvasGenerationDialogs';
import {
inspectPerfectPixelProjectSnapshot,
@@ -2615,9 +2619,7 @@ describe('useImageCanvasGenerationWorkflow', () => {
expect(events.slice(0, 3)).toEqual(['snapshot', 'flush', 'post']);
expect(flushProjectPersistence).toHaveBeenCalledTimes(1);
expect(flushProjectPersistence).toHaveBeenCalledWith({
requireSuccess: true,
requiredDialogId: flushedOperation!.operationId,
deadlineAt: expect.any(Number),
preferLatestGenerationDialogs: true,
});
expect(resolveEditorImageReferenceDataUrlMock).not.toHaveBeenCalled();
expect(flushedOperation).toEqual(
@@ -2901,14 +2903,10 @@ describe('useImageCanvasGenerationWorkflow', () => {
).toHaveLength(1);
expect(flushProjectPersistence).toHaveBeenCalledTimes(2);
expect(flushProjectPersistence).toHaveBeenNthCalledWith(1, {
requireSuccess: true,
requiredDialogId: firstOperation.operationId,
deadlineAt: expect.any(Number),
preferLatestGenerationDialogs: true,
});
expect(flushProjectPersistence).toHaveBeenNthCalledWith(2, {
requireSuccess: true,
requiredDialogId: firstOperation.operationId,
deadlineAt: expect.any(Number),
preferLatestGenerationDialogs: true,
});
});
@@ -3146,7 +3144,7 @@ describe('useImageCanvasGenerationWorkflow', () => {
);
});
it('keeps a confirmed source operation for exact retry when strict layout persistence fails before POST', async () => {
it('still posts and keeps the operation for exact retry when layout persistence fails first', async () => {
const applyProjectSnapshot = vi.fn();
const flushProjectPersistence = vi
.fn()
@@ -3185,25 +3183,38 @@ describe('useImageCanvasGenerationWorkflow', () => {
fireEvent.click(screen.getByRole('button', { name: '完美像素' }));
// 中文注释:阶段 3 的核心行为翻转。账本已经先落本机,布局保存失败只影响画布同步,
// POST 照发——旧的严格保存会在这里把整条链路拦死。
await waitFor(() => {
expect(screen.getByTestId('dialog').textContent).toContain('failed');
expect(snapImageToPerfectPixelsMock).toHaveBeenCalledTimes(1);
});
const failedOperation = JSON.parse(
await waitFor(() => {
expect(screen.getByTestId('dialog').textContent).toContain(
'pending-confirmation',
);
});
expect(flushProjectPersistence).toHaveBeenCalledTimes(1);
expect(uploadEditorMediaAssetObjectFileMock).toHaveBeenCalledTimes(1);
expect(applyProjectSnapshot).not.toHaveBeenCalled();
const postedOperation = JSON.parse(
screen.getByTestId('perfect-pixel-operation').textContent!,
) as {
operationId: string;
taskId: string;
request: EditorPixelArtSnapInput;
};
expect(failedOperation.request.sourceImageSrc).toBe(
expect(postedOperation.request.sourceImageSrc).toBe(
'generated-images/editor/confirmed-source.png',
);
expect(uploadEditorMediaAssetObjectFileMock).toHaveBeenCalledTimes(1);
expect(snapImageToPerfectPixelsMock).not.toHaveBeenCalled();
expect(loadEditorProjectMock).not.toHaveBeenCalled();
expect(applyProjectSnapshot).not.toHaveBeenCalled();
expect(screen.getByTestId('dialog-error').textContent).toBe(
'完美像素请求尚未发出:画布保存失败 请在原占位重试同一操作。',
expect(postedOperation.taskId).toBe(
`pixel-art-snap-${postedOperation.operationId}`,
);
const postedRequest = snapImageToPerfectPixelsMock.mock.calls[0]?.[0] as
| EditorPixelArtSnapInput
| undefined;
expect(JSON.stringify(postedRequest)).toBe(
JSON.stringify(postedOperation.request),
);
fireEvent.click(screen.getByRole('button', { name: '完美像素' }));
@@ -3211,25 +3222,6 @@ describe('useImageCanvasGenerationWorkflow', () => {
expect(screen.getByTestId('reference-pick-warning').textContent).toBe(
'该素材已有一条未收口的完美像素操作,请在原占位上继续核对或重试。',
);
fireEvent.click(screen.getByRole('button', { name: '重试完美像素' }));
await waitFor(() => {
expect(snapImageToPerfectPixelsMock).toHaveBeenCalledTimes(1);
});
const retriedRequest = snapImageToPerfectPixelsMock.mock.calls[0]?.[0] as
| EditorPixelArtSnapInput
| undefined;
expect(JSON.stringify(retriedRequest)).toBe(
JSON.stringify(failedOperation.request),
);
expect(retriedRequest?.canvasCompletion.dialogId).toBe(
failedOperation.operationId,
);
expect(failedOperation.taskId).toBe(
`pixel-art-snap-${failedOperation.operationId}`,
);
expect(uploadEditorMediaAssetObjectFileMock).toHaveBeenCalledTimes(1);
expect(flushProjectPersistence).toHaveBeenCalledTimes(2);
});
it('reconciles a responded failure that the server marked as post-persistence', async () => {
@@ -3423,6 +3415,61 @@ describe('useImageCanvasGenerationWorkflow', () => {
expect(screen.getByTestId('generation-dialogs').textContent).toBe('-');
});
it('reconciles an orphan local ledger entry whose placeholder never reached the layout', async () => {
// 中文注释:账本先于 POST 落本机、布局保存只是尽力而为,因此存在「账本有、占位没写进
// 布局就断电」的窗口。刷新后必须仍然对账并告知用户结果去了素材库——这正是删掉严格
// 布局保存之后仍然安全的原因。
const operationId = 'perfect-pixel-orphan-ledger';
const refreshAssetLibrary = vi.fn().mockResolvedValue(undefined);
savePerfectPixelOperation('user-a', 'project-1', {
version: 1,
kind: 'perfect-pixel',
operationId,
taskId: `pixel-art-snap-${operationId}`,
request: {
sourceImageSrc: 'generated-images/editor/source.png',
projectId: 'project-1',
canvasCompletion: {
dialogId: operationId,
title: '源图 · 完美像素',
placeholder: {
x: 0,
y: 0,
width: 320,
height: 240,
originalWidth: 320,
originalHeight: 240,
},
},
},
submittedAt: Date.now(),
reconcileUntil: Date.now() + 75_000,
});
loadEditorProjectMock.mockImplementation(async () =>
createPerfectPixelProject(operationId, 'dialog-missing'),
);
render(
<GenerationWorkflowHarness
projectId="project-1"
currentUserId="user-a"
applyProjectSnapshotWithoutHistory={vi.fn(() => true)}
flushProjectPersistence={vi.fn().mockResolvedValue(undefined)}
refreshAssetLibrary={refreshAssetLibrary}
/>,
);
await waitFor(() => {
expect(screen.getByTestId('reference-pick-warning').textContent).toBe(
'完美像素结果已保存到素材库,画布占位已不存在。',
);
});
expect(loadEditorProjectMock).toHaveBeenCalled();
expect(refreshAssetLibrary).toHaveBeenCalled();
// 中文注释:收口后账本必须清掉,否则下次加载会再发一次无谓的对账 GET。
expect(readPerfectPixelOperations('user-a', 'project-1').size).toBe(0);
});
it('does not let a never-settling asset refresh block a terminal reconciliation verdict', async () => {
const applyProjectSnapshot = vi.fn();
const refreshAssetLibrary = vi.fn(() => new Promise(() => {}));
@@ -116,6 +116,11 @@ import {
type UiAssetExtractionTool,
updateUiAssetExtractionDraftMark,
} from './ImageCanvasUiAssetExtractionModel';
import {
forgetPerfectPixelOperation,
readPerfectPixelOperations,
savePerfectPixelOperation,
} from './perfectPixelOperationStore';
import {
applyQueuedEditorGenerationProject,
createEditorGenerationMediaUploadId,
@@ -220,17 +225,15 @@ function createProjectLayerSnapshotFromLayer(
};
}
// 中文注释:源图解析/直传与 strict operation journal 使用两段独立预算。
// 中文注释:源图可能需要 ticket → PUT → confirm90 秒预算和同一 AbortSignal 覆盖完整
// 上传;confirm 成功后已经有稳定 objectKeyoperation 请求随即形成。
//
// 源图可能需要 ticket → PUT → confirm90 秒预算和同一 AbortSignal 覆盖完整上传;confirm
// 成功后已经有稳定 objectKey,operation 请求随即形成。此后 strict layout save 使用独立
// 60 秒绝对 deadline,不能继续消耗源上传的余额,也不能在本地放弃等待后继续占住保存队列。
// 这一段之前 POST 必须为零:失败时保留 `failed + perfectPixelOperation`,允许原样重试同一
// request/source object,也不能执行结果 GET 或声称派生素材可能已落库。
//
// strict ACK 之前 POST 必须为零。此阶段失败保留 `failed + perfectPixelOperation`,只允许
// 原样重试同一 request/source object;不能退回普通按钮创建新 identity,也不能执行结果 GET
// 或声称派生素材可能已落库。
// 账本落到本机(perfectPixelOperationStore)之后,这一段与 POST 之间不再有任何需要等待
// 服务端 ACK 的环节——原先的严格布局保存预算随之删除。
export const PERFECT_PIXEL_SOURCE_PREPARATION_BUDGET_MS = 90_000;
export const PERFECT_PIXEL_STRICT_LAYOUT_SAVE_BUDGET_MS = 60_000;
const PERFECT_PIXEL_PROJECT_READ_TIMEOUT_MS = 10_000;
const PERFECT_PIXEL_RECONCILIATION_DELAYS_MS = [0, 1_000, 2_000, 4_000, 5_000];
@@ -931,10 +934,7 @@ type GenerationWorkflowOptions = {
project: EditorProjectSnapshot,
) => boolean | void;
flushProjectPersistence?: (options?: {
requireSuccess?: boolean;
requiredDialogId?: string;
deadlineAt?: number;
signal?: AbortSignal;
preferLatestGenerationDialogs?: boolean;
}) => Promise<void>;
refreshAssetLibrary?: () => Promise<unknown> | void;
onWalletBalanceMayHaveChanged?: () => void;
@@ -2145,6 +2145,13 @@ export function useImageCanvasGenerationWorkflow({
if (verdict.kind !== 'applied' && verdict.kind !== 'dialog-missing') {
return false;
}
// 中文注释:项目事实已给出终态,账本再无用处。留着只会让下次加载多发一次无谓的
// 对账 GET,并占住保留期名额。
forgetPerfectPixelOperation(
currentUserId,
operation.request.projectId,
operation.operationId,
);
const confirmedAsset = resolveConfirmedPerfectPixelAsset(
result,
operation,
@@ -2177,6 +2184,7 @@ export function useImageCanvasGenerationWorkflow({
},
[
applyProjectSnapshot,
currentUserId,
hasCanvasGenerationDialogById,
refreshPerfectPixelAssetLibrary,
setActiveSidebarPanel,
@@ -2355,13 +2363,22 @@ export function useImageCanvasGenerationWorkflow({
updateCanvasGenerationDialogById(perfectPixelDialogId, (dialog) => ({
...dialog,
requiresLiveSession: undefined,
perfectPixelOperationId: perfectPixelDialogId,
perfectPixelOperation,
}));
// 中文注释:账本必须先于 POST 落到本机。这一步是同步的、不过网络、不受服务端
// 校验影响,因此它能提供「请求可被追溯」的保证,却不会像旧的严格布局保存那样
// 把布局校验失败升级成完美像素的硬阻断。
savePerfectPixelOperation(
currentUserId,
normalizedProjectId,
perfectPixelOperation,
);
// 中文注释:布局保存尽力而为——占位存进服务端后,服务端才能用 canvasCompletion
// 就地替换它。保存失败不拦 POST,只是把结果降级成「只进素材库」,由对账提示用户。
await flushProjectPersistence({
requireSuccess: true,
requiredDialogId: perfectPixelDialogId,
deadlineAt: Date.now() + PERFECT_PIXEL_STRICT_LAYOUT_SAVE_BUDGET_MS,
});
preferLatestGenerationDialogs: true,
}).catch(() => undefined);
if (!isPerfectPixelAuthorityCurrent(operationAuthority)) {
return;
}
@@ -2469,7 +2486,7 @@ export function useImageCanvasGenerationWorkflow({
const errorMessage = reconciledMessage
? `${serverMessage ? `${serverMessage} ` : ''}${reconciledMessage}`
: perfectPixelOperation && !perfectPixelPostAttempted
? `${serverMessage ?? '提交前画布保存失败'} `
? `${serverMessage ?? '提交前准备失败'} `
: (serverMessage ?? '完美像素处理失败');
if (
perfectPixelDialogId &&
@@ -2481,10 +2498,16 @@ export function useImageCanvasGenerationWorkflow({
errorMessage,
}));
} else {
// 中文注释:durable operation 的普通删除路径已被禁止,但占位可能在快照写入前
// 失败、被其它权威项目更新移除,或来自旧会话状态。没有本地 dialog 可挂错误时必须
// 退回全局提示,不能静默开放一条新 identity 的重提路径。
// 中文注释:占位可能在快照写入前失败、被用户删除、被其它权威项目更新移除,或来自
// 旧会话状态。没有本地 dialog 可挂错误时退回全局提示。
showGenerationWarning(errorMessage);
if (perfectPixelDialogId) {
forgetPerfectPixelOperation(
currentUserId,
normalizedProjectId,
perfectPixelDialogId,
);
}
}
} finally {
if (perfectPixelDialogId) {
@@ -2589,13 +2612,17 @@ export function useImageCanvasGenerationWorkflow({
status: 'generating',
errorMessage: undefined,
requiresLiveSession: undefined,
perfectPixelOperationId: normalizedDialogId,
perfectPixelOperation: retriedOperation,
}));
savePerfectPixelOperation(
currentUserId,
normalizedProjectId,
retriedOperation,
);
await flushProjectPersistence({
requireSuccess: true,
requiredDialogId: normalizedDialogId,
deadlineAt: Date.now() + PERFECT_PIXEL_STRICT_LAYOUT_SAVE_BUDGET_MS,
});
preferLatestGenerationDialogs: true,
}).catch(() => undefined);
if (!isPerfectPixelAuthorityCurrent(operationAuthority)) {
return;
}
@@ -2732,19 +2759,36 @@ export function useImageCanvasGenerationWorkflow({
perfectPixelRecoveryControllersRef.current.clear();
return;
}
const recoverableDialogs = canvasGenerationDialogs.filter(
(dialog) =>
Boolean(dialog.perfectPixelOperation) &&
(dialog.status === 'generating' ||
dialog.status === 'pending-confirmation'),
const recoverableOperations = canvasGenerationDialogs.flatMap((dialog) =>
dialog.perfectPixelOperation &&
(dialog.status === 'generating' ||
dialog.status === 'pending-confirmation')
? [{ dialogId: dialog.id, operation: dialog.perfectPixelOperation }]
: [],
);
// 中文注释:账本先于 POST 落本机、布局保存只是尽力而为,因此存在「账本有、占位没写进
// 布局就断电」的窗口。那些孤儿账本同样要对账——结果多半已经落进素材库,用户有权知道。
// 这条路径不需要占位存在:下面所有 updateCanvasGenerationDialogById 对缺席 dialog 都是
// 无操作,applied / dialog-missing 分支本就带着「占位不在」的提示文案。
const recoverableDialogIds = new Set(
canvasGenerationDialogs.map((dialog) => dialog.id),
);
const nowMs = Date.now();
for (const [operationId, operation] of readPerfectPixelOperations(
currentUserId,
normalizedProjectId,
)) {
if (
recoverableDialogIds.has(operationId) ||
operation.reconcileUntil <= nowMs
) {
continue;
}
recoverableOperations.push({ dialogId: operationId, operation });
}
const recoveryKeys = new Set(
recoverableDialogs.map((dialog) =>
perfectPixelRecoveryKey(
currentUserId,
normalizedProjectId,
dialog.perfectPixelOperation!,
),
recoverableOperations.map(({ operation }) =>
perfectPixelRecoveryKey(currentUserId, normalizedProjectId, operation),
),
);
for (const [
@@ -2757,8 +2801,7 @@ export function useImageCanvasGenerationWorkflow({
}
}
for (const dialog of recoverableDialogs) {
const operation = dialog.perfectPixelOperation!;
for (const { dialogId, operation } of recoverableOperations) {
const recoveryKey = perfectPixelRecoveryKey(
currentUserId,
normalizedProjectId,
@@ -2766,7 +2809,7 @@ export function useImageCanvasGenerationWorkflow({
);
// 中文注释:本页首次 POST / 人工重试已有自己的 Promise 收口。effect 会在 operation
// 写入后重跑,必须把该 key 记为本会话已观察并跳过,否则会并发启动第二条 GET 轮询。
if (hasActiveInlineGenerationDialog(dialog.id)) {
if (hasActiveInlineGenerationDialog(dialogId)) {
observedPerfectPixelRecoveryKeysRef.current.add(recoveryKey);
continue;
}
@@ -2781,7 +2824,12 @@ export function useImageCanvasGenerationWorkflow({
perfectPixelRecoveryControllersRef.current.set(recoveryKey, controller);
void (async () => {
if (operation.request.projectId !== normalizedProjectId) {
updateCanvasGenerationDialogById(dialog.id, (current) => {
forgetPerfectPixelOperation(
currentUserId,
normalizedProjectId,
operation.operationId,
);
updateCanvasGenerationDialogById(dialogId, (current) => {
if (
!isSameUnsettledPerfectPixelOperation(
current,
@@ -2817,15 +2865,23 @@ export function useImageCanvasGenerationWorkflow({
) {
return;
}
if (verdict.kind === 'applied' || verdict.kind === 'dialog-missing') {
// 中文注释:项目事实已给出终态,账本收口清掉,避免下次加载重复对账。
forgetPerfectPixelOperation(
currentUserId,
normalizedProjectId,
operation.operationId,
);
}
if (verdict.kind === 'applied') {
if (!hasCanvasGenerationDialogById(dialog.id)) {
if (!hasCanvasGenerationDialogById(dialogId)) {
refreshPerfectPixelAssetLibrary();
showGenerationWarning(PERFECT_PIXEL_APPLIED_REMOTELY_NOTICE);
return;
}
const applied = applyProjectSnapshotWithoutHistory(verdict.project);
if (applied === false) {
updateCanvasGenerationDialogById(dialog.id, (current) =>
updateCanvasGenerationDialogById(dialogId, (current) =>
isSameUnsettledPerfectPixelOperation(
current,
operation.operationId,
@@ -2844,8 +2900,8 @@ export function useImageCanvasGenerationWorkflow({
return;
}
if (verdict.kind === 'dialog-missing') {
if (hasCanvasGenerationDialogById(dialog.id)) {
updateCanvasGenerationDialogById(dialog.id, (current) =>
if (hasCanvasGenerationDialogById(dialogId)) {
updateCanvasGenerationDialogById(dialogId, (current) =>
isSameUnsettledPerfectPixelOperation(
current,
operation.operationId,
@@ -2858,7 +2914,7 @@ export function useImageCanvasGenerationWorkflow({
showGenerationWarning(PERFECT_PIXEL_ASSET_ONLY_NOTICE);
return;
}
updateCanvasGenerationDialogById(dialog.id, (current) =>
updateCanvasGenerationDialogById(dialogId, (current) =>
isSameUnsettledPerfectPixelOperation(current, operation.operationId)
? {
...current,
@@ -2881,9 +2937,9 @@ export function useImageCanvasGenerationWorkflow({
.catch((error: unknown) => {
if (
!controller.signal.aborted &&
hasCanvasGenerationDialogById(dialog.id)
hasCanvasGenerationDialogById(dialogId)
) {
updateCanvasGenerationDialogById(dialog.id, (current) =>
updateCanvasGenerationDialogById(dialogId, (current) =>
isSameUnsettledPerfectPixelOperation(
current,
operation.operationId,
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff