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)