diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index 694eb853..e0a1b1c8 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -1601,10 +1601,18 @@ - 现象:拼图生成页已经收到 VectorEngine 图片编辑失败并进入重试态,但用户返回草稿 Tab 后,同一草稿仍显示“生成中”;连续触发多个拼图生成时,失败后还可能只剩一条新增草稿,或者只看到标题为“第1关”的半成品空壳;抓大鹅后台失败时也可能没有任何通知,点击草稿又像重新开始生成。 - 原因:前端失败 notice 只更新生成页局部状态,pending 作品架条目在失败时被清掉或被非 `generating` 状态误映射为 `ready`;后端作品摘要也可能短暂仍是 `generationStatus=generating`。如果失败消息没有写入 notice,用户离开生成页后不会弹出 `PlatformErrorDialog`;如果打开草稿只看持久化 `generating`,就会绕过失败态恢复。 -- 处理:失败时按 session 保留 pending 作品架条目并标记 `failed`,失败 notice 保存错误消息并触发带来源的 `PlatformErrorDialog`;拼图契约没有 `failed` 枚举,pending 拼图映射为 `idle`,同时用本地失败 notice 覆盖持久化生成中状态和旧的“正在生成”摘要。点击失败草稿应优先用 notice / 后端 session / fallback payload 组装失败生成页,不能重新从 0 秒启动新进度;拼图失败半成品没有有效 `workTitle` 时,作品架标题回退为“拼图草稿”。 +- 处理:失败时按 session 保留 pending 作品架条目并标记 `failed`,失败 notice 保存错误消息并触发带来源的 `PlatformErrorDialog`;拼图契约没有 `failed` 枚举,pending 拼图映射为 `idle`,同时用本地失败 notice 覆盖持久化生成中状态和旧的“正在生成”摘要。点击失败草稿应优先用 notice / 后端 session / fallback payload 组装失败生成页,不能重新从 0 秒启动新进度;失败页点击重新生成必须优先复用当前 `sessionId` 执行编译 action,不得因存在表单缓存 payload 就调用 create-session。拼图失败半成品没有有效 `workTitle` 时,作品架标题回退为“拼图草稿”。 - 验证:`node node_modules/vitest/vitest.mjs run src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "failed parallel puzzle|background match3d"`。 - 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/custom-world-home/creationWorkShelf.ts`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 +## 生成失败重试不要走新建草稿 + +- 现象:拼图或抓大鹅生成失败后,在失败页点击“重新生成”,作品架里多出一份新的草稿,原失败草稿仍留在列表里。 +- 原因:重试 handler 曾优先读取缓存的表单 payload 并调用 create-session 路径;失败草稿按 session 留在作品架是正确行为,于是重试动作额外创建了第二份草稿。 +- 处理:只要当前失败页还能恢复到原 `sessionId`,重试就走该 session 的 compile action;只有没有可恢复 session 时,才允许用表单 payload 重新创建草稿。 +- 验证:`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "failed .* draft retry reuses current session"`。 +- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + ## 汪汪声浪草稿试玩不要写正式 run - 现象:如果草稿结果页试玩和发布后 runtime 共用同一写成绩路径,未发布或未确认资源的草稿试玩会污染正式单局、排行榜和作品统计。 diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index 063f48f8..0258cda0 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -54,7 +54,7 @@ 4. 生成中作品在整卡上加等待遮罩,但不移除作品基础信息。 5. 生成中状态不能只存在前端内存 notice。后端作品摘要必须下发可恢复的 `generationStatus`;前端刷新或退出产品后,作品架优先用摘要状态恢复等待遮罩,本轮内存 notice 只作为即时反馈。 6. 点击 `generationStatus=generating` 的草稿卡必须恢复对应玩法的生成进度页,不能进入空白结果页或普通工作区;恢复生成页的 `startedAtMs` 优先使用后端 session 的 `updatedAt`,没有 session 时再使用作品摘要 `updatedAt`,不得因重新进入页面从 0 秒重新计时。 -7. 生成失败必须按 session 独立记录,不能用一个失败打断或覆盖同玩法的其它生成任务。失败 notice 需要保存错误消息并覆盖作品架本地状态:即使后端摘要暂时仍是 `generationStatus=generating` 或只写出半成品投影,草稿卡也不得继续显示“生成中”,点击后必须进入失败 / 重试生成页,不能重新创建一轮生成;拼图这类失败半成品若没有有效 `workTitle`,作品架标题回退为“拼图草稿”,不暴露“第1关”空壳。 +7. 生成失败必须按 session 独立记录,不能用一个失败打断或覆盖同玩法的其它生成任务。失败 notice 需要保存错误消息并覆盖作品架本地状态:即使后端摘要暂时仍是 `generationStatus=generating` 或只写出半成品投影,草稿卡也不得继续显示“生成中”,点击后必须进入失败 / 重试生成页,不能重新创建一轮生成。失败页点击重新生成时必须优先复用当前可恢复 `sessionId` 执行编译 action;只有没有可恢复 session 时才允许回退到新建草稿。拼图这类失败半成品若没有有效 `workTitle`,作品架标题回退为“拼图草稿”,不暴露“第1关”空壳。 8. 从草稿 Tab 作品架打开草稿工作区、生成页或结果页时,返回按钮必须回到草稿 Tab 的同一作品架语境;从创作 Tab 新建或直接进入创作链路时才回到创作 Tab。平台壳层需要显式记录本次创作流的返回来源,不能让结果页返回动作固定跳到创作入口。 9. 私有 generated 图片必须通过 `ResolvedAssetImage` / `/api/assets/read-url` 换签读取。 10. 敲木鱼作品架读取当前用户作品列表时走 `GET /api/creation/wooden-fish/works`;发布成功后平台壳必须同时刷新作品架与公开广场,避免作品刚发布时仍停留在旧列表。 diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index b4d77e98..86e77d3c 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -9449,20 +9449,26 @@ export function PlatformEntryFlowShellImpl({ const executeSquareHoleAction = squareHoleFlow.executeAction; const retryMatch3DDraftGeneration = useCallback(() => { - if (match3dFormDraftPayload && !match3dSession?.draft?.profileId) { - void createMatch3DDraftFromForm(match3dFormDraftPayload); + if (match3dSession?.sessionId) { + const retryPayload = + match3dFormDraftPayload ?? + buildMatch3DFormPayloadFromSession(match3dSession); + void executeMatch3DAction({ + action: 'match3d_compile_draft', + generateClickSound: retryPayload.generateClickSound, + }); return; } - void executeMatch3DAction({ - action: 'match3d_compile_draft', - generateClickSound: match3dFormDraftPayload?.generateClickSound, - }); + if (match3dFormDraftPayload) { + void createMatch3DDraftFromForm(match3dFormDraftPayload); + return; + } }, [ createMatch3DDraftFromForm, executeMatch3DAction, match3dFormDraftPayload, - match3dSession?.draft?.profileId, + match3dSession, ]); const retrySquareHoleAssetGeneration = useCallback(() => { @@ -10336,15 +10342,25 @@ export function PlatformEntryFlowShellImpl({ ); const retryPuzzleDraftGeneration = useCallback(() => { - if (puzzleFormDraftPayload) { - void createPuzzleDraftFromForm(puzzleFormDraftPayload); + if (puzzleSession?.sessionId) { + const retryPayload = + puzzleFormDraftPayload ?? + buildPuzzleFormPayloadFromSession(puzzleSession); + void executePuzzleAction( + buildPuzzleCompileActionFromFormPayload(retryPayload), + ); return; } - void executePuzzleAction( - buildPuzzleCompileActionFromFormPayload(puzzleFormDraftPayload), - ); - }, [createPuzzleDraftFromForm, executePuzzleAction, puzzleFormDraftPayload]); + if (puzzleFormDraftPayload) { + void createPuzzleDraftFromForm(puzzleFormDraftPayload); + } + }, [ + createPuzzleDraftFromForm, + executePuzzleAction, + puzzleFormDraftPayload, + puzzleSession, + ]); const retryVisualNovelDraftGeneration = useCallback(() => { if (!visualNovelFormDraftPayload) { diff --git a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx index 64d065a3..d1323f12 100644 --- a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx +++ b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx @@ -4228,6 +4228,115 @@ test('background match3d draft failure notifies and reopens failed retry page', expect(match3dCreationClient.executeAction).toHaveBeenCalledTimes(1); }); +test('failed match3d draft retry reuses current session instead of creating another draft', async () => { + const user = userEvent.setup(); + const failedSession = buildMockMatch3DAgentSession({ + sessionId: 'match3d-retry-failed-session', + draft: null, + stage: 'collecting_config', + updatedAt: '2026-05-18T12:05:00.000Z', + }); + const persistedFailedWork: Match3DWorkSummary = { + workId: 'match3d-retry-failed-work', + profileId: 'match3d-retry-failed-profile', + ownerUserId: 'user-1', + sourceSessionId: failedSession.sessionId, + gameName: '重试抓鹅', + themeText: '霓虹水果摊', + summary: '抓大鹅素材生成失败,可重新打开处理。', + tags: ['水果', '抓大鹅'], + coverImageSrc: null, + referenceImageSrc: null, + clearCount: 12, + difficulty: 4, + publicationStatus: 'draft', + playCount: 0, + updatedAt: '2026-05-18T12:05:00.000Z', + publishedAt: null, + publishReady: false, + generationStatus: 'generating', + generatedItemAssets: [], + }; + let rejectCompile!: (reason?: unknown) => void; + vi.mocked(match3dCreationClient.createSession).mockResolvedValue({ + session: failedSession, + }); + vi.mocked(match3dCreationClient.executeAction).mockReturnValueOnce( + new Promise((_, reject) => { + rejectCompile = reject; + }), + ); + vi.mocked(match3dCreationClient.getSession).mockResolvedValue({ + session: failedSession, + }); + + render(); + + await openCreateTemplateHub(user); + await user.click(await findCreationTypeButton('抓大鹅')); + await user.click( + await screen.findByRole('button', { name: '生成抓大鹅草稿' }), + ); + await screen.findByRole('progressbar', { name: '抓大鹅草稿生成进度' }); + await user.click(screen.getByRole('button', { name: '返回创作中心' })); + await openDraftHub(user); + vi.mocked(listMatch3DWorks).mockResolvedValue({ + items: [persistedFailedWork], + }); + + await act(async () => { + rejectCompile(new Error('抓大鹅素材服务失败')); + await Promise.resolve(); + }); + const failureDialog = await screen.findByRole('dialog', { + name: '发生错误', + }); + await user.click(within(failureDialog).getByRole('button', { name: '关闭' })); + + const draftPanel = getPlatformTabPanel('saves'); + await user.click( + await within(draftPanel).findByRole('button', { + name: /继续创作《(?:重试抓鹅|抓大鹅草稿)》/u, + }), + ); + const reopenedFailureDialog = await screen.findByRole('dialog', { + name: '发生错误', + }); + await user.click( + within(reopenedFailureDialog).getByRole('button', { name: '关闭' }), + ); + vi.mocked(match3dCreationClient.executeAction).mockResolvedValueOnce({ + session: buildMockMatch3DAgentSession({ + sessionId: failedSession.sessionId, + stage: 'draft_ready', + draft: { + profileId: persistedFailedWork.profileId, + gameName: persistedFailedWork.gameName, + themeText: persistedFailedWork.themeText, + summary: persistedFailedWork.summary, + tags: persistedFailedWork.tags, + coverImageSrc: null, + referenceImageSrc: null, + clearCount: persistedFailedWork.clearCount, + difficulty: persistedFailedWork.difficulty, + generatedItemAssets: [], + }, + }), + }); + + await user.click(await screen.findByRole('button', { name: '重新生成草稿' })); + + await waitFor(() => { + expect(match3dCreationClient.executeAction).toHaveBeenCalledTimes(2); + }); + expect(match3dCreationClient.createSession).toHaveBeenCalledTimes(1); + expect(match3dCreationClient.executeAction).toHaveBeenNthCalledWith( + 2, + failedSession.sessionId, + expect.objectContaining({ action: 'match3d_compile_draft' }), + ); +}); + test('running match3d persisted draft reopens progress instead of unfinished result', async () => { const user = userEvent.setup(); const runningSession = buildMockMatch3DAgentSession({ @@ -4921,6 +5030,113 @@ test('failed parallel puzzle generations stay as separate non-generating drafts' .toBeTruthy(); }); +test('failed puzzle draft retry reuses current session instead of creating another draft', async () => { + const user = userEvent.setup(); + const failedSession = buildMockPuzzleAgentSession({ + sessionId: 'puzzle-retry-failed-session', + draft: null, + stage: 'collecting_anchors', + updatedAt: '2026-05-18T12:00:00.000Z', + }); + const persistedFailedWork: PuzzleWorkSummary = { + workId: `puzzle-work-${failedSession.sessionId}`, + profileId: `puzzle-profile-${failedSession.sessionId}`, + ownerUserId: 'user-1', + sourceSessionId: failedSession.sessionId, + authorDisplayName: '测试玩家', + workTitle: '', + workDescription: '一套雨夜猫街主题拼图。', + levelName: '第1关', + summary: '一套雨夜猫街主题拼图。', + themeTags: [], + coverImageSrc: null, + coverAssetId: null, + publicationStatus: 'draft', + updatedAt: '2026-05-18T12:00:00.000Z', + publishedAt: null, + playCount: 0, + remixCount: 0, + likeCount: 0, + publishReady: false, + generationStatus: 'failed', + levels: [], + }; + let rejectCompile!: (reason?: unknown) => void; + vi.mocked(createPuzzleAgentSession).mockResolvedValue({ + session: failedSession, + }); + vi.mocked(getPuzzleAgentSession).mockResolvedValue({ + session: failedSession, + }); + vi.mocked(executePuzzleAgentAction).mockReturnValueOnce( + new Promise((_, reject) => { + rejectCompile = reject; + }), + ); + + render(); + + await openCreateTemplateHub(user); + await user.click(await findCreationTypeButton('拼图')); + await user.click(await screen.findByRole('button', { name: '生成草稿' })); + await screen.findByRole('progressbar', { name: '拼图图片生成进度' }); + await user.click(screen.getByRole('button', { name: '返回创作中心' })); + await openDraftHub(user); + vi.mocked(listPuzzleWorks).mockResolvedValue({ + items: [persistedFailedWork], + }); + + await act(async () => { + rejectCompile(new Error('拼图图片生成失败')); + await Promise.resolve(); + }); + const failureDialog = await screen.findByRole('dialog', { + name: '发生错误', + }); + await user.click(within(failureDialog).getByRole('button', { name: '关闭' })); + + const draftPanel = getPlatformTabPanel('saves'); + await user.click( + await within(draftPanel).findByRole('button', { + name: /继续创作《[^》]+》/u, + }), + ); + const reopenedFailureDialog = await screen.findByRole('dialog', { + name: '发生错误', + }); + await user.click( + within(reopenedFailureDialog).getByRole('button', { name: '关闭' }), + ); + vi.mocked(executePuzzleAgentAction).mockResolvedValueOnce({ + operation: { + operationId: 'compile-puzzle-retry', + type: 'compile_puzzle_draft', + status: 'completed', + phaseLabel: '已完成', + phaseDetail: '草稿已生成', + progress: 1, + }, + session: buildMockPuzzleAgentSession({ + sessionId: failedSession.sessionId, + stage: 'ready_to_publish', + progressPercent: 100, + draft: buildReadyPuzzleDraft(), + }), + }); + + await user.click(await screen.findByRole('button', { name: '重新生成图片' })); + + await waitFor(() => { + expect(executePuzzleAgentAction).toHaveBeenCalledTimes(2); + }); + expect(createPuzzleAgentSession).toHaveBeenCalledTimes(1); + expect(executePuzzleAgentAction).toHaveBeenNthCalledWith( + 2, + failedSession.sessionId, + expect.objectContaining({ action: 'compile_puzzle_draft' }), + ); +}); + test('running puzzle draft opens generation progress from draft tab', async () => { const user = userEvent.setup(); const runningSession = buildMockPuzzleAgentSession({