diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md
index 207bee19a..76f1e0791 100644
--- a/docs/project-memory/shared-memory/decision-log.md
+++ b/docs/project-memory/shared-memory/decision-log.md
@@ -5962,3 +5962,12 @@
- Runtime 恢复确认:GUI 自动扫描 `agent.resume` 前必须先用只读方式判断是否存在可恢复任务或 durable recovery artifact;全新项目与已完全终态、无任何恢复工作的项目直接返回空结果,不弹出“恢复未完成 Runtime 任务”;一旦存在 task、retry、handoff、finalization、pending action 或 reconciliation 等可恢复工作,仍必须经过原 `agent.resume` policy 门禁,不得通过吞掉 policy error 绕过确认。
- 每条输出入聊天:事件文件中的原始 `summary / detail` 仍是私有 Runtime 证据,不可由前端直接持久化。Rust 只对白名单用户进度生成 `publicText`,同时为每次真实追加生成 `eventId`;action 重放沿用 action 身份,普通事件使用进程、毫秒与单调序列组成唯一身份。前端把 `eventId + publicText` 和三阶段专业 Agent 的 durable final reply 作为独立 assistant 消息,按顶层 `messageId` 幂等写入项目 conversation;重载恢复、轮询与实时事件并发不得重复或漏掉当前已观察输出。
- 关联文档:`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`、`docs/project-memory/shared-memory/development-workflow.md`。
+
+## 2026-08-01 完美像素未知结果先对账再定性
+
+- 缺陷:`snapImageToPerfectPixels` 的 catch 对所有错误一视同仁——标 `failed`、`finally` 解锁、按钮恢复可点。transport 异常、abort 和 120 秒客户端超时因此被谎报成明确失败,而服务端此时很可能已经完成 OSS PUT、asset object、project resource、账号素材和画布回填,只是响应没回来。用户按提示重试就再造一整份对象、资源与素材。这直接违反本功能自己立下的契约:「结果未知时先 GET 权威项目 / 素材快照,由用户显式决定是否再次执行」。客户端未配 `EDITOR_REQUEST_RETRY_OPTIONS`(禁自动重放)这半条一直是达标的。
+- 判别依据:`ApiClientError` 只在拿到服务端 `Response` 时由 `buildApiClientError` 构造,transport 异常、`AbortError` 和 `TimeoutError` 在重试判定后原样抛出。因此 `error instanceof ApiClientError` 即「服务端明确响应过、结果已知」,其余一律按未知处理。已知结果不发对账 GET,避免每个 `400` 都多打一次权威读取。
+- 决策:未知结果先 `loadEditorProject` 取权威快照,再按占位是否存活分流。占位已被 completion 消费掉说明这次其实成功,按快照收口并写入正常的 `perfect-pixel` 历史,不报错。占位仍在说明画布没收到结果,同步快照消除本地与服务端偏差但**不写历史**,文案明确告知结果未知且素材库可能已有派生图、要求用户先核对再决定是否重试——持久化是非事务的,OSS 对象与账号素材可能已落库而画布回填未完成。对账 GET 本身失败时给出「权威快照读取失败」的独立文案,不退回谎报。
+- 未覆盖:刷新页面后停在 `generating` 的占位仍无自动收口。占位在 POST 前已由 `flushProjectPersistence` 落库,`hydrateCanvasGenerationDialog` 原样恢复 `generating`,而该链路不进 `external_generation_job`,任务侧栏轮询看不到它,inline POST 的 Promise 随旧页面销毁。需要在 hydration 后加对账,且要先给 dialog 增加「属于无 durable job 的 inline 链路」标记,改动面大于本次,单独立项。客户端 120 秒超时相对服务端 30 秒预算是四倍冗余,调小可让对账更早发生,未处理。
+- 验证:新增三条用例分别覆盖「未知但实际成功→按快照收口并写历史」「未知且占位存活→只同步快照、标失败、文案要求先核对」「`ApiClientError` 已知失败→不发对账 GET、不动快照」。既有用例 `keeps a failed perfect-pixel placeholder` 原本用裸 `Error` 表达「服务端识别不到网格」,语义不准且会误入对账路径,改为 `ApiClientError`。测试 harness 新增 `dialog-error` 输出,否则对账文案不可观测。
+- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md
index 34897110a..d90376f0b 100644
--- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md
+++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md
@@ -169,7 +169,7 @@
- 发送消息后,面板先展示本地用户消息和请求等待态,再应用普通 JSON 响应中的 `deltaMessages`;客户端取消等待只终止本次 transport 等待,不把已经确认入队的外部生成任务改成停止态。
- Agent 工具任务完成并懒回填后,消息内缩略图不显示名称;前端通过编辑器作用域 Action Context 的 `refreshCanvas()` 直接重新读取工程快照和素材库,不从 Editor 经 Stage、Panel 和 MessageBubble 透传刷新 callback。图片、视频和音频结果携带有效 `resourceId` 时,在素材右键菜单显示“在画布中定位”;有效图片结果的普通单击也直接通过同一 Context 的 `focusResource(resourceId)` 请求画布在 `420ms` 内平滑 fit 到对应图层。结果卡片不声明按钮语义或 `tabIndex`,Enter 和 Space 不得触发定位;视频和音频的普通点击及原生播放器交互保持独立。定位只改变 viewport,不选择图层、不切换工具或侧栏、不收起 Agent 面板,也不避让面板覆盖区。缺少 `resourceId` 时单击无动作且不显示定位菜单项,目标图层已删除时保持无动作。对话入口触发生成时不创建“即将生成”画布占位,生成完成后由后端 `canvasCompletion` 落新图层。规划或工具失败时消息内必须保留可回读的失败状态和错误气泡,不能只弹一次性 toast 或返回瞬时 `errorMessage`。
- 画布 Agent 会话刷新后能从后端恢复会话标题、消息、附件和生成记录;前端不得根据本地临时状态伪造会话持久化结果。
-- 图片选中后的浮动工具栏按钮顺序固定为:快速编辑、分割线、裁扩按钮、去除背景按钮、完美像素按钮、UI设计图专属提取素材、角色图专属生成动画、分割线、重绘、下载按钮。完美像素只对当前静态栅格图层一键执行,按钮在请求期间按 layer id 进入 disabled / busy,首个 await 前用同步 ref 抢占,连续点击不得重复提交;完成后保留源图并在右侧显示派生 PNG,失败占位保留明确错误且释放 busy。该图层的素材类型保存在途时(`persistingAssetKindLayerIds`)完美像素按钮同样必须 disabled / busy,并在 handler 里用同步 ref 二次拦截——请求同时携带 `assetKind` 与 `sourceResourceId`,本地类型已改而资源尚未落库时两者不一致,后端 `resolve_editor_pixel_art_snap_asset_kind` 直接返回 `400`,只留下需要手动清理的失败占位。这与相邻的拆分图集按钮共用同一套门禁,但保存态的无障碍名称必须区分(完美像素用 `完美像素等待素材类型保存`),否则 `icon-spritesheet` 图层上两个按钮会同时叫「素材类型保存中」。裁扩通过画布边界拖拉完成,不再展示四边数值输入;默认自由比例,选择固定比例后拖拉边界保持对应比例,完成后在原素材旁边新增裁扩结果图层,扩展区域透明填充。去除背景调用同源 BFF `POST /api/editor/images/background-removals`;父流程解析并校验私有 OSS object key 后只调用一次唯一内部 `bgfilter-worker` 的 complex 链路,子 worker 负责签发 600 秒 URL、`N / Q` 限流和最多两次顺序 provider attempt,complex 失败不接入 fallback,成功二进制返回后仍由父流程完成最终持久化。有项目上下文时先在画布创建关闭面板的去背景生成占位,完成后由后端通过 `canvasCompletion` 把新 project resource 写入该占位并返回快照,无占位上下文时才用新的 project resource 引用替换当前图层。画布任务侧栏按“排队/生成中”和“已完成”分页,生成中排在排队前,生成中耗时从任务开始时间戳实时计算,排队中不计时;进行中任务只显示阶段文本和已用时,不显示百分比;完成态生成任务副标题显示用户提示词并单行截断;点击任务只聚焦对应画布内容,不激活生成面板或改变任务顺序,聚焦时必须预留图片上方工具栏、底部工具栏和可见生成对话框空间。UI设计图的提取素材必须先进入红框素材框选状态,默认启用矩形框选,右侧框选工具与快速编辑统一且可再次点击取消启用态,当前启用工具按钮必须保持高亮。素材提取面板必须在素材下方,使用与生成新素材一致的面板宽度和底部模型 / 按钮样式,提示语显示 `使用框选工具框选你希望从画面中提取的素材`,并展示按原图坐标准确裁剪的框选区域截图预览、固定模型 `gpt-image-2`、左下角计划规格 `1:1·1K/2K` 和 `提取 · N泥点` 按钮,不显示额外取消按钮;点击素材和面板以外的画布区域即退出 UI 素材提取。至少框选一个区域后才可提交,前端把红色轮廓绘入原图后固定走 `gpt-image-2` 和自动决策纯色背景素材提取提示词。透明处理及拆分正常完成时,透明 spritesheet 和拆分素材都按后端快照保留为画布图层;透明处理失败时仅原图作为主结果,既不要求透明图也不要求切片;透明图成功但拆分失败时保留整张透明图并展示拆分告警。三种完成结果都以后端项目快照为准。
+- 图片选中后的浮动工具栏按钮顺序固定为:快速编辑、分割线、裁扩按钮、去除背景按钮、完美像素按钮、UI设计图专属提取素材、角色图专属生成动画、分割线、重绘、下载按钮。完美像素只对当前静态栅格图层一键执行,按钮在请求期间按 layer id 进入 disabled / busy,首个 await 前用同步 ref 抢占,连续点击不得重复提交;完成后保留源图并在右侧显示派生 PNG,失败占位保留明确错误且释放 busy。该路由是 unsafe POST 且不得配置自动重放,因此 catch 必须区分已知与未知结果:`ApiClientError` 表示服务端明确响应过,直接标失败;transport 异常、abort 和客户端超时属于未知结果,必须先 GET 权威项目快照对账——占位已被 completion 消费掉则按快照收口并写入正常的完美像素历史,占位仍存活则只同步快照、不写历史,并明确告知结果未知且素材库可能已有派生图,由用户先核对再决定是否重试。刷新后停在 `generating` 的占位目前仍无自动收口,需要用户手动处理。该图层的素材类型保存在途时(`persistingAssetKindLayerIds`)完美像素按钮同样必须 disabled / busy,并在 handler 里用同步 ref 二次拦截——请求同时携带 `assetKind` 与 `sourceResourceId`,本地类型已改而资源尚未落库时两者不一致,后端 `resolve_editor_pixel_art_snap_asset_kind` 直接返回 `400`,只留下需要手动清理的失败占位。这与相邻的拆分图集按钮共用同一套门禁,但保存态的无障碍名称必须区分(完美像素用 `完美像素等待素材类型保存`),否则 `icon-spritesheet` 图层上两个按钮会同时叫「素材类型保存中」。裁扩通过画布边界拖拉完成,不再展示四边数值输入;默认自由比例,选择固定比例后拖拉边界保持对应比例,完成后在原素材旁边新增裁扩结果图层,扩展区域透明填充。去除背景调用同源 BFF `POST /api/editor/images/background-removals`;父流程解析并校验私有 OSS object key 后只调用一次唯一内部 `bgfilter-worker` 的 complex 链路,子 worker 负责签发 600 秒 URL、`N / Q` 限流和最多两次顺序 provider attempt,complex 失败不接入 fallback,成功二进制返回后仍由父流程完成最终持久化。有项目上下文时先在画布创建关闭面板的去背景生成占位,完成后由后端通过 `canvasCompletion` 把新 project resource 写入该占位并返回快照,无占位上下文时才用新的 project resource 引用替换当前图层。画布任务侧栏按“排队/生成中”和“已完成”分页,生成中排在排队前,生成中耗时从任务开始时间戳实时计算,排队中不计时;进行中任务只显示阶段文本和已用时,不显示百分比;完成态生成任务副标题显示用户提示词并单行截断;点击任务只聚焦对应画布内容,不激活生成面板或改变任务顺序,聚焦时必须预留图片上方工具栏、底部工具栏和可见生成对话框空间。UI设计图的提取素材必须先进入红框素材框选状态,默认启用矩形框选,右侧框选工具与快速编辑统一且可再次点击取消启用态,当前启用工具按钮必须保持高亮。素材提取面板必须在素材下方,使用与生成新素材一致的面板宽度和底部模型 / 按钮样式,提示语显示 `使用框选工具框选你希望从画面中提取的素材`,并展示按原图坐标准确裁剪的框选区域截图预览、固定模型 `gpt-image-2`、左下角计划规格 `1:1·1K/2K` 和 `提取 · N泥点` 按钮,不显示额外取消按钮;点击素材和面板以外的画布区域即退出 UI 素材提取。至少框选一个区域后才可提交,前端把红色轮廓绘入原图后固定走 `gpt-image-2` 和自动决策纯色背景素材提取提示词。透明处理及拆分正常完成时,透明 spritesheet 和拆分素材都按后端快照保留为画布图层;透明处理失败时仅原图作为主结果,既不要求透明图也不要求切片;透明图成功但拆分失败时保留整张透明图并展示拆分告警。三种完成结果都以后端项目快照为准。
- 重绘生成资源后,右侧出现新生成结果图层,并自动 fit 原图 + 新图,且重绘面板保持打开。
- 快速编辑 / 重绘站内 public 示例图、历史 generated 图或 OSS generated 图时,优先复用当前图层已有 `objectKey` / `resourceId` / `sourceAssetId`;尚未登记且没有稳定引用的浏览器本地图片或普通 public 图片路径都必须先上传并取得 objectKey。前端不得再把正式对象下载成 `data:image/*;base64,...` 后提交,也不得把 Data URL / Blob URL 写入外部生成持久任务 JSON;后端收到引用后统一做 owner 归属校验并签名读取。
- 快速编辑不保留额外参考图入口;点击修改时只把原图或红框序号标注图作为 `/api/editor/images/edits` 的 `sourceImageSrc` 提交给后端。
diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx
index 4377e3462..861d8b949 100644
--- a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx
+++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx
@@ -10,6 +10,7 @@ import {
import { useRef, useState } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { ApiClientError } from '../../services/apiClient';
import type {
CanvasGenerationDialogState,
CanvasLayer,
@@ -34,6 +35,7 @@ const uploadEditorMediaAssetFileMock = vi.hoisted(() => vi.fn());
const renderCropExpandImageMock = vi.hoisted(() => vi.fn());
const removeImageBackgroundMock = vi.hoisted(() => vi.fn());
const snapImageToPerfectPixelsMock = vi.hoisted(() => vi.fn());
+const loadEditorProjectMock = vi.hoisted(() => vi.fn());
const resolveEditorImageReferenceDataUrlMock = vi.hoisted(() => vi.fn());
vi.mock('../../services/image-editor/editorImageReference', async () => {
@@ -62,6 +64,7 @@ vi.mock('../../services/image-editor/editorProjectClient', async () => {
generateEditorIconSpritesheet: generateEditorIconSpritesheetMock,
generateEditorImage: generateEditorImageMock,
generateEditorSoundEffect: generateEditorSoundEffectMock,
+ loadEditorProject: loadEditorProjectMock,
splitEditorIconSpritesheet: splitEditorIconSpritesheetMock,
};
});
@@ -231,6 +234,9 @@ function GenerationWorkflowHarness({
: '-'}
{activeDialog?.prompt || '-'}
+
+ {activeDialog?.errorMessage || '-'}
+
{activeDialog
? `${activeDialog.mode}:${activeDialog.imageModel ?? '-'}:${activeDialog.aspectRatio ?? '-'}:${activeDialog.imageSize ?? '-'}`
@@ -925,6 +931,9 @@ function GenerationWorkflowHarness({
describe('useImageCanvasGenerationWorkflow', () => {
beforeEach(() => {
vi.clearAllMocks();
+ // 中文注释:未知结果的对账 GET 默认返回 null,等价于"权威快照读不到"。需要具体
+ // 对账结果的用例各自 mockResolvedValueOnce 覆盖。
+ loadEditorProjectMock.mockResolvedValue(null);
resolveEditorImageReferenceDataUrlMock.mockImplementation(
async (src: string) => src,
);
@@ -2256,6 +2265,153 @@ describe('useImageCanvasGenerationWorkflow', () => {
});
});
+ it('reconciles an unknown perfect-pixel outcome that actually succeeded', async () => {
+ // 中文注释:120 秒超时或 transport abort 时服务端可能已经写完。此路由禁用自动
+ // 重放,直接标 failed 会谎报结果、诱使用户重试再造一份对象与素材。契约要求先
+ // GET 权威快照对账;占位已被 completion 消费掉即视为成功。
+ const applyProjectSnapshot = vi.fn();
+ const reconciledProject = {
+ projectId: 'project-1',
+ title: '未命名画布',
+ viewport: { x: 0, y: 0, scale: 1 },
+ layers: [],
+ resources: [],
+ updatedAt: '2026-08-01T00:00:00.000Z',
+ };
+ const timeoutError = new Error('The operation timed out.');
+ timeoutError.name = 'TimeoutError';
+ snapImageToPerfectPixelsMock.mockRejectedValueOnce(timeoutError);
+ loadEditorProjectMock.mockResolvedValueOnce(reconciledProject);
+
+ render(
+ {})}
+ />,
+ );
+
+ fireEvent.click(screen.getByRole('button', { name: '完美像素' }));
+
+ await waitFor(() => {
+ expect(loadEditorProjectMock).toHaveBeenCalledWith('project-1');
+ });
+ expect(applyProjectSnapshot).toHaveBeenCalledWith(reconciledProject, {
+ type: 'perfect-pixel',
+ count: 1,
+ });
+ await waitFor(() => {
+ expect(
+ screen.getByRole('status', { name: '完美像素状态' }).textContent,
+ ).toBe('空闲');
+ });
+ expect(screen.getByTestId('dialog').textContent).not.toContain('failed');
+ expect(screen.getByTestId('dialog-error').textContent).toBe('-');
+ });
+
+ it('marks an unknown perfect-pixel outcome as unresolved when the placeholder survives', async () => {
+ // 中文注释:占位仍在权威快照里说明画布没收到结果。持久化是非事务的,OSS 对象与
+ // 账号素材仍可能已落库,因此只同步快照、不写历史,文案必须要求用户先核对。
+ const applyProjectSnapshot = vi.fn();
+ const reconciledProject = {
+ projectId: 'project-1',
+ title: '未命名画布',
+ viewport: { x: 0, y: 0, scale: 1 },
+ layers: [
+ {
+ itemType: 'generation-dialog',
+ dialog: {
+ id: 'generation-dialog-1',
+ mode: 'quick-edit',
+ status: 'generating',
+ prompt: '完美像素',
+ },
+ },
+ ],
+ resources: [],
+ updatedAt: '2026-08-01T00:00:00.000Z',
+ };
+ const abortError = new Error('The operation was aborted.');
+ abortError.name = 'AbortError';
+ snapImageToPerfectPixelsMock.mockRejectedValueOnce(abortError);
+ loadEditorProjectMock.mockResolvedValueOnce(reconciledProject);
+
+ render(
+ {})}
+ />,
+ );
+
+ fireEvent.click(screen.getByRole('button', { name: '完美像素' }));
+
+ await waitFor(() => {
+ expect(loadEditorProjectMock).toHaveBeenCalledWith('project-1');
+ });
+ // 中文注释:只同步快照,不带历史动作——这次没有成功。
+ expect(applyProjectSnapshot).toHaveBeenCalledWith(reconciledProject);
+ await waitFor(() => {
+ expect(screen.getByTestId('dialog').textContent).toContain('failed');
+ });
+ expect(screen.getByTestId('dialog-error').textContent).toContain(
+ '结果未知',
+ );
+ expect(screen.getByTestId('dialog-error').textContent).toContain(
+ '请先确认再决定是否重试',
+ );
+ });
+
+ it('keeps a rejected perfect-pixel request out of the reconciliation path', async () => {
+ // 中文注释:服务端明确响应过(ApiClientError)就是已知结果,不需要也不应该
+ // 再发对账 GET,否则每个 400 都要多打一次权威读取。
+ const applyProjectSnapshot = vi.fn();
+ snapImageToPerfectPixelsMock.mockRejectedValueOnce(
+ new ApiClientError({
+ message: 'assetKind 与来源素材权威类型不一致。',
+ status: 400,
+ code: 'HTTP_400',
+ }),
+ );
+
+ render(
+ {})}
+ />,
+ );
+
+ fireEvent.click(screen.getByRole('button', { name: '完美像素' }));
+
+ await waitFor(() => {
+ expect(screen.getByTestId('dialog').textContent).toContain('failed');
+ });
+ expect(loadEditorProjectMock).not.toHaveBeenCalled();
+ expect(applyProjectSnapshot).not.toHaveBeenCalled();
+ expect(screen.getByTestId('dialog-error').textContent).toBe(
+ 'assetKind 与来源素材权威类型不一致。',
+ );
+ });
+
it('uploads an inline perfect-pixel source before flushing and posting', async () => {
const order: string[] = [];
uploadEditorMediaAssetFileMock.mockImplementationOnce(async () => {
@@ -2318,8 +2474,14 @@ describe('useImageCanvasGenerationWorkflow', () => {
it('keeps a failed perfect-pixel placeholder and releases busy state', async () => {
const applyProjectSnapshot = vi.fn();
+ // 中文注释:服务端明确拒绝(识别不到像素网格)是已知结果,用 ApiClientError 表达。
+ // 裸 Error 在生产里对应的是 transport 异常,属于未知结果,会走对账路径。
snapImageToPerfectPixelsMock.mockRejectedValueOnce(
- new Error('像素网格无法识别'),
+ new ApiClientError({
+ message: '像素网格无法识别',
+ status: 422,
+ code: 'HTTP_422',
+ }),
);
render(
null,
+ );
+ if (reconciled) {
+ const placeholderSurvived = reconciled.layers.some(
+ (item) =>
+ isCanvasGenerationDialogLayoutItem(item) &&
+ (item as { dialog?: { id?: unknown } }).dialog?.id ===
+ perfectPixelDialogId,
+ );
+ if (!placeholderSurvived) {
+ // 中文注释:占位已被服务端 completion 消费掉,说明这次其实成功了。
+ // 按权威快照收口并写入正常的完美像素历史,不再报错。
+ applyProjectSnapshot(reconciled, {
+ type: 'perfect-pixel',
+ count: 1,
+ });
+ setActiveTool('select');
+ setActiveSidebarPanel('layers');
+ return;
+ }
+ // 中文注释:占位仍在,画布没收到结果。先同步权威快照消除本地与服务端的
+ // 偏差,但不写历史——这次没有成功。持久化是非事务的,OSS 对象与账号素材
+ // 仍可能已经落库,所以文案必须让用户先去核对而不是直接重试。
+ applyProjectSnapshot(reconciled);
+ reconciledMessage =
+ '完美像素结果未知:已核对权威快照,画布未收到结果。素材库可能已存在派生图,请先确认再决定是否重试。';
+ } else {
+ reconciledMessage =
+ '完美像素结果未知,且权威快照读取失败。请刷新后确认素材库与画布,再决定是否重试。';
+ }
+ }
const errorMessage =
- error instanceof Error && error.message.trim()
+ reconciledMessage ??
+ (error instanceof Error && error.message.trim()
? error.message
- : '完美像素处理失败';
+ : '完美像素处理失败');
if (
perfectPixelDialogId &&
hasCanvasGenerationDialogById(perfectPixelDialogId)