feat(jump-hop): 优化跳一跳素材生成与背景底图 #55
@@ -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 共用同一写成绩路径,未发布或未确认资源的草稿试玩会污染正式单局、排行榜和作品统计。
|
||||
|
||||
@@ -71,6 +71,8 @@ spacetime sql <database> "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/<session_id>/actions` 路径对齐真实失败请求,避免被前端显示的“来源草稿”误导。
|
||||
|
||||
查看本地 Rust / SpacetimeDB 日志:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -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`;发布成功后平台壳必须同时刷新作品架与公开广场,避免作品刚发布时仍停留在旧列表。
|
||||
|
||||
@@ -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<Mutex<HashSet<String>>> = OnceLock::new();
|
||||
|
||||
fn puzzle_background_compile_tasks() -> &'static Mutex<HashSet<String>> {
|
||||
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<String>) -> 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)
|
||||
}
|
||||
|
||||
@@ -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<PuzzleAgentSessionRecord, AppError> {
|
||||
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,
|
||||
|
||||
@@ -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 背景为第一关图片。"
|
||||
},
|
||||
|
||||
@@ -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("雨夜猫街", "雨夜猫街主题背景");
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
20
src/components/platform-entry/puzzleDraftGenerationState.ts
Normal file
20
src/components/platform-entry/puzzleDraftGenerationState.ts
Normal file
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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(<TestWrapper withAuth />);
|
||||
|
||||
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(<TestWrapper withAuth />);
|
||||
|
||||
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({
|
||||
|
||||
Reference in New Issue
Block a user