diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index feb5b666..811f055b 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 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/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index 6c68b970..630ff6e4 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/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 8e94d794..c26882b7 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -70,6 +70,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, @@ -6279,7 +6280,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); @@ -6303,6 +6304,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', { @@ -7197,6 +7239,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]; @@ -7240,6 +7298,9 @@ export function PlatformEntryFlowShellImpl({ }; }, [ activePuzzleGenerationSessionId, + puzzleGenerationViewPayload, + puzzleGenerationViewState, + recoverCompletedPuzzleDraftGeneration, shouldPollPuzzleGenerationSession, setPuzzleSession, ]); @@ -9479,20 +9540,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(() => { @@ -10405,15 +10472,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/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 + ); +} diff --git a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx index c2ff40bb..a674387a 100644 --- a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx +++ b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx @@ -4223,6 +4223,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({ @@ -4916,6 +5025,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({