From 7b37271f172a5334fb2b4d7131f4391bd1dc012f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E7=89=A9?= <253518756@qq.com> Date: Tue, 19 May 2026 10:02:13 +0800 Subject: [PATCH] Puzzle: support history images & partial generation Allow history-generated image paths to be submitted where Data URLs were previously required and avoid treating partial/result-page generations as blocking the whole draft. Backend: resolve history /generated-* references via resolve_puzzle_reference_image_as_data_url and convert to PuzzleDownloadedImage; add PuzzleDownloadedImage::from_resolved_reference_image; extend draft handling to apply generated level metadata (auto-naming) and normalize generation_status to treat levels with images as ready. API: add shouldAutoNameLevel to action contracts and use it to request/refine generated level names. Spacetime/module and mappers: normalize completed level statuses when saving/reading so result-page background or per-level generation doesn't mask completed drafts. Frontend: expose resolver helpers, only mark a work as generating when no usable cover or ready level exists, keep level controls enabled during UI-background regeneration, and add tests covering history-image submission, auto-naming, and UI-background/partial-generation behaviors. --- .hermes/shared-memory/pitfalls.md | 16 +++ ...玩法创作】平å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md | 5 +- .../src/contracts/puzzleAgentActions.ts | 1 + .../crates/api-server/src/puzzle/draft.rs | 67 ++++++++-- .../crates/api-server/src/puzzle/handlers.rs | 53 ++++++-- .../crates/api-server/src/puzzle/mappers.rs | 66 ++++++++-- .../crates/api-server/src/puzzle/tests.rs | 20 ++- .../api-server/src/puzzle/vector_engine.rs | 10 ++ .../shared-contracts/src/puzzle_agent.rs | 2 + .../crates/spacetime-module/src/puzzle.rs | 66 +++++++++- .../CustomWorldCreationHub.test.tsx | 75 +++++++++++ .../custom-world-home/creationWorkShelf.ts | 75 +++++++---- .../PlatformEntryFlowShellImpl.tsx | 25 +++- .../PuzzleAgentWorkspace.interaction.test.tsx | 54 ++++++++ .../puzzle-result/PuzzleResultView.test.tsx | 123 ++++++++++++++++++ .../puzzle-result/PuzzleResultView.tsx | 68 +++++++++- 16 files changed, 653 insertions(+), 73 deletions(-) diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index c62d83a2..6290df2f 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -936,6 +936,22 @@ - 验è¯ï¼š`npm run test -- src/components/puzzle-agent/PuzzleAgentWorkspace.interaction.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx`,并执行 `npm run check:encoding`。 - å…³è”:`src/services/puzzle-works/puzzleHistoryAsset.ts`ã€`src/components/puzzle-agent/PuzzleHistoryAssetPickerDialog.tsx`ã€`docs/technical/ASSET_HISTORY_PUZZLE_COVER_KIND_FIX_2026-04-27.md`。 +## 拼图历å²å›¾å…³é—­ AI é‡ç»˜ä¸è¦å¼ºåˆ¶ Data URL + +- 现象:拼图创作页从历å²ç”Ÿæˆå›¾ç‰‡ä¸­é€‰æ‹©ä¸»å›¾ï¼Œå†å…³é—­ AI é‡ç»˜ç”Ÿæˆè‰ç¨¿æ—¶ï¼ŒåŽç«¯æŠ¥â€œä¸Šä¼ å›¾å¿…须是图片 Data URLâ€ã€‚ +- 原因:历å²å›¾ `imageSrc` 是 `/generated-puzzle-assets/...` ç§æœ‰å…¼å®¹è·¯å¾„ï¼›AI é‡ç»˜å¼€å¯æ—¶åŽç«¯å‚考图分支会解æžè¯¥è·¯å¾„,但关闭 AI é‡ç»˜çš„“直用上传图â€åˆ†æ”¯æ—§å®žçްåªè°ƒç”¨ `parse_puzzle_image_data_url`。 +- 处ç†ï¼šå…³é—­ AI é‡ç»˜æ—¶ä¹Ÿå¤ç”¨æ‹¼å›¾å‚考图解æžå…¥å£ï¼Œå…许 Data URL 与 `/generated-*` 历å²è·¯å¾„ç»Ÿä¸€è½¬æˆ `PuzzleDownloadedImage` åŽæŒä¹…化;å‰ç«¯ä¸éœ€è¦ä¸‹è½½åކå²å›¾å†è½¬ base64。 +- 验è¯ï¼š`npm run test -- src/components/puzzle-agent/PuzzleAgentWorkspace.interaction.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx`ã€`cargo test -p api-server puzzle_uploaded_cover_can_reuse_resolved_history_image --manifest-path server-rs\Cargo.toml`ã€`npm run dev:api-server` åŽæ£€æŸ¥ `/healthz`。 +- å…³è”:`server-rs/crates/api-server/src/puzzle/draft.rs`ã€`server-rs/crates/api-server/src/puzzle/vector_engine.rs`ã€`src/components/puzzle-agent/PuzzleAgentWorkspace.interaction.test.tsx`。 + +## 拼图结果页局部生图ä¸è¦æ±¡æŸ“è‰ç¨¿ç”Ÿæˆæ€ + +- 现象:拼图è‰ç¨¿å·²ç»ç”Ÿæˆå®ŒæˆåŽï¼Œåœ¨ç»“æžœé¡µé‡æ–°ç”Ÿæˆ UI 背景或追加关å¡ç”Ÿæˆå›¾ç‰‡ï¼Œè‰ç¨¿é¡µä»æ˜¾ç¤ºæ•´å¡â€œç”Ÿæˆä¸­â€ï¼Œç‚¹å‡»è‰ç¨¿ä¼šå›žåˆ°ç”Ÿæˆè¿‡ç¨‹é¡µï¼Œæ— æ³•查看已有结果;UI 背景生æˆä¸­è¿˜ä¼šç¦ç”¨â€œæ–°å¢žå…³å¡â€å’Œå…³å¡å›¾ç”Ÿæˆã€‚ +- 原因:结果页局部 action å¤ç”¨äº†å…¨å±€ `isPuzzleBusy` / æŒä¹…化 `generationStatus=generating` è¯­ä¹‰ï¼Œä½œå“æž¶æ²¡æœ‰åŒºåˆ†â€œåˆå§‹è‰ç¨¿ä¸å¯æŸ¥çœ‹â€å’Œâ€œå·²æœ‰ç»“果上的局部关å¡ç”Ÿæˆâ€ã€‚ +- 处ç†ï¼šä½œå“æž¶åªåœ¨æ‹¼å›¾æ²¡æœ‰å¯ç”¨å°é¢ã€é¦–å…³å€™é€‰å›¾æˆ–ä»»ä¸€å¯æŸ¥çœ‹å…³å¡æ—¶æ‰æŠŠ `generationStatus=generating` 解释为åˆå§‹è‰ç¨¿ç”Ÿæˆï¼›ç»“果页 UI 背景和关å¡å›¾èµ° background action,ä¸è®¾ç½®å…¨å±€ busy,UI 背景åªç¦ç”¨è‡ªå·±çš„æŒ‰é’®ï¼›SpacetimeDB/API mapper 读写时把已有图片但状æ€ä»æ˜¯ `generating` 的历å²å…³å¡å½’一为 `ready`。 +- 验è¯ï¼š`npm run test -- src/components/custom-world-home/CustomWorldCreationHub.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx`ã€`cargo test -p api-server puzzle --manifest-path server-rs\Cargo.toml`。 +- å…³è”:`src/components/custom-world-home/creationWorkShelf.ts`ã€`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`ã€`src/components/puzzle-result/PuzzleResultView.tsx`ã€`server-rs/crates/api-server/src/puzzle/mappers.rs`ã€`server-rs/crates/spacetime-module/src/puzzle.rs`。 + ## Jenkins æ•°æ®åº“导入导出脚本先补 Node 工具链 PATH - 现象:`Genarrative-Database-Import` 或 `Genarrative-Database-Export` è¿è¡Œåˆ°è¿ç§»è„šæœ¬æ—¶ï¼Œ`bash` 报 `node: command not found`,常è§åœ¨æ—¥å¿—里表现为æŸä¸ª `sh` å—内第 61 行直接调用 `node` 失败。 diff --git a/docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md b/docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md index c82e2a6b..44b8d7ce 100644 --- a/docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md +++ b/docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md @@ -31,13 +31,16 @@ 当å‰å£å¾„: - 图åƒè¾“å…¥å¤ç”¨ `CreativeImageInputPanel`。 -- 支æŒç”»é¢æè¿°ç”Ÿå›¾ã€å¤šå‚考图生图ã€ä¸Šä¼ ä¸»å›¾åŽ AI é‡ç»˜ã€ä¸Šä¼ ä¸»å›¾åŽä¸é‡ç»˜ã€‚ +- 支æŒç”»é¢æè¿°ç”Ÿå›¾ã€å¤šå‚考图生图ã€ä¸Šä¼ æˆ–历å²ç”Ÿæˆä¸»å›¾åŽ AI é‡ç»˜ã€ä¸Šä¼ æˆ–历å²ç”Ÿæˆä¸»å›¾åŽä¸é‡ç»˜ï¼›å…³é—­ AI é‡ç»˜æ—¶ï¼Œå‰ç«¯å¯æäº¤æœ¬åœ°ä¸Šä¼  Data URL æˆ–åŽ†å² `/generated-*` 图片路径,åŽç«¯ç»Ÿä¸€è§£æžä¸ºé¦–关正å¼å›¾åŽå†æŒä¹…化。 - è‰ç¨¿ç”Ÿæˆä¼šå…ˆæŒä¹…化 `generationStatus=generating` çš„ä½œå“æ‘˜è¦ï¼Œç”Ÿæˆå®Œæˆå¹¶å›žå†™å…³å¡å›¾ã€UI 背景åŽå†å˜ä¸º `ready`;首关关å¡å›¾å’Œ UI 背景在命å稳定åŽå¹¶è¡Œå¯åŠ¨ï¼Œå½“å‰ä¸è‡ªåŠ¨ç”ŸæˆèƒŒæ™¯éŸ³ä¹ã€‚ +- ä½œå“æž¶æ‹¼å›¾è‰ç¨¿çš„“生æˆä¸­â€é®ç½©åªè¡¨ç¤ºåˆå§‹è‰ç¨¿è¿˜æ²¡æœ‰å¯æŸ¥çœ‹ç»“果;åªè¦ä½œå“摘è¦ã€é¦–å…³å°é¢æˆ–任一关å¡å€™é€‰å›¾å·²ç»å¯ç”¨ï¼ŒåŽç»­ UI 背景é‡ç”Ÿæˆå’Œè¿½åŠ å…³å¡ç”Ÿå›¾éƒ½å¿…é¡»ä½œä¸ºç»“æžœé¡µå±€éƒ¨ç”Ÿæˆæ€å¤„ç†ï¼Œä¸èƒ½é˜»æ­¢æ‰“å¼€è‰ç¨¿ç»“果页。 - 拼图è‰ç¨¿ç¼–译是长耗时 action,å‰ç«¯ action 请求默认等待 `1_000_000ms` 且ä¸è‡ªåЍé‡è¯•,生æˆé¡µé¢„è®¡å®Œæˆæ—¶é—´æŒ‰ `5` 分钟展示;生æˆé¡µæ¢å¤æ—¶å¿…é¡»æ²¿ç”¨ä½œå“æ‘˜è¦ `updatedAt` 作为原始 `startedAtMs`,失败/å®Œæˆæ€ç”¨ `finishedAtMs` 冻结耗时,ä¸èƒ½åœ¨é”屿ˆ–返回è‰ç¨¿é¡µåŽé‡æ–°ä»Ž 0 计时。 - è‹¥æµè§ˆå™¨é”å±ã€æ¯å±æˆ–网络切æ¢å¯¼è‡´ compile 请求失败,å‰ç«¯åœ¨æ ‡è®°å¤±è´¥å‰å¿…须先å¤è¯» `getPuzzleAgentSession(sessionId)`ï¼›åªæœ‰æœ€æ–° session ä»ç¼º `draft.coverImageSrc`ã€é¦–å…³ `coverImageSrc` 或候选图时æ‰å±•示失败,å¤è¯»åˆ°å·²ç”Ÿæˆè‰ç¨¿æ—¶æŒ‰æˆåŠŸæ”¶å°¾ã€åˆ·æ–°ä½œå“架并继续自动试玩/结果页链路。 - 拼图å‚考图 AI é‡ç»˜ä¼˜å…ˆèµ° VectorEngine `/v1/images/edits`;若编辑接å£è¶…时,`api-server` 会é™çº§ä¸º `/v1/images/generations`,并把åŒä¸€å‚考图塞进 `image` 数组继续生æˆï¼Œé¿å…å‚考图è‰ç¨¿æ•´å•失败。 - 结果页素æé…置当å‰åªä¿ç•™ UI 相关能力;旧背景音ä¹å…¥å£éšè—。 - 结果页å…许多关å¡å¹¶è¡Œç¼–辑和生æˆï¼›æŸä¸€å…³å¡å›¾ç‰‡ç”Ÿæˆå®Œæˆå›žåŒ…åªé™é»˜æ›´æ–°è¯¥å…³å¡ç´ æä¸Žç”Ÿæˆæ€ï¼Œä¸å¾—自动打开或切æ¢å…³å¡è¯¦æƒ…颿¿ï¼Œé¿å…打断用户正在编辑的其它关å¡ã€‚ +- 结果页 UI 背景é‡ç”Ÿæˆåªç¦ç”¨ UI 背景自己的按钮和确认动作,ä¸ç¦ç”¨â€œæ–°å¢žå…³å¡â€ã€å…³å¡å›¾ç‰‡ç”Ÿæˆã€å…³å¡è¯¦æƒ…编辑和结果页导航;关å¡å›¾ç‰‡ç”Ÿæˆä¹Ÿåªæ ‡è®°å¯¹åº”å…³å¡çš„局部生æˆè¿›åº¦ã€‚ +- 结果页生æˆå…³å¡å›¾æ—¶è‹¥å…³å¡å为空,å‰ç«¯å¿…须传 `shouldAutoNameLevel=true`,åŽç«¯å¤ç”¨é¦–关命åå¥‘çº¦å…ˆæŒ‰ç”»é¢æè¿°ç”Ÿæˆå…³å¡å,å†åœ¨å›¾ç‰‡ç”ŸæˆåŽç”¨è§†è§‰å‘½å结果精修,并把生æˆåå’Œ UI 背景æç¤ºè¯éšæœ¬æ¬¡å…³å¡å¿«ç…§å†™å›žã€‚ - 拼图 UI 背景是作å“è¿è¡Œæ€èƒŒæ™¯ï¼Œä¸åªå±žäºŽç¬¬ä¸€å…³ï¼›æœ¬åœ°è¯•玩ã€ç›´è¾¾æŒ‡å®šå…³å¡å’Œæ­£å¼ `next-level` 推进时,目标关å¡ç¼º `uiBackgroundImageSrc/uiBackgroundImageObjectKey` 必须继承åŒä½œå“首个å¯ç”¨ UI 背景,ä»ç¼ºå¤±æ—¶æ‰æ²¿ç”¨å½“å‰è¿è¡Œæ€å¿«ç…§èƒŒæ™¯æˆ–默认 UI。 - 拼图è¿è¡Œæ€æ£‹ç›˜ä¸å åŠ åˆ†å—è’™ç‰ˆã€æè¾¹ã€é˜´å½±ã€é€‰ä¸­åº•色或åˆå¹¶å— SVG 轮廓;拼图片本体需è¦è£åˆ‡ä¸ºåœ†è§’形状,å•å—使用独立圆角è£åˆ‡ï¼Œåˆå¹¶å—使用 SVG 原生 `clipPath` è£åˆ‡æ•´ä½“外轮廓,外凸角和内凹角分别计算åŠå¾„,内凹角åŠå¾„è¦æ¯”外凸角更明显以é¿å…手机 WebView 中看起æ¥ä»æ˜¯ç›´è§’。原图é“å…·åªåœ¨ç”¨æˆ·ä¸»åŠ¨ç¡®è®¤åŽæ‰“开独立原图查看层,ä¸åœ¨å½“剿‹¼å›¾æ£‹ç›˜ä¸Šå åŠ åŽŸå›¾ã€‚ - 拼图è¿è¡Œæ€æ‹–æ‹½å¿…é¡»å®Œå…¨è·Ÿéšæ‰‹æŒ‡æˆ–é¼ æ ‡ä½ç½®ï¼Œ`pointermove` æœŸé—´å³æ—¶å†™å…¥å¯è§æ‹¼å—çš„ transform,ä¸ä¾èµ–等待åŽç«¯å›žåŒ…ã€React 釿¸²æŸ“或下一帧动画队列;进入拖动åŽä¸å±•示拼å—é€‰ä¸­æ€æˆ–â€œå·²é€‰æ‹©â€æç¤ºï¼Œæ¾æ‰‹åŽå†æäº¤ç›®æ ‡æ ¼åŒæ­¥è§„则真相。 diff --git a/packages/shared/src/contracts/puzzleAgentActions.ts b/packages/shared/src/contracts/puzzleAgentActions.ts index 594d66ca..9e6d2cb3 100644 --- a/packages/shared/src/contracts/puzzleAgentActions.ts +++ b/packages/shared/src/contracts/puzzleAgentActions.ts @@ -76,6 +76,7 @@ export type PuzzleAgentActionRequest = imageModel?: string | null; aiRedraw?: boolean; candidateCount?: number; + shouldAutoNameLevel?: boolean; workTitle?: string; workDescription?: string; summary?: string; diff --git a/server-rs/crates/api-server/src/puzzle/draft.rs b/server-rs/crates/api-server/src/puzzle/draft.rs index 4a445782..d301c0b5 100644 --- a/server-rs/crates/api-server/src/puzzle/draft.rs +++ b/server-rs/crates/api-server/src/puzzle/draft.rs @@ -1415,13 +1415,22 @@ pub(crate) async fn compile_puzzle_draft_with_uploaded_cover( "message": "关闭 AI é‡ç»˜æ—¶å¿…须上传拼图图片。", })) })?; - let uploaded_image = parse_puzzle_image_data_url(uploaded_image_src).ok_or_else(|| { - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": PUZZLE_AGENT_API_BASE_PROVIDER, - "field": "referenceImageSrc", - "message": "关闭 AI é‡ç»˜æ—¶ä¸Šä¼ å›¾å¿…须是图片 Data URL。", - })) - })?; + let http_client = reqwest::Client::new(); + let uploaded_downloaded_image = + resolve_puzzle_reference_image_as_data_url(state, &http_client, uploaded_image_src) + .await + .map(PuzzleDownloadedImage::from_resolved_reference_image) + .map_err(|error| { + if error.status_code() == StatusCode::BAD_REQUEST { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "field": "referenceImageSrc", + "message": "关闭 AI é‡ç»˜æ—¶ä¸Šä¼ å›¾å¿…须是图片 Data URL 或历å²ç”Ÿæˆå›¾ç‰‡è·¯å¾„。", + })) + } else { + error + } + })?; let compiled_session = state .spacetime_client() .compile_puzzle_agent_draft(session_id.clone(), owner_user_id.clone(), now) @@ -1446,11 +1455,6 @@ pub(crate) async fn compile_puzzle_draft_with_uploaded_cover( compiled_session.session_id, target_level.candidates.len() + 1 ); - let uploaded_downloaded_image = PuzzleDownloadedImage { - extension: puzzle_mime_to_extension(uploaded_image.mime_type.as_str()).to_string(), - mime_type: normalize_puzzle_downloaded_image_mime_type(uploaded_image.mime_type.as_str()), - bytes: uploaded_image.bytes, - }; let level_name_future = generate_puzzle_first_level_name(state, &target_level.picture_description); let image_level_name_future = generate_puzzle_first_level_name_from_image( @@ -1807,6 +1811,45 @@ pub(crate) fn apply_generated_puzzle_initial_metadata_to_session_snapshot( session } +pub(crate) fn apply_generated_puzzle_metadata_to_session_snapshot( + mut session: PuzzleAgentSessionRecord, + target_level_id: &str, + metadata: &PuzzleLevelNaming, + previous_level_name: &str, + updated_at_micros: i64, +) -> PuzzleAgentSessionRecord { + let Some(draft) = session.draft.as_mut() else { + return session; + }; + let Some(target_index) = draft + .levels + .iter() + .position(|level| level.level_id == target_level_id) + .or_else(|| (!draft.levels.is_empty()).then_some(0)) + else { + return session; + }; + + draft.levels[target_index].level_name = metadata.level_name.clone(); + if metadata.ui_background_prompt.is_some() { + draft.levels[target_index].ui_background_prompt = metadata.ui_background_prompt.clone(); + } + + if target_index == 0 { + apply_generated_puzzle_initial_metadata_to_draft( + draft, + metadata, + previous_level_name, + updated_at_micros, + ); + } else { + sync_puzzle_primary_draft_fields_from_level(draft); + } + + session.updated_at = format_timestamp_micros(updated_at_micros); + session +} + pub(crate) fn apply_generated_puzzle_initial_metadata_to_draft( draft: &mut PuzzleResultDraftRecord, metadata: &PuzzleLevelNaming, diff --git a/server-rs/crates/api-server/src/puzzle/handlers.rs b/server-rs/crates/api-server/src/puzzle/handlers.rs index 1aabf16a..a47c00b1 100644 --- a/server-rs/crates/api-server/src/puzzle/handlers.rs +++ b/server-rs/crates/api-server/src/puzzle/handlers.rs @@ -769,6 +769,21 @@ pub async fn execute_puzzle_agent_action( payload.prompt_text.as_deref(), &target_level.picture_description, ); + let should_auto_name_level = payload + .should_auto_name_level + .unwrap_or_else(|| target_level.level_name.trim().is_empty()); + let mut generated_naming = if should_auto_name_level { + let naming = generate_puzzle_first_level_name( + &state, + target_level.picture_description.as_str(), + ) + .await; + target_level.level_name = naming.level_name.clone(); + target_level.ui_background_prompt = naming.ui_background_prompt.clone(); + Some(naming) + } else { + None + }; let reference_image_sources = collect_puzzle_reference_image_sources( payload.reference_image_src.as_deref(), payload.reference_image_srcs.as_slice(), @@ -806,11 +821,14 @@ pub async fn execute_puzzle_agent_action( &candidates[0].downloaded_image, ) .await + .filter(|_| should_auto_name_level) { - target_level.level_name = refined_naming.level_name; + target_level.level_name = refined_naming.level_name.clone(); if refined_naming.ui_background_prompt.is_some() { - target_level.ui_background_prompt = refined_naming.ui_background_prompt; + target_level.ui_background_prompt = + refined_naming.ui_background_prompt.clone(); } + generated_naming = Some(refined_naming); } let generated_level_name = target_level.level_name.clone(); let levels_json_with_generated_name = @@ -859,19 +877,36 @@ pub async fn execute_puzzle_agent_action( ); let fallback_session = replace_puzzle_session_draft_snapshot(session, draft, now); - Ok(apply_generated_puzzle_candidates_to_session_snapshot( + let fallback_session = if should_auto_name_level { apply_generated_puzzle_first_level_name_to_session_snapshot( fallback_session, target_level.level_id.as_str(), generated_level_name.as_str(), fallback_level_name.as_str(), now, - ), - target_level.level_id.as_str(), - candidates.into_records(), - primary_reference_image_src, - now, - )) + ) + } else { + fallback_session + }; + let mut fallback_session = + apply_generated_puzzle_candidates_to_session_snapshot( + fallback_session, + target_level.level_id.as_str(), + candidates.into_records(), + primary_reference_image_src, + now, + ); + if let Some(generated_naming) = generated_naming.as_ref() { + fallback_session = + apply_generated_puzzle_metadata_to_session_snapshot( + fallback_session, + target_level.level_id.as_str(), + generated_naming, + fallback_level_name.as_str(), + now, + ); + } + Ok(fallback_session) } Err(error) => Err(map_puzzle_client_error(error)), } diff --git a/server-rs/crates/api-server/src/puzzle/mappers.rs b/server-rs/crates/api-server/src/puzzle/mappers.rs index 2b08f7ea..6e6c91fd 100644 --- a/server-rs/crates/api-server/src/puzzle/mappers.rs +++ b/server-rs/crates/api-server/src/puzzle/mappers.rs @@ -96,6 +96,7 @@ pub(super) fn map_puzzle_form_draft_response( pub(super) fn map_puzzle_draft_level_response( level: PuzzleDraftLevelRecord, ) -> PuzzleDraftLevelResponse { + let generation_status = resolve_puzzle_level_generation_status(&level); PuzzleDraftLevelResponse { level_id: level.level_id, level_name: level.level_name, @@ -115,7 +116,7 @@ pub(super) fn map_puzzle_draft_level_response( selected_candidate_id: level.selected_candidate_id, cover_image_src: level.cover_image_src, cover_asset_id: level.cover_asset_id, - generation_status: level.generation_status, + generation_status, } } @@ -279,23 +280,66 @@ pub(super) fn map_puzzle_result_preview_finding_response( } fn resolve_puzzle_work_generation_status(item: &PuzzleWorkProfileRecord) -> Option { + let has_viewable_result = item + .cover_image_src + .as_deref() + .map(str::trim) + .is_some_and(|value| !value.is_empty()) + || item.levels.iter().any(has_puzzle_level_image); + if has_viewable_result { + return Some("ready".to_string()); + } + item.levels .iter() - .map(|level| level.generation_status.trim()) - .find(|status| *status == "generating") + .map(resolve_puzzle_level_generation_status) + .find(|status| status.as_str() == "generating") .or_else(|| { item.levels .iter() - .map(|level| level.generation_status.trim()) - .find(|status| *status == "ready") + .map(resolve_puzzle_level_generation_status) + .find(|status| status.as_str() == "ready") }) .or_else(|| { item.levels .iter() - .map(|level| level.generation_status.trim()) + .map(resolve_puzzle_level_generation_status) .find(|status| !status.is_empty()) }) - .map(str::to_string) +} + +fn resolve_puzzle_level_generation_status(level: &PuzzleDraftLevelRecord) -> String { + if level.generation_status.trim() == "generating" && has_puzzle_level_image(level) { + return "ready".to_string(); + } + + level.generation_status.trim().to_string() +} + +fn has_puzzle_level_image(level: &PuzzleDraftLevelRecord) -> bool { + let has_cover = level + .cover_image_src + .as_deref() + .map(str::trim) + .is_some_and(|value| !value.is_empty()); + let has_selected_candidate = level + .selected_candidate_id + .as_deref() + .and_then(|candidate_id| { + level + .candidates + .iter() + .find(|candidate| candidate.candidate_id == candidate_id) + }) + .map(|candidate| candidate.image_src.trim()) + .is_some_and(|value| !value.is_empty()); + let has_fallback_candidate = level + .candidates + .last() + .map(|candidate| candidate.image_src.trim()) + .is_some_and(|value| !value.is_empty()); + + has_cover || has_selected_candidate || has_fallback_candidate } pub(super) fn map_puzzle_work_summary_response( @@ -338,7 +382,11 @@ pub(super) fn map_puzzle_work_summary_response( .saturating_sub(item.point_incentive_claimed_points), publish_ready: item.publish_ready, generation_status, - levels: item.levels.iter().map(|x|map_puzzle_draft_level_response(x.clone())).collect(), + levels: item + .levels + .iter() + .map(|x| map_puzzle_draft_level_response(x.clone())) + .collect(), } } @@ -603,4 +651,4 @@ pub(super) fn build_puzzle_welcome_text(seed_text: &str) -> String { } "拼图创作信æ¯å·²å‡†å¤‡å¥½ã€‚".to_string() -} \ No newline at end of file +} diff --git a/server-rs/crates/api-server/src/puzzle/tests.rs b/server-rs/crates/api-server/src/puzzle/tests.rs index ec169758..69425e82 100644 --- a/server-rs/crates/api-server/src/puzzle/tests.rs +++ b/server-rs/crates/api-server/src/puzzle/tests.rs @@ -253,6 +253,7 @@ fn puzzle_image_generation_builds_fallback_session_from_levels_snapshot() { image_model: Some(PUZZLE_IMAGE_MODEL_GPT_IMAGE_2.to_string()), ai_redraw: None, candidate_count: Some(1), + should_auto_name_level: None, candidate_id: None, level_id: Some("puzzle-level-1".to_string()), work_title: Some("æš–ç¯çŒ«è¡—作å“".to_string()), @@ -376,6 +377,21 @@ fn puzzle_level_name_image_data_url_downsizes_generated_image() { assert!(data_url.len() > "data:image/png;base64,".len()); } +#[test] +fn puzzle_uploaded_cover_can_reuse_resolved_history_image() { + let resolved = PuzzleResolvedReferenceImage { + mime_type: "image/png".to_string(), + bytes_len: 8, + bytes: b"pngbytes".to_vec(), + }; + + let downloaded = PuzzleDownloadedImage::from_resolved_reference_image(resolved); + + assert_eq!(downloaded.extension, "png"); + assert_eq!(downloaded.mime_type, "image/png"); + assert_eq!(downloaded.bytes, b"pngbytes"); +} + #[test] fn puzzle_first_level_name_snapshot_defaults_work_title() { let levels_json = serde_json::to_string(&vec![json!({ @@ -397,6 +413,7 @@ fn puzzle_first_level_name_snapshot_defaults_work_title() { image_model: Some(PUZZLE_IMAGE_MODEL_GPT_IMAGE_2.to_string()), ai_redraw: None, candidate_count: Some(1), + should_auto_name_level: None, candidate_id: None, level_id: Some("puzzle-level-1".to_string()), work_title: Some("猫画é¢".to_string()), @@ -619,7 +636,7 @@ fn puzzle_work_summary_response_keeps_levels_for_shelf_cover() { selected_candidate_id: Some("candidate-1".to_string()), cover_image_src: Some("/generated-puzzle-assets/session/cover.png".to_string()), cover_asset_id: Some("asset-1".to_string()), - generation_status: "ready".to_string(), + generation_status: "generating".to_string(), }; let response = map_puzzle_work_summary_response( @@ -654,6 +671,7 @@ fn puzzle_work_summary_response_keeps_levels_for_shelf_cover() { assert_eq!(response.levels.len(), 1); assert_eq!(response.generation_status.as_deref(), Some("ready")); + assert_eq!(response.levels[0].generation_status, "ready"); assert_eq!( response.levels[0].cover_image_src.as_deref(), Some("/generated-puzzle-assets/session/cover.png") diff --git a/server-rs/crates/api-server/src/puzzle/vector_engine.rs b/server-rs/crates/api-server/src/puzzle/vector_engine.rs index 08fdb5bc..40383193 100644 --- a/server-rs/crates/api-server/src/puzzle/vector_engine.rs +++ b/server-rs/crates/api-server/src/puzzle/vector_engine.rs @@ -69,6 +69,16 @@ pub(crate) struct PuzzleDownloadedImage { pub(crate) bytes: Vec, } +impl PuzzleDownloadedImage { + pub(crate) fn from_resolved_reference_image(image: PuzzleResolvedReferenceImage) -> Self { + Self { + extension: puzzle_mime_to_extension(image.mime_type.as_str()).to_string(), + mime_type: normalize_puzzle_downloaded_image_mime_type(image.mime_type.as_str()), + bytes: image.bytes, + } + } +} + pub(crate) struct ParsedPuzzleImageDataUrl { pub(crate) mime_type: String, pub(crate) bytes: Vec, diff --git a/server-rs/crates/shared-contracts/src/puzzle_agent.rs b/server-rs/crates/shared-contracts/src/puzzle_agent.rs index 32f79da4..aec6e3e9 100644 --- a/server-rs/crates/shared-contracts/src/puzzle_agent.rs +++ b/server-rs/crates/shared-contracts/src/puzzle_agent.rs @@ -49,6 +49,8 @@ pub struct ExecutePuzzleAgentActionRequest { #[serde(default)] pub candidate_count: Option, #[serde(default)] + pub should_auto_name_level: Option, + #[serde(default)] pub candidate_id: Option, #[serde(default)] pub level_id: Option, diff --git a/server-rs/crates/spacetime-module/src/puzzle.rs b/server-rs/crates/spacetime-module/src/puzzle.rs index 7b027bd4..2703a355 100644 --- a/server-rs/crates/spacetime-module/src/puzzle.rs +++ b/server-rs/crates/spacetime-module/src/puzzle.rs @@ -1063,6 +1063,7 @@ fn save_puzzle_generated_images_tx( if let Some(levels) = deserialize_optional_levels_input(input.levels_json.as_deref())? { // 中文注释:结果页新增关å¡å¯èƒ½è¿˜æ²¡ç­‰åˆ°è‡ªåЍä¿å­˜ï¼Œç”Ÿæˆå›¾æ—¶ä»¥æœ¬æ¬¡ action æºå¸¦çš„å…³å¡å¿«ç…§ä½œä¸ºå†™å›žç›®æ ‡ã€‚ draft.levels = levels; + draft = normalize_completed_puzzle_level_generation_status(draft); module_puzzle::sync_primary_level_fields(&mut draft); // 中文注释:入å£ç›´åˆ›ä¼šåœ¨ api-server 生æˆé¦–å…³ååŽéš levels_json 写入;作å“å仿˜¯æ—§é¦–å…³åæˆ–空值时æ‰è·Ÿéšé¦–å…³å,é¿å…覆盖用户手动命å。 sync_generated_primary_level_name_as_default_work_title( @@ -1092,6 +1093,7 @@ fn save_puzzle_generated_images_tx( next_level.cover_asset_id = Some(selected.asset_id); } draft = replace_puzzle_level(&draft, next_level).map_err(|error| error.to_string())?; + draft = normalize_completed_puzzle_level_generation_status(draft); let saved_at = Timestamp::from_micros_since_unix_epoch(input.saved_at_micros); let next_stage = if build_result_preview(&draft, Some("陶泥儿主")).publish_ready { @@ -1143,6 +1145,7 @@ fn save_puzzle_ui_background_tx( if let Some(levels) = deserialize_optional_levels_input(input.levels_json.as_deref())? { // 中文注释:UI 背景å¯ä»¥åœ¨è‡ªåЍä¿å­˜å‰ç«‹å³ç”Ÿæˆï¼Œå†™å›žå‰ä¼˜å…ˆä½¿ç”¨æœ¬æ¬¡ action æºå¸¦çš„å…³å¡å¿«ç…§ã€‚ draft.levels = levels; + draft = normalize_completed_puzzle_level_generation_status(draft); module_puzzle::sync_primary_level_fields(&mut draft); } let target_level = selected_puzzle_level(&draft, input.level_id.as_deref()) @@ -1155,6 +1158,7 @@ fn save_puzzle_ui_background_tx( (!trimmed.is_empty()).then_some(trimmed) }); let draft = replace_puzzle_level(&draft, next_level).map_err(|error| error.to_string())?; + let draft = normalize_completed_puzzle_level_generation_status(draft); let saved_at = Timestamp::from_micros_since_unix_epoch(input.saved_at_micros); let next_stage = if build_result_preview(&draft, Some("陶泥儿主")).publish_ready { @@ -1208,6 +1212,52 @@ fn sync_generated_primary_level_name_as_default_work_title( } } +fn normalize_completed_puzzle_level_generation_status( + mut draft: PuzzleResultDraft, +) -> PuzzleResultDraft { + draft.levels = normalize_completed_puzzle_levels_generation_status(draft.levels); + module_puzzle::sync_primary_level_fields(&mut draft); + draft +} + +fn normalize_completed_puzzle_levels_generation_status( + mut levels: Vec, +) -> Vec { + for level in &mut levels { + if level.generation_status.trim() == "generating" && has_completed_puzzle_level_image(level) + { + level.generation_status = "ready".to_string(); + } + } + levels +} + +fn has_completed_puzzle_level_image(level: &module_puzzle::PuzzleDraftLevel) -> bool { + let has_cover = level + .cover_image_src + .as_deref() + .map(str::trim) + .is_some_and(|value| !value.is_empty()); + let has_selected_candidate = level + .selected_candidate_id + .as_deref() + .and_then(|candidate_id| { + level + .candidates + .iter() + .find(|candidate| candidate.candidate_id == candidate_id) + }) + .map(|candidate| candidate.image_src.trim()) + .is_some_and(|value| !value.is_empty()); + let has_fallback_candidate = level + .candidates + .last() + .map(|candidate| candidate.image_src.trim()) + .is_some_and(|value| !value.is_empty()); + + has_cover || has_selected_candidate || has_fallback_candidate +} + fn select_puzzle_cover_image_tx( ctx: &TxContext, input: PuzzleSelectCoverImageInput, @@ -1391,12 +1441,13 @@ fn update_puzzle_work_tx( if theme_tags.len() > PUZZLE_MAX_TAG_COUNT { return Err("拼图标签数é‡ä¸åˆæ³•".to_string()); } - let levels = deserialize_optional_levels_input(input.levels_json.as_deref())? + let mut levels = deserialize_optional_levels_input(input.levels_json.as_deref())? .map(|levels| { normalize_puzzle_levels(levels, &theme_tags).map_err(|error| error.to_string()) }) .transpose()? .unwrap_or_else(|| build_profile_levels_from_row(&row).unwrap_or_default()); + levels = normalize_completed_puzzle_levels_generation_status(levels); let preview_draft = PuzzleResultDraft { work_title: input.work_title.clone(), work_description: input.work_description.clone(), @@ -2547,6 +2598,10 @@ fn build_puzzle_gallery_card_view_row( fn resolve_puzzle_gallery_generation_status( levels: &[module_puzzle::PuzzleDraftLevel], ) -> Option { + if levels.iter().any(has_completed_puzzle_level_image) { + return Some("ready".to_string()); + } + levels .iter() .map(|level| level.generation_status.trim()) @@ -2822,6 +2877,7 @@ fn replace_puzzle_work_profile( } fn upsert_puzzle_work_profile(ctx: &TxContext, profile: PuzzleWorkProfile) -> Result<(), String> { + let levels = normalize_completed_puzzle_levels_generation_status(profile.levels); if let Some(existing) = ctx .db .puzzle_work_profile() @@ -2844,7 +2900,7 @@ fn upsert_puzzle_work_profile(ctx: &TxContext, profile: PuzzleWorkProfile) -> Re theme_tags_json: serialize_json(&profile.theme_tags), cover_image_src: profile.cover_image_src, cover_asset_id: profile.cover_asset_id, - levels_json: serialize_json(&profile.levels), + levels_json: serialize_json(&levels), publication_status: profile.publication_status, // 二次编辑å‘布åŒä¸€ä¸ª profile 时,作å“内容å¯ä»¥è¦†ç›–ï¼Œä½†åŽ†å²æ¸¸çŽ©æ•°å±žäºŽ // 广场消费数æ®ï¼Œä¸èƒ½å› ä¸ºé‡æ–°å‘布被清零。 @@ -2882,7 +2938,7 @@ fn upsert_puzzle_work_profile(ctx: &TxContext, profile: PuzzleWorkProfile) -> Re theme_tags_json: serialize_json(&profile.theme_tags), cover_image_src: profile.cover_image_src, cover_asset_id: profile.cover_asset_id, - levels_json: serialize_json(&profile.levels), + levels_json: serialize_json(&levels), publication_status: profile.publication_status, play_count: profile.play_count, remix_count: profile.remix_count, @@ -3532,7 +3588,9 @@ fn deserialize_levels_json(value: &str) -> Result { expect(html).toContain('creation-work-card__spinner'); }); +test('creation hub does not mask completed puzzle drafts while a later level image is generating', () => { + const html = renderToStaticMarkup( + {}} + onCreateType={noopCreateType} + onOpenDraft={() => {}} + onEnterPublished={() => {}} + entryConfig={testEntryConfig} + creationTypes={testCreationTypes} + onOpenPuzzleDetail={() => {}} + />, + ); + + expect(html).not.toContain('生æˆä¸­...'); + expect(html).not.toContain('creation-work-card__spinner'); + expect(html).toContain('继续创作《潮雾拼图è‰ç¨¿ã€‹'); +}); + test('creation hub published work uses unified list card layout', () => { const html = renderToStaticMarkup( 0 - ? normalizeCoverImageSrc( - level.candidates.find( - (candidate) => candidate.candidateId === level.selectedCandidateId, - )?.imageSrc, + return null; +} + +export function resolvePuzzleLevelCoverImageSrc( + level: NonNullable[number], +) { + const levelCoverImageSrc = normalizeCoverImageSrc(level.coverImageSrc); + if ( + levelCoverImageSrc && + !isCreationTypeReferenceCoverImageSrc(levelCoverImageSrc) + ) { + return levelCoverImageSrc; + } + + const selectedCandidateImageSrc = + level.selectedCandidateId && level.candidates.length > 0 + ? normalizeCoverImageSrc( + level.candidates.find( + (candidate) => candidate.candidateId === level.selectedCandidateId, + )?.imageSrc, ) - : null; - const fallbackCandidateImageSrc = normalizeCoverImageSrc( - level.candidates[level.candidates.length - 1]?.imageSrc, - ); - const candidateImageSrc = selectedCandidateImageSrc || fallbackCandidateImageSrc; + : null; + const fallbackCandidateImageSrc = normalizeCoverImageSrc( + level.candidates[level.candidates.length - 1]?.imageSrc, + ); + const candidateImageSrc = selectedCandidateImageSrc || fallbackCandidateImageSrc; - if ( - candidateImageSrc && - !isCreationTypeReferenceCoverImageSrc(candidateImageSrc) - ) { - return candidateImageSrc; - } + if ( + candidateImageSrc && + !isCreationTypeReferenceCoverImageSrc(candidateImageSrc) + ) { + return candidateImageSrc; } return null; @@ -804,12 +815,26 @@ function isPersistedCreationWorkGenerating(item: CreationWorkShelfItem) { case 'match3d': return item.source.item.generationStatus === 'generating'; case 'puzzle': - return item.source.item.generationStatus === 'generating'; + return isPersistedPuzzleDraftGenerating(item.source.item); default: return false; } } +export function isPersistedPuzzleDraftGenerating(item: PuzzleWorkSummary) { + if (item.generationStatus !== 'generating') { + return false; + } + + const hasUsableCover = Boolean(resolvePuzzleWorkCoverImageSrc(item)); + const hasReadyLevel = (item.levels ?? []).some((level) => + Boolean(resolvePuzzleLevelCoverImageSrc(level)), + ); + + // ä¸­æ–‡æ³¨é‡Šï¼šä½œå“æž¶â€œç”Ÿæˆä¸­â€åªè¡¨ç¤ºåˆå§‹è‰ç¨¿è¿˜æ²¡æœ‰å¯æŸ¥çœ‹ç»“æžœï¼›ç»“æžœé¡µè¿½åŠ å…³å¡æˆ–é‡ç»˜å±€éƒ¨å›¾ç‰‡ä¸èƒ½é”使•´å¼ è‰ç¨¿å¡ã€‚ + return !hasUsableCover && !hasReadyLevel; +} + function buildRpgWorkShelfActions( item: CustomWorldWorkSummary, adapter: RpgWorkShelfAdapter, diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 8d8d9dc1..b3b4b773 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -300,7 +300,11 @@ import { PublishShareModal } from '../common/PublishShareModal'; import type { PublishShareModalPayload } from '../common/publishShareModalModel'; import { UnifiedModal } from '../common/UnifiedModal'; import { resolveCreativeAgentTargetSelectionStage } from '../creative-agent/creativeAgentViewModel'; -import type { CreationWorkShelfItem } from '../custom-world-home/creationWorkShelf'; +import { + isPersistedPuzzleDraftGenerating, + resolvePuzzleWorkCoverImageSrc, + type CreationWorkShelfItem, +} from '../custom-world-home/creationWorkShelf'; import { isBigFishGalleryEntry, isEdutainmentGalleryEntry, @@ -3403,9 +3407,12 @@ export function PlatformEntryFlowShellImpl({ const notice = getDraftGenerationNotice( getGenerationNoticeShelfKeys(item), ); + const isNoticeGenerating = + notice?.status === 'generating' && + (item.source.kind !== 'puzzle' || + !resolvePuzzleWorkCoverImageSrc(item.source.item)); return { - isGenerating: - notice?.status === 'generating' || item.isGenerating === true, + isGenerating: isNoticeGenerating || item.isGenerating === true, hasUnreadUpdate: notice?.status === 'ready' && !notice.seen, }; }, @@ -6495,7 +6502,10 @@ export function PlatformEntryFlowShellImpl({ } } - if (payload.action === 'generate_puzzle_images') { + if ( + payload.action === 'generate_puzzle_images' || + payload.action === 'generate_puzzle_ui_background' + ) { void executePuzzleBackgroundAction(payload); return; } @@ -8748,13 +8758,16 @@ export function PlatformEntryFlowShellImpl({ buildPuzzleResultWorkId(item.sourceSessionId), buildPuzzleResultProfileId(item.sourceSessionId), ]); - const isMarkedGenerating = isDraftNoticeGenerating('puzzle', [ + const hasGeneratingNotice = isDraftNoticeGenerating('puzzle', [ item.workId, item.profileId, item.sourceSessionId, buildPuzzleResultWorkId(item.sourceSessionId), buildPuzzleResultProfileId(item.sourceSessionId), - ]) || isPersistedDraftGenerating(item.generationStatus); + ]); + const isMarkedGenerating = + (hasGeneratingNotice && !resolvePuzzleWorkCoverImageSrc(item)) || + isPersistedPuzzleDraftGenerating(item); setPuzzleOperation(null); setPuzzleRun(null); setPuzzleRuntimeAuthMode('default'); diff --git a/src/components/puzzle-agent/PuzzleAgentWorkspace.interaction.test.tsx b/src/components/puzzle-agent/PuzzleAgentWorkspace.interaction.test.tsx index 9da9b5e5..67c304a8 100644 --- a/src/components/puzzle-agent/PuzzleAgentWorkspace.interaction.test.tsx +++ b/src/components/puzzle-agent/PuzzleAgentWorkspace.interaction.test.tsx @@ -535,6 +535,60 @@ test('puzzle workspace hides prompt and cost when AI redraw is off', async () => }); }); +test('puzzle workspace submits history image when AI redraw is off', async () => { + const onCreateFromForm = vi.fn(); + vi.mocked(puzzleAssetClient.listHistoryAssets).mockResolvedValue([ + { + assetObjectId: 'asset-history-1', + assetKind: 'puzzle_cover_image', + imageSrc: '/generated-puzzle-assets/history/image.png', + ownerUserId: 'user-1', + ownerLabel: 'è´¦å· user-1', + profileId: null, + entityId: 'puzzle-session-1', + createdAt: '1713686400.000000Z', + updatedAt: '1713686400.000000Z', + }, + ]); + + render( + {}} + onSubmitMessage={() => {}} + onExecuteAction={() => {}} + onCreateFromForm={onCreateFromForm} + />, + ); + + fireEvent.click(screen.getByRole('button', { name: '选择历å²å›¾ç‰‡' })); + const picker = await screen.findByRole('dialog', { + name: '选择历å²å›¾ç‰‡', + }); + fireEvent.click( + await within(picker).findByRole('button', { name: /image\.png/u }), + ); + await waitFor(() => { + expect(screen.queryByRole('dialog', { name: '选择历å²å›¾ç‰‡' })).toBeNull(); + }); + + const aiRedrawSwitch = screen.getByRole('switch', { name: 'AIé‡ç»˜' }); + fireEvent.click(aiRedrawSwitch); + expect(screen.queryByLabelText('ç”»é¢AIé‡ç»˜è¦æ±‚(æç¤ºè¯ï¼‰')).toBeNull(); + expect(screen.queryByText('消耗2泥点')).toBeNull(); + + fireEvent.click(screen.getByRole('button', { name: /ç”Ÿæˆæ‹¼å›¾æ¸¸æˆè‰ç¨¿/u })); + + expect(onCreateFromForm).toHaveBeenCalledWith({ + seedText: '历å²ç´ æ · image.png', + pictureDescription: '历å²ç´ æ · image.png', + referenceImageSrc: '/generated-puzzle-assets/history/image.png', + referenceImageSrcs: [], + imageModel: 'gpt-image-2', + aiRedraw: false, + }); +}); + test('puzzle workspace submits uploaded reference image when AI redraw is on', async () => { const onCreateFromForm = vi.fn(); const uploadedDataUrl = 'data:image/png;base64,uploaded-square'; diff --git a/src/components/puzzle-result/PuzzleResultView.test.tsx b/src/components/puzzle-result/PuzzleResultView.test.tsx index 62cfd7e1..4d694e94 100644 --- a/src/components/puzzle-result/PuzzleResultView.test.tsx +++ b/src/components/puzzle-result/PuzzleResultView.test.tsx @@ -317,6 +317,7 @@ describe('PuzzleResultView', () => { imageModel: 'gpt-image-2', aiRedraw: true, candidateCount: 1, + shouldAutoNameLevel: false, workTitle: 'æš–ç¯çŒ«è¡—作å“', workDescription: '一套雨夜猫街主题拼图。', summary: '一套雨夜猫街主题拼图。', @@ -466,6 +467,7 @@ describe('PuzzleResultView', () => { imageModel: 'gpt-image-2', aiRedraw: true, candidateCount: 1, + shouldAutoNameLevel: true, workTitle: 'æš–ç¯çŒ«è¡—作å“', workDescription: '一套雨夜猫街主题拼图。', summary: '一套雨夜猫街主题拼图。', @@ -485,6 +487,42 @@ describe('PuzzleResultView', () => { ]); }); + test('requests automatic level naming when generating an unnamed level image', () => { + vi.spyOn(Date, 'now').mockReturnValue(1_775_000_000_000); + const onExecuteAction = vi.fn(); + + render( + {}} + onExecuteAction={onExecuteAction} + />, + ); + + openPuzzleLevelsTab(); + fireEvent.click(screen.getByRole('button', { name: /新增关å¡/u })); + const dialog = screen.getByRole('dialog', { name: 'å…³å¡è¯¦æƒ…' }); + fireEvent.change(within(dialog).getByLabelText('ç”»é¢æè¿°'), { + target: { value: 'æ–°å…³å¡é‡Œæœ‰ä¸€åº§å‘光钟楼。' }, + }); + fireEvent.click(within(dialog).getByRole('button', { name: /生æˆç”»é¢/u })); + fireEvent.click( + within(screen.getByRole('dialog', { name: '确认消耗泥点' })).getByRole( + 'button', + { name: '确定' }, + ), + ); + + expect(onExecuteAction).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'generate_puzzle_images', + levelId: 'puzzle-level-1775000000000-2', + promptText: 'æ–°å…³å¡é‡Œæœ‰ä¸€åº§å‘光钟楼。', + shouldAutoNameLevel: true, + }), + ); + }); + test('keeps generation progress visible after closing and reopening level dialog', () => { const onExecuteAction = vi.fn(); @@ -567,6 +605,90 @@ describe('PuzzleResultView', () => { ).toHaveProperty('disabled', true); }); + test('keeps level controls enabled while regenerating the UI background', () => { + const onExecuteAction = vi.fn(); + + render( + {}} + onExecuteAction={onExecuteAction} + isBusy={false} + />, + ); + + fireEvent.click(screen.getByRole('button', { name: 'ç´ æé…ç½®' })); + fireEvent.change(screen.getByLabelText('拼图UI背景æç¤ºè¯'), { + target: { value: 'é›¨å¤œçŒ«è¡—ç«–å±æ‹¼å›¾UI背景' }, + }); + fireEvent.click(screen.getByRole('button', { name: /生æˆUI背景/u })); + fireEvent.click( + within(screen.getByRole('dialog', { name: /确认消耗泥点/u })).getByRole( + 'button', + { name: '确定' }, + ), + ); + + expect(onExecuteAction).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'generate_puzzle_ui_background', + promptText: 'é›¨å¤œçŒ«è¡—ç«–å±æ‹¼å›¾UI背景', + }), + ); + expect( + screen.getByRole('button', { name: /生æˆä¸­/u }), + ).toHaveProperty('disabled', true); + + openPuzzleLevelsTab(); + const addLevelButton = screen.getByRole('button', { name: /新增关å¡/u }); + expect(addLevelButton).toHaveProperty('disabled', false); + fireEvent.click(addLevelButton); + expect(screen.getByRole('dialog', { name: 'å…³å¡è¯¦æƒ…' })).toBeTruthy(); + }); + + test('restores UI background generate button when background generation fails', () => { + const onExecuteAction = vi.fn(); + const { rerender } = render( + {}} + onExecuteAction={onExecuteAction} + isBusy={false} + />, + ); + + fireEvent.click(screen.getByRole('button', { name: 'ç´ æé…ç½®' })); + fireEvent.change(screen.getByLabelText('拼图UI背景æç¤ºè¯'), { + target: { value: 'é›¨å¤œçŒ«è¡—ç«–å±æ‹¼å›¾UI背景' }, + }); + fireEvent.click(screen.getByRole('button', { name: /生æˆUI背景/u })); + fireEvent.click( + within(screen.getByRole('dialog', { name: /确认消耗泥点/u })).getByRole( + 'button', + { name: '确定' }, + ), + ); + + expect(screen.getByRole('button', { name: /生æˆä¸­/u })).toHaveProperty( + 'disabled', + true, + ); + + rerender( + {}} + onExecuteAction={onExecuteAction} + isBusy={false} + />, + ); + + const generateButton = screen.getByRole('button', { name: /生æˆUI背景/u }); + expect(generateButton).toHaveProperty('disabled', false); + expect(screen.queryByRole('button', { name: /生æˆä¸­/u })).toBeNull(); + }); + test('keeps the current level dialog open when another level generation completes', () => { const base = createSession(); const firstLevel = base.draft!.levels![0]!; @@ -1143,6 +1265,7 @@ describe('PuzzleResultView', () => { imageModel: 'gpt-image-2', aiRedraw: true, candidateCount: 1, + shouldAutoNameLevel: false, workTitle: 'æš–ç¯çŒ«è¡—作å“', workDescription: '一套雨夜猫街主题拼图。', summary: '一套雨夜猫街主题拼图。', diff --git a/src/components/puzzle-result/PuzzleResultView.tsx b/src/components/puzzle-result/PuzzleResultView.tsx index 34319a7c..2f623c5e 100644 --- a/src/components/puzzle-result/PuzzleResultView.tsx +++ b/src/components/puzzle-result/PuzzleResultView.tsx @@ -93,6 +93,11 @@ type PuzzleLevelGenerationRuntime = { estimateSeconds: number; }; +type PuzzleUiBackgroundGenerationState = { + levelId: string; + prompt: string; +} | null; + function resolvePuzzleLevelGenerationProgress( level: PuzzleDraftLevel, runtime: PuzzleLevelGenerationRuntime | null, @@ -1409,16 +1414,22 @@ function PuzzleUiAssetsTab({ editState, imageRefreshKey, isBusy, + uiBackgroundGeneration, onChange, onGenerate, }: { editState: DraftEditState; imageRefreshKey: string; isBusy: boolean; + uiBackgroundGeneration: PuzzleUiBackgroundGenerationState; onChange: (nextState: DraftEditState) => void; onGenerate: (prompt: string) => void; }) { const firstLevel = editState.levels[0] ?? null; + const isGeneratingUiBackground = Boolean( + firstLevel && + uiBackgroundGeneration?.levelId === firstLevel.levelId, + ); const formalImageSrc = firstLevel ? resolveLevelFormalImageSrc(firstLevel) : ''; const defaultPrompt = buildDefaultPuzzleUiBackgroundPrompt( editState, @@ -1490,21 +1501,30 @@ function PuzzleUiAssetsTab({ @@ -1547,7 +1567,12 @@ function PuzzleUiAssetsTab({ @@ -1696,6 +1721,7 @@ function PuzzleAssetConfigTab({ editState, imageRefreshKey, isBusy, + uiBackgroundGeneration, onAssetConfigTabChange, onChange, onGenerateUiBackground, @@ -1704,6 +1730,7 @@ function PuzzleAssetConfigTab({ editState: DraftEditState; imageRefreshKey: string; isBusy: boolean; + uiBackgroundGeneration: PuzzleUiBackgroundGenerationState; onAssetConfigTabChange: (tab: PuzzleAssetConfigTabId) => void; onChange: (nextState: DraftEditState) => void; onGenerateUiBackground: (prompt: string) => void; @@ -1719,6 +1746,7 @@ function PuzzleAssetConfigTab({ editState={editState} imageRefreshKey={imageRefreshKey} isBusy={isBusy} + uiBackgroundGeneration={uiBackgroundGeneration} onChange={onChange} onGenerate={onGenerateUiBackground} /> @@ -1829,6 +1857,8 @@ export function PuzzleResultView({ const [tagGenerationError, setTagGenerationError] = useState( null, ); + const [uiBackgroundGeneration, setUiBackgroundGeneration] = + useState(null); const [generationRuntimeByLevelId, setGenerationRuntimeByLevelId] = useState< Record >({}); @@ -1844,11 +1874,18 @@ export function PuzzleResultView({ latestEditStateRef.current = editState; }, [editState]); + useEffect(() => { + if (error) { + setUiBackgroundGeneration(null); + } + }, [error]); + useEffect(() => { if (!draft) { setEditState(null); latestEditStateRef.current = null; setActiveLevelId(null); + setUiBackgroundGeneration(null); setAutoSaveState('idle'); setAutoSaveError(null); setTagGenerationError(null); @@ -1884,6 +1921,19 @@ export function PuzzleResultView({ setAutoSaveState('idle'); setAutoSaveError(null); setTagGenerationError(null); + setUiBackgroundGeneration((current) => { + if ( + current && + mergedState.levels.some( + (level) => + level.levelId === current.levelId && + resolvePuzzleUiBackgroundSource(level), + ) + ) { + return null; + } + return current; + }); }, [draft]); const syncedDraft = useMemo(() => { @@ -2163,6 +2213,7 @@ export function PuzzleResultView({ editState={editState} imageRefreshKey={imageRefreshKey} isBusy={isBusy} + uiBackgroundGeneration={uiBackgroundGeneration} onAssetConfigTabChange={setActiveAssetConfigTab} onChange={setEditState} onGenerateUiBackground={(prompt) => { @@ -2170,6 +2221,10 @@ export function PuzzleResultView({ if (!firstLevel) { return; } + setUiBackgroundGeneration({ + levelId: firstLevel.levelId, + prompt, + }); onExecuteAction({ action: 'generate_puzzle_ui_background', levelId: firstLevel.levelId, @@ -2256,6 +2311,7 @@ export function PuzzleResultView({ imageModel: imageModel ?? PUZZLE_IMAGE_MODEL_GPT_IMAGE_2, aiRedraw: true, candidateCount: 1, + shouldAutoNameLevel: !nextLevel.levelName.trim(), workTitle: editState.workTitle.trim(), workDescription: editState.workDescription.trim(), summary: editState.workDescription.trim(),