From dcbf02bbda4227045ab08a789146e2294232656b Mon Sep 17 00:00:00 2001 From: kdletters Date: Fri, 5 Jun 2026 22:49:07 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20=E5=A4=B1=E8=B4=A5=E8=8D=89=E7=A8=BF?= =?UTF-8?q?=E9=87=8D=E8=AF=95=E5=A4=8D=E7=94=A8=E5=8E=9F=E4=BC=9A=E8=AF=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .hermes/shared-memory/pitfalls.md | 10 +- ...玩法创作】平å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md | 2 +- .../PlatformEntryFlowShellImpl.tsx | 42 ++-- ...gEntryFlowShell.agent.interaction.test.tsx | 216 ++++++++++++++++++ 4 files changed, 255 insertions(+), 15 deletions(-) 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({ From 8e1a62d1305bb2691e69a8c8da859e17b6ef6a1e Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Fri, 5 Jun 2026 23:21:55 +0800 Subject: [PATCH 2/2] fix: decouple puzzle draft generation lifecycle --- ...å‘è¿ç»´ã€‘本地开å‘验è¯ä¸Žç”Ÿäº§è¿ç»´-2026-05-15.md | 2 + server-rs/crates/api-server/src/puzzle.rs | 70 ++++++++- .../crates/api-server/src/puzzle/draft.rs | 11 +- .../crates/api-server/src/puzzle/handlers.rs | 137 +++++++++++++++--- .../crates/api-server/src/puzzle/tests.rs | 35 +++++ .../PlatformEntryFlowShellImpl.tsx | 63 +++++++- .../puzzleDraftGenerationState.test.ts | 40 +++++ .../puzzleDraftGenerationState.ts | 20 +++ 8 files changed, 350 insertions(+), 28 deletions(-) create mode 100644 src/components/platform-entry/puzzleDraftGenerationState.test.ts create mode 100644 src/components/platform-entry/puzzleDraftGenerationState.ts diff --git a/docs/ã€å¼€å‘è¿ç»´ã€‘本地开å‘验è¯ä¸Žç”Ÿäº§è¿ç»´-2026-05-15.md b/docs/ã€å¼€å‘è¿ç»´ã€‘本地开å‘验è¯ä¸Žç”Ÿäº§è¿ç»´-2026-05-15.md index e2e05bd8..05ed0fe5 100644 --- a/docs/ã€å¼€å‘è¿ç»´ã€‘本地开å‘验è¯ä¸Žç”Ÿäº§è¿ç»´-2026-05-15.md +++ b/docs/ã€å¼€å‘è¿ç»´ã€‘本地开å‘验è¯ä¸Žç”Ÿäº§è¿ç»´-2026-05-15.md @@ -71,6 +71,8 @@ spacetime sql "SELECT * FROM puzzle_gallery_card_view LIMIT 1" --serv VectorEngine å›¾ç‰‡ç”Ÿæˆ / 编辑在 `request_send` 阶段出现 `timeout` 或 `connect` 错误时,`platform-image` 会对åŒä¸€è¯·æ±‚最多å‘é€ 3 次;multipart å›¾ç‰‡ç¼–è¾‘æ¯æ¬¡é‡è¯•éƒ½ä¼šé‡æ–°æž„造 form,é¿å…å¤ç”¨å·²æ¶ˆè´¹çš„ body。日志中 `VectorEngine 图片请求å‘é€å¤±è´¥ï¼Œå‡†å¤‡é‡è¯•` 表示本次失败已进入下一次å°è¯•;最终ä»å¤±è´¥æ—¶æ‰ä¼šå†™å…¥ `external_api_call_failure` 并返回 504ã€‚æŽ’æŸ¥ç”Ÿäº§å¤±è´¥æ—¶åº”åŒæ—¶ç»Ÿè®¡ retry å‰çš„å°è¯•日志和最终 audit,é¿å…把一次用户请求内的多次å‘é€è¯¯åˆ¤æˆå¤šä¸ªç”¨æˆ·è¯·æ±‚。 +拼图入å£ç›´åˆ›çš„ `compile_puzzle_draft` 是长耗时链路:åŽç«¯ä¼šå…ˆå¿«é€Ÿç¼–译è‰ç¨¿å¹¶è¿”回 `image_refining` / `generating` 快照,然åŽåœ¨ api-server åŽå°ä»»åŠ¡ä¸­å®Œæˆé¦–图ã€UI 资产ã€OSS æŒä¹…化ã€ä½œå“投影ã€è®¡è´¹é€€æ¬¾å’Œå¤±è´¥æ€å›žå†™ã€‚生产排查å°ç¨‹åº `Failed to fetch` 时,若 Nginx access log 里 action POST 是 `499`ã€`upstream_status=-`,说明客户端或 WebView 先断开;此时ä¸åº”å†æŠŠé•¿ POST 是å¦è¿”å›žä½œä¸ºç”Ÿæˆæˆè´¥ä¾æ®ï¼Œè€Œåº”继续按实际 `session_id` 查åŽå°ä»»åŠ¡æ—¥å¿—ã€VectorEngine provider 日志ã€`external_api_call_failure` å’ŒåŽç»­ GET 轮询结果。åŒä¸€ç”¨æˆ·å¯èƒ½å…ˆè½®è¯¢æ—§çš„ `puzzle-session-*`,éšåŽ POST æ–°å»ºå®žé™…ç”Ÿæˆ session;必须用 action POST çš„ `request_id` å’Œ `/api/runtime/puzzle/agent/sessions//actions` 路径对é½çœŸå®žå¤±è´¥è¯·æ±‚,é¿å…被å‰ç«¯æ˜¾ç¤ºçš„â€œæ¥æºè‰ç¨¿â€è¯¯å¯¼ã€‚ + 查看本地 Rust / SpacetimeDB 日志: ```bash diff --git a/server-rs/crates/api-server/src/puzzle.rs b/server-rs/crates/api-server/src/puzzle.rs index b4fe7b41..be4b8cb0 100644 --- a/server-rs/crates/api-server/src/puzzle.rs +++ b/server-rs/crates/api-server/src/puzzle.rs @@ -1,5 +1,6 @@ use std::{ - collections::BTreeMap, + collections::{BTreeMap, HashSet}, + sync::{Mutex, OnceLock}, time::{Instant, SystemTime, UNIX_EPOCH}, }; @@ -130,6 +131,73 @@ const PUZZLE_UI_BACKGROUND_PROMPT_FALLBACK_MARKER: &str = const PUZZLE_VECTOR_ENGINE_SQUARE_IMAGE_SIZE: &str = "1024x1024"; const PUZZLE_VECTOR_ENGINE_PORTRAIT_IMAGE_SIZE: &str = "1024x1536"; +static PUZZLE_BACKGROUND_COMPILE_TASKS: OnceLock>> = OnceLock::new(); + +fn puzzle_background_compile_tasks() -> &'static Mutex> { + PUZZLE_BACKGROUND_COMPILE_TASKS.get_or_init(|| Mutex::new(HashSet::new())) +} + +fn try_register_puzzle_background_compile_task(session_id: &str) -> bool { + match puzzle_background_compile_tasks().lock() { + Ok(mut tasks) => tasks.insert(session_id.to_string()), + Err(error) => { + tracing::warn!( + provider = PUZZLE_AGENT_API_BASE_PROVIDER, + session_id, + error = %error, + "拼图åŽå°ç”Ÿæˆä»»åŠ¡æ³¨å†Œè¡¨é”å·²æŸå,å…许本次任务继续" + ); + true + } + } +} + +fn unregister_puzzle_background_compile_task(session_id: &str) { + match puzzle_background_compile_tasks().lock() { + Ok(mut tasks) => { + tasks.remove(session_id); + } + Err(error) => { + tracing::warn!( + provider = PUZZLE_AGENT_API_BASE_PROVIDER, + session_id, + error = %error, + "拼图åŽå°ç”Ÿæˆä»»åŠ¡æ³¨å†Œè¡¨è§£é”失败,忽略清ç†" + ); + } + } +} + +fn has_puzzle_cover_image_src(value: &Option) -> bool { + value + .as_deref() + .map(str::trim) + .is_some_and(|value| !value.is_empty()) +} + +fn mark_puzzle_initial_generation_started_snapshot( + mut session: PuzzleAgentSessionRecord, +) -> PuzzleAgentSessionRecord { + session.stage = "image_refining".to_string(); + session.progress_percent = session.progress_percent.max(88); + if let Some(draft) = session.draft.as_mut() { + let draft_needs_cover = !has_puzzle_cover_image_src(&draft.cover_image_src); + if let Some(primary_level) = draft.levels.first_mut() { + if !has_puzzle_cover_image_src(&primary_level.cover_image_src) { + primary_level.generation_status = "generating".to_string(); + } + draft.generation_status = primary_level.generation_status.clone(); + draft.candidates = primary_level.candidates.clone(); + draft.selected_candidate_id = primary_level.selected_candidate_id.clone(); + draft.cover_image_src = primary_level.cover_image_src.clone(); + draft.cover_asset_id = primary_level.cover_asset_id.clone(); + } else if draft_needs_cover { + draft.generation_status = "generating".to_string(); + } + } + session +} + pub(crate) fn format_puzzle_reference_image_upload_bytes(bytes: usize) -> String { format!("{:.1}MB", bytes as f64 / 1024.0 / 1024.0) } diff --git a/server-rs/crates/api-server/src/puzzle/draft.rs b/server-rs/crates/api-server/src/puzzle/draft.rs index 276a29f5..43bc146d 100644 --- a/server-rs/crates/api-server/src/puzzle/draft.rs +++ b/server-rs/crates/api-server/src/puzzle/draft.rs @@ -1177,21 +1177,16 @@ pub(crate) fn find_puzzle_level_for_initial_asset_check<'a>( .or_else(|| levels.first()) } -pub(crate) async fn compile_puzzle_draft_with_initial_cover( +pub(crate) async fn generate_puzzle_initial_cover_from_compiled_session( state: &PuzzleApiState, request_context: &RequestContext, - session_id: String, + compiled_session: PuzzleAgentSessionRecord, owner_user_id: String, prompt_text: Option<&str>, reference_image_src: Option<&str>, image_model: Option<&str>, now: i64, ) -> Result { - let compiled_session = state - .spacetime_client() - .compile_puzzle_agent_draft(session_id.clone(), owner_user_id.clone(), now) - .await - .map_err(map_puzzle_compile_error)?; let draft = compiled_session.draft.clone().ok_or_else(|| { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": PUZZLE_AGENT_API_BASE_PROVIDER, @@ -1419,7 +1414,7 @@ pub(crate) async fn compile_puzzle_draft_with_initial_cover( match state .spacetime_client() .select_puzzle_cover_image(PuzzleSelectCoverImageRecordInput { - session_id, + session_id: compiled_session.session_id.clone(), owner_user_id, level_id: Some(target_level.level_id), candidate_id: selected_candidate_id, diff --git a/server-rs/crates/api-server/src/puzzle/handlers.rs b/server-rs/crates/api-server/src/puzzle/handlers.rs index afd6f3cf..873495f7 100644 --- a/server-rs/crates/api-server/src/puzzle/handlers.rs +++ b/server-rs/crates/api-server/src/puzzle/handlers.rs @@ -623,7 +623,7 @@ pub async fn execute_puzzle_agent_action( session_id, owner_user_id, error_message, - failed_at_micros: now, + failed_at_micros: current_utc_micros(), }) .await; if let Err(error) = result { @@ -668,27 +668,128 @@ pub async fn execute_puzzle_agent_action( Err(response) => return Err(response), }; let session = if ai_redraw { - execute_billable_asset_operation_with_cost( - state.root_state(), - &owner_user_id, - "puzzle_initial_image", - &billing_asset_id, - PUZZLE_IMAGE_GENERATION_POINTS_COST, - async { - compile_puzzle_draft_with_initial_cover( - &state, - &request_context, + if !try_register_puzzle_background_compile_task(&compile_session_id) { + tracing::info!( + provider = PUZZLE_AGENT_API_BASE_PROVIDER, + session_id = %compile_session_id, + owner_user_id = %owner_user_id, + "拼图首图åŽå°ç”Ÿæˆä»»åŠ¡å·²å­˜åœ¨ï¼Œæœ¬æ¬¡ action 直接返回生æˆä¸­ä¼šè¯" + ); + state + .spacetime_client() + .get_puzzle_agent_session( + compile_session_id.clone(), + owner_user_id.clone(), + ) + .await + .map(mark_puzzle_initial_generation_started_snapshot) + .map_err(map_puzzle_client_error) + } else { + let compiled_session = state + .spacetime_client() + .compile_puzzle_agent_draft( compile_session_id.clone(), owner_user_id.clone(), - prompt_text, - primary_reference_image_src, - payload.image_model.as_deref(), now, ) .await - }, - ) - .await + .map_err(map_puzzle_compile_error); + match compiled_session { + Ok(compiled_session) => { + let response_session = + mark_puzzle_initial_generation_started_snapshot( + compiled_session.clone(), + ); + let background_state = state.clone(); + let background_request_context = request_context.clone(); + let background_session_id = compile_session_id.clone(); + let background_owner_user_id = owner_user_id.clone(); + let background_prompt_text = prompt_text.map(str::to_string); + let background_reference_image_src = + primary_reference_image_src.map(str::to_string); + let background_image_model = payload.image_model.clone(); + let background_billing_asset_id = + format!("{background_session_id}:compile_puzzle_draft"); + tokio::spawn(async move { + let operation_owner_user_id = + background_owner_user_id.clone(); + let background_root_state = + background_state.root_state().clone(); + let operation_state = background_state.clone(); + let result = execute_billable_asset_operation_with_cost( + &background_root_state, + &background_owner_user_id, + "puzzle_initial_image", + &background_billing_asset_id, + PUZZLE_IMAGE_GENERATION_POINTS_COST, + async move { + generate_puzzle_initial_cover_from_compiled_session( + &operation_state, + &background_request_context, + compiled_session, + operation_owner_user_id, + background_prompt_text.as_deref(), + background_reference_image_src.as_deref(), + background_image_model.as_deref(), + current_utc_micros(), + ) + .await + }, + ) + .await; + match result { + Ok(session) => { + tracing::info!( + provider = PUZZLE_AGENT_API_BASE_PROVIDER, + session_id = %session.session_id, + owner_user_id = %background_owner_user_id, + "拼图首图åŽå°ç”Ÿæˆä»»åŠ¡å®Œæˆ" + ); + } + Err(error) => { + let error_message = error.body_text(); + let failure_result = background_state + .spacetime_client() + .mark_puzzle_draft_generation_failed( + PuzzleDraftCompileFailureRecordInput { + session_id: background_session_id.clone(), + owner_user_id: background_owner_user_id + .clone(), + error_message: error_message.clone(), + failed_at_micros: current_utc_micros(), + }, + ) + .await; + if let Err(mark_error) = failure_result { + tracing::warn!( + provider = PUZZLE_AGENT_API_BASE_PROVIDER, + session_id = %background_session_id, + owner_user_id = %background_owner_user_id, + message = %mark_error, + "拼图首图åŽå°ç”Ÿæˆå¤±è´¥æ€å›žå†™å¤±è´¥" + ); + } + tracing::warn!( + provider = PUZZLE_AGENT_API_BASE_PROVIDER, + session_id = %background_session_id, + owner_user_id = %background_owner_user_id, + message = %error_message, + "拼图首图åŽå°ç”Ÿæˆä»»åŠ¡å¤±è´¥" + ); + } + } + unregister_puzzle_background_compile_task( + &background_session_id, + ); + }); + Ok(response_session) + } + Err(error) => { + unregister_puzzle_background_compile_task(&compile_session_id); + Err(error) + } + } + } } else { compile_puzzle_draft_with_uploaded_cover( &state, @@ -716,7 +817,7 @@ pub async fn execute_puzzle_agent_action( "compile_puzzle_draft", "首关拼图è‰ç¨¿", if ai_redraw { - "已编译首关è‰ç¨¿ã€å¹¶è¡Œç”Ÿæˆé¦–关画é¢å’Œ UI 背景并写入正å¼è‰ç¨¿ã€‚" + "已编译首关è‰ç¨¿ï¼Œå¹¶å¯åŠ¨é¦–å…³ç”»é¢å’Œ UI 资产åŽå°ç”Ÿæˆã€‚" } else { "已编译首关è‰ç¨¿ï¼Œå¹¶ç›´æŽ¥åº”用上传图片ã€ç”Ÿæˆ UI 背景为第一关图片。" }, diff --git a/server-rs/crates/api-server/src/puzzle/tests.rs b/server-rs/crates/api-server/src/puzzle/tests.rs index 86512e7d..b5b902b9 100644 --- a/server-rs/crates/api-server/src/puzzle/tests.rs +++ b/server-rs/crates/api-server/src/puzzle/tests.rs @@ -980,6 +980,41 @@ fn puzzle_work_summary_response_keeps_levels_for_shelf_cover() { ); } +#[test] +fn puzzle_compile_started_snapshot_marks_primary_level_generating() { + let mut session = PuzzleAgentSessionRecord { + session_id: "puzzle-session-1".to_string(), + seed_text: "ç”»é¢æè¿°ï¼šä¸€åªçŒ«åœ¨é›¨å¤œç¯ç‰Œä¸‹å›žå¤´ã€‚".to_string(), + current_turn: 1, + progress_percent: 88, + stage: "draft_ready".to_string(), + anchor_pack: test_puzzle_anchor_pack_record(), + draft: Some(test_puzzle_draft_record()), + messages: Vec::new(), + last_assistant_reply: None, + published_profile_id: None, + suggested_actions: Vec::new(), + result_preview: None, + updated_at: "2024-01-01T00:00:00Z".to_string(), + }; + { + let draft = session.draft.as_mut().expect("draft"); + draft.generation_status = "idle".to_string(); + draft.levels[0].generation_status = "idle".to_string(); + draft.levels[0].cover_image_src = None; + draft.levels[0].cover_asset_id = None; + } + + let session = mark_puzzle_initial_generation_started_snapshot(session); + let draft = session.draft.expect("draft"); + + assert_eq!(session.stage, "image_refining"); + assert_eq!(draft.generation_status, "generating"); + assert_eq!(draft.levels[0].generation_status, "generating"); + assert!(draft.cover_image_src.is_none()); + assert!(draft.levels[0].cover_image_src.is_none()); +} + #[test] fn puzzle_ui_background_prompt_keeps_generated_slots_out_of_background() { let prompt = build_puzzle_ui_background_request_prompt_for_test("雨夜猫街", "雨夜猫街主题背景"); diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 86e77d3c..3c789f71 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -67,6 +67,7 @@ import type { PuzzleAgentSessionSnapshot, SendPuzzleAgentMessageRequest, } from '../../../packages/shared/src/contracts/puzzleAgentSession'; +import { isPuzzleCompileActionReady } from './puzzleDraftGenerationState'; import type { PuzzleCreativeTemplateSelection } from '../../../packages/shared/src/contracts/puzzleCreativeTemplate'; import type { PuzzleRunSnapshot, @@ -6251,7 +6252,7 @@ export function PlatformEntryFlowShellImpl({ sessionController.setCreationTypeError(errorMessage); setPuzzleCreationError(errorMessage); }, - onActionComplete: async ({ payload, response, setSession }) => { + onActionComplete: async ({ payload, response, session, setSession }) => { setPuzzleOperation(response.operation); setSession(response.session); const formPayload = buildPuzzleFormPayloadFromAction(payload); @@ -6275,6 +6276,47 @@ export function PlatformEntryFlowShellImpl({ if (payload.action === 'compile_puzzle_draft') { const openResult = selectionStageRef.current === 'puzzle-generating'; + if (!isPuzzleCompileActionReady(response.session)) { + const nextPayload = + formPayload ?? buildPuzzleFormPayloadFromSession(response.session); + const fallbackGenerationState = createPuzzleDraftGenerationStateFromPayload( + nextPayload, + response.session, + ); + const nextGenerationState = mergePuzzleSessionProgressIntoGenerationState( + puzzleGenerationState ?? fallbackGenerationState, + response.session, + ); + activePuzzleGenerationSessionIdRef.current = response.session.sessionId; + setSelectionStage('puzzle-generating'); + markDraftGenerating('puzzle', [ + response.session.sessionId, + buildPuzzleResultWorkId(response.session.sessionId), + response.session.publishedProfileId, + buildPuzzleResultProfileId(response.session.sessionId), + ]); + markPendingDraftGenerating( + 'puzzle', + response.session.sessionId, + buildPendingPuzzleDraftMetadata(nextPayload), + ); + setPuzzleGenerationState(nextGenerationState); + setPuzzleBackgroundCompileTasks((current) => { + const next = { ...current }; + if (session.sessionId !== response.session.sessionId) { + delete next[session.sessionId]; + } + next[response.session.sessionId] = { + session: response.session, + payload: nextPayload, + generationState: nextGenerationState, + error: null, + }; + return next; + }); + void refreshPuzzleShelf(); + return { openResult: false }; + } setPuzzleGenerationState((current) => current ? resolveFinishedMiniGameDraftGenerationState(current, 'ready', { @@ -7169,6 +7211,22 @@ export function PlatformEntryFlowShellImpl({ return; } + if (hasRecoverableGeneratedPuzzleDraft(latestSession)) { + const payload = + puzzleGenerationViewPayload ?? + buildPuzzleFormPayloadFromSession(latestSession); + const generationState = + puzzleGenerationViewState ?? + createPuzzleDraftGenerationStateFromPayload(payload, latestSession); + await recoverCompletedPuzzleDraftGeneration({ + sessionId: latestSession.sessionId, + payload, + generationState, + setSession: setPuzzleSession, + }); + return; + } + setPuzzleSession(latestSession); setPuzzleBackgroundCompileTasks((current) => { const task = current[activePuzzleGenerationSessionId]; @@ -7212,6 +7270,9 @@ export function PlatformEntryFlowShellImpl({ }; }, [ activePuzzleGenerationSessionId, + puzzleGenerationViewPayload, + puzzleGenerationViewState, + recoverCompletedPuzzleDraftGeneration, shouldPollPuzzleGenerationSession, setPuzzleSession, ]); diff --git a/src/components/platform-entry/puzzleDraftGenerationState.test.ts b/src/components/platform-entry/puzzleDraftGenerationState.test.ts new file mode 100644 index 00000000..dd4a955c --- /dev/null +++ b/src/components/platform-entry/puzzleDraftGenerationState.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; + +import { isPuzzleCompileActionReady } from './puzzleDraftGenerationState'; +import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession'; + +describe('isPuzzleCompileActionReady', () => { + it('keeps compile action generating until the draft has a cover image', () => { + const session = { + sessionId: 'puzzle-session-1', + draft: { + coverImageSrc: null, + levels: [ + { + generationStatus: 'generating', + coverImageSrc: null, + }, + ], + }, + } as PuzzleAgentSessionSnapshot; + + expect(isPuzzleCompileActionReady(session)).toBe(false); + }); + + it('treats compile action as ready after the selected cover exists', () => { + const session = { + sessionId: 'puzzle-session-1', + draft: { + coverImageSrc: '/generated-puzzle-assets/session/cover.png', + levels: [ + { + generationStatus: 'ready', + coverImageSrc: '/generated-puzzle-assets/session/cover.png', + }, + ], + }, + } as PuzzleAgentSessionSnapshot; + + expect(isPuzzleCompileActionReady(session)).toBe(true); + }); +}); diff --git a/src/components/platform-entry/puzzleDraftGenerationState.ts b/src/components/platform-entry/puzzleDraftGenerationState.ts new file mode 100644 index 00000000..f00ee282 --- /dev/null +++ b/src/components/platform-entry/puzzleDraftGenerationState.ts @@ -0,0 +1,20 @@ +import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession'; + +function hasText(value: string | null | undefined) { + return typeof value === 'string' && value.trim().length > 0; +} + +export function isPuzzleCompileActionReady( + session: PuzzleAgentSessionSnapshot, +) { + const draft = session.draft; + if (!draft) { + return false; + } + if (hasText(draft.coverImageSrc)) { + return true; + } + return ( + draft.levels?.some((level) => hasText(level.coverImageSrc)) === true + ); +}