Merge remote-tracking branch 'origin/master' into codex/tiaoyitiao
This commit is contained in:
@@ -1601,10 +1601,18 @@
|
|||||||
|
|
||||||
- 现象:拼图生成页已经收到 VectorEngine 图片编辑失败并进入重试态,但用户返回草稿 Tab 后,同一草稿仍显示“生成中”;连续触发多个拼图生成时,失败后还可能只剩一条新增草稿,或者只看到标题为“第1关”的半成品空壳;抓大鹅后台失败时也可能没有任何通知,点击草稿又像重新开始生成。
|
- 现象:拼图生成页已经收到 VectorEngine 图片编辑失败并进入重试态,但用户返回草稿 Tab 后,同一草稿仍显示“生成中”;连续触发多个拼图生成时,失败后还可能只剩一条新增草稿,或者只看到标题为“第1关”的半成品空壳;抓大鹅后台失败时也可能没有任何通知,点击草稿又像重新开始生成。
|
||||||
- 原因:前端失败 notice 只更新生成页局部状态,pending 作品架条目在失败时被清掉或被非 `generating` 状态误映射为 `ready`;后端作品摘要也可能短暂仍是 `generationStatus=generating`。如果失败消息没有写入 notice,用户离开生成页后不会弹出 `PlatformErrorDialog`;如果打开草稿只看持久化 `generating`,就会绕过失败态恢复。
|
- 原因:前端失败 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"`。
|
- 验证:`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`。
|
- 关联:`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
|
## 汪汪声浪草稿试玩不要写正式 run
|
||||||
|
|
||||||
- 现象:如果草稿结果页试玩和发布后 runtime 共用同一写成绩路径,未发布或未确认资源的草稿试玩会污染正式单局、排行榜和作品统计。
|
- 现象:如果草稿结果页试玩和发布后 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,避免把一次用户请求内的多次发送误判成多个用户请求。
|
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 日志:
|
查看本地 Rust / SpacetimeDB 日志:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -54,7 +54,7 @@
|
|||||||
4. 生成中作品在整卡上加等待遮罩,但不移除作品基础信息。
|
4. 生成中作品在整卡上加等待遮罩,但不移除作品基础信息。
|
||||||
5. 生成中状态不能只存在前端内存 notice。后端作品摘要必须下发可恢复的 `generationStatus`;前端刷新或退出产品后,作品架优先用摘要状态恢复等待遮罩,本轮内存 notice 只作为即时反馈。
|
5. 生成中状态不能只存在前端内存 notice。后端作品摘要必须下发可恢复的 `generationStatus`;前端刷新或退出产品后,作品架优先用摘要状态恢复等待遮罩,本轮内存 notice 只作为即时反馈。
|
||||||
6. 点击 `generationStatus=generating` 的草稿卡必须恢复对应玩法的生成进度页,不能进入空白结果页或普通工作区;恢复生成页的 `startedAtMs` 优先使用后端 session 的 `updatedAt`,没有 session 时再使用作品摘要 `updatedAt`,不得因重新进入页面从 0 秒重新计时。
|
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。平台壳层需要显式记录本次创作流的返回来源,不能让结果页返回动作固定跳到创作入口。
|
8. 从草稿 Tab 作品架打开草稿工作区、生成页或结果页时,返回按钮必须回到草稿 Tab 的同一作品架语境;从创作 Tab 新建或直接进入创作链路时才回到创作 Tab。平台壳层需要显式记录本次创作流的返回来源,不能让结果页返回动作固定跳到创作入口。
|
||||||
9. 私有 generated 图片必须通过 `ResolvedAssetImage` / `/api/assets/read-url` 换签读取。
|
9. 私有 generated 图片必须通过 `ResolvedAssetImage` / `/api/assets/read-url` 换签读取。
|
||||||
10. 敲木鱼作品架读取当前用户作品列表时走 `GET /api/creation/wooden-fish/works`;发布成功后平台壳必须同时刷新作品架与公开广场,避免作品刚发布时仍停留在旧列表。
|
10. 敲木鱼作品架读取当前用户作品列表时走 `GET /api/creation/wooden-fish/works`;发布成功后平台壳必须同时刷新作品架与公开广场,避免作品刚发布时仍停留在旧列表。
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
use std::{
|
use std::{
|
||||||
collections::BTreeMap,
|
collections::{BTreeMap, HashSet},
|
||||||
|
sync::{Mutex, OnceLock},
|
||||||
time::{Instant, SystemTime, UNIX_EPOCH},
|
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_SQUARE_IMAGE_SIZE: &str = "1024x1024";
|
||||||
const PUZZLE_VECTOR_ENGINE_PORTRAIT_IMAGE_SIZE: &str = "1024x1536";
|
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 {
|
pub(crate) fn format_puzzle_reference_image_upload_bytes(bytes: usize) -> String {
|
||||||
format!("{:.1}MB", bytes as f64 / 1024.0 / 1024.0)
|
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())
|
.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,
|
state: &PuzzleApiState,
|
||||||
request_context: &RequestContext,
|
request_context: &RequestContext,
|
||||||
session_id: String,
|
compiled_session: PuzzleAgentSessionRecord,
|
||||||
owner_user_id: String,
|
owner_user_id: String,
|
||||||
prompt_text: Option<&str>,
|
prompt_text: Option<&str>,
|
||||||
reference_image_src: Option<&str>,
|
reference_image_src: Option<&str>,
|
||||||
image_model: Option<&str>,
|
image_model: Option<&str>,
|
||||||
now: i64,
|
now: i64,
|
||||||
) -> Result<PuzzleAgentSessionRecord, AppError> {
|
) -> 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(|| {
|
let draft = compiled_session.draft.clone().ok_or_else(|| {
|
||||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||||
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||||
@@ -1419,7 +1414,7 @@ pub(crate) async fn compile_puzzle_draft_with_initial_cover(
|
|||||||
match state
|
match state
|
||||||
.spacetime_client()
|
.spacetime_client()
|
||||||
.select_puzzle_cover_image(PuzzleSelectCoverImageRecordInput {
|
.select_puzzle_cover_image(PuzzleSelectCoverImageRecordInput {
|
||||||
session_id,
|
session_id: compiled_session.session_id.clone(),
|
||||||
owner_user_id,
|
owner_user_id,
|
||||||
level_id: Some(target_level.level_id),
|
level_id: Some(target_level.level_id),
|
||||||
candidate_id: selected_candidate_id,
|
candidate_id: selected_candidate_id,
|
||||||
|
|||||||
@@ -623,7 +623,7 @@ pub async fn execute_puzzle_agent_action(
|
|||||||
session_id,
|
session_id,
|
||||||
owner_user_id,
|
owner_user_id,
|
||||||
error_message,
|
error_message,
|
||||||
failed_at_micros: now,
|
failed_at_micros: current_utc_micros(),
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
if let Err(error) = result {
|
if let Err(error) = result {
|
||||||
@@ -668,27 +668,128 @@ pub async fn execute_puzzle_agent_action(
|
|||||||
Err(response) => return Err(response),
|
Err(response) => return Err(response),
|
||||||
};
|
};
|
||||||
let session = if ai_redraw {
|
let session = if ai_redraw {
|
||||||
execute_billable_asset_operation_with_cost(
|
if !try_register_puzzle_background_compile_task(&compile_session_id) {
|
||||||
state.root_state(),
|
tracing::info!(
|
||||||
&owner_user_id,
|
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||||
"puzzle_initial_image",
|
session_id = %compile_session_id,
|
||||||
&billing_asset_id,
|
owner_user_id = %owner_user_id,
|
||||||
PUZZLE_IMAGE_GENERATION_POINTS_COST,
|
"拼图首图后台生成任务已存在,本次 action 直接返回生成中会话"
|
||||||
async {
|
);
|
||||||
compile_puzzle_draft_with_initial_cover(
|
state
|
||||||
&state,
|
.spacetime_client()
|
||||||
&request_context,
|
.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(),
|
compile_session_id.clone(),
|
||||||
owner_user_id.clone(),
|
owner_user_id.clone(),
|
||||||
prompt_text,
|
|
||||||
primary_reference_image_src,
|
|
||||||
payload.image_model.as_deref(),
|
|
||||||
now,
|
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
|
.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 {
|
} else {
|
||||||
compile_puzzle_draft_with_uploaded_cover(
|
compile_puzzle_draft_with_uploaded_cover(
|
||||||
&state,
|
&state,
|
||||||
@@ -716,7 +817,7 @@ pub async fn execute_puzzle_agent_action(
|
|||||||
"compile_puzzle_draft",
|
"compile_puzzle_draft",
|
||||||
"首关拼图草稿",
|
"首关拼图草稿",
|
||||||
if ai_redraw {
|
if ai_redraw {
|
||||||
"已编译首关草稿、并行生成首关画面和 UI 背景并写入正式草稿。"
|
"已编译首关草稿,并启动首关画面和 UI 资产后台生成。"
|
||||||
} else {
|
} else {
|
||||||
"已编译首关草稿,并直接应用上传图片、生成 UI 背景为第一关图片。"
|
"已编译首关草稿,并直接应用上传图片、生成 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]
|
#[test]
|
||||||
fn puzzle_ui_background_prompt_keeps_generated_slots_out_of_background() {
|
fn puzzle_ui_background_prompt_keeps_generated_slots_out_of_background() {
|
||||||
let prompt = build_puzzle_ui_background_request_prompt_for_test("雨夜猫街", "雨夜猫街主题背景");
|
let prompt = build_puzzle_ui_background_request_prompt_for_test("雨夜猫街", "雨夜猫街主题背景");
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ import type {
|
|||||||
PuzzleAgentSessionSnapshot,
|
PuzzleAgentSessionSnapshot,
|
||||||
SendPuzzleAgentMessageRequest,
|
SendPuzzleAgentMessageRequest,
|
||||||
} from '../../../packages/shared/src/contracts/puzzleAgentSession';
|
} from '../../../packages/shared/src/contracts/puzzleAgentSession';
|
||||||
|
import { isPuzzleCompileActionReady } from './puzzleDraftGenerationState';
|
||||||
import type { PuzzleCreativeTemplateSelection } from '../../../packages/shared/src/contracts/puzzleCreativeTemplate';
|
import type { PuzzleCreativeTemplateSelection } from '../../../packages/shared/src/contracts/puzzleCreativeTemplate';
|
||||||
import type {
|
import type {
|
||||||
PuzzleRunSnapshot,
|
PuzzleRunSnapshot,
|
||||||
@@ -6279,7 +6280,7 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
sessionController.setCreationTypeError(errorMessage);
|
sessionController.setCreationTypeError(errorMessage);
|
||||||
setPuzzleCreationError(errorMessage);
|
setPuzzleCreationError(errorMessage);
|
||||||
},
|
},
|
||||||
onActionComplete: async ({ payload, response, setSession }) => {
|
onActionComplete: async ({ payload, response, session, setSession }) => {
|
||||||
setPuzzleOperation(response.operation);
|
setPuzzleOperation(response.operation);
|
||||||
setSession(response.session);
|
setSession(response.session);
|
||||||
const formPayload = buildPuzzleFormPayloadFromAction(payload);
|
const formPayload = buildPuzzleFormPayloadFromAction(payload);
|
||||||
@@ -6303,6 +6304,47 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
|
|
||||||
if (payload.action === 'compile_puzzle_draft') {
|
if (payload.action === 'compile_puzzle_draft') {
|
||||||
const openResult = selectionStageRef.current === 'puzzle-generating';
|
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) =>
|
setPuzzleGenerationState((current) =>
|
||||||
current
|
current
|
||||||
? resolveFinishedMiniGameDraftGenerationState(current, 'ready', {
|
? resolveFinishedMiniGameDraftGenerationState(current, 'ready', {
|
||||||
@@ -7197,6 +7239,22 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
return;
|
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);
|
setPuzzleSession(latestSession);
|
||||||
setPuzzleBackgroundCompileTasks((current) => {
|
setPuzzleBackgroundCompileTasks((current) => {
|
||||||
const task = current[activePuzzleGenerationSessionId];
|
const task = current[activePuzzleGenerationSessionId];
|
||||||
@@ -7240,6 +7298,9 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
activePuzzleGenerationSessionId,
|
activePuzzleGenerationSessionId,
|
||||||
|
puzzleGenerationViewPayload,
|
||||||
|
puzzleGenerationViewState,
|
||||||
|
recoverCompletedPuzzleDraftGeneration,
|
||||||
shouldPollPuzzleGenerationSession,
|
shouldPollPuzzleGenerationSession,
|
||||||
setPuzzleSession,
|
setPuzzleSession,
|
||||||
]);
|
]);
|
||||||
@@ -9479,20 +9540,26 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
const executeSquareHoleAction = squareHoleFlow.executeAction;
|
const executeSquareHoleAction = squareHoleFlow.executeAction;
|
||||||
|
|
||||||
const retryMatch3DDraftGeneration = useCallback(() => {
|
const retryMatch3DDraftGeneration = useCallback(() => {
|
||||||
if (match3dFormDraftPayload && !match3dSession?.draft?.profileId) {
|
if (match3dSession?.sessionId) {
|
||||||
void createMatch3DDraftFromForm(match3dFormDraftPayload);
|
const retryPayload =
|
||||||
|
match3dFormDraftPayload ??
|
||||||
|
buildMatch3DFormPayloadFromSession(match3dSession);
|
||||||
|
void executeMatch3DAction({
|
||||||
|
action: 'match3d_compile_draft',
|
||||||
|
generateClickSound: retryPayload.generateClickSound,
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
void executeMatch3DAction({
|
if (match3dFormDraftPayload) {
|
||||||
action: 'match3d_compile_draft',
|
void createMatch3DDraftFromForm(match3dFormDraftPayload);
|
||||||
generateClickSound: match3dFormDraftPayload?.generateClickSound,
|
return;
|
||||||
});
|
}
|
||||||
}, [
|
}, [
|
||||||
createMatch3DDraftFromForm,
|
createMatch3DDraftFromForm,
|
||||||
executeMatch3DAction,
|
executeMatch3DAction,
|
||||||
match3dFormDraftPayload,
|
match3dFormDraftPayload,
|
||||||
match3dSession?.draft?.profileId,
|
match3dSession,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const retrySquareHoleAssetGeneration = useCallback(() => {
|
const retrySquareHoleAssetGeneration = useCallback(() => {
|
||||||
@@ -10405,15 +10472,25 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const retryPuzzleDraftGeneration = useCallback(() => {
|
const retryPuzzleDraftGeneration = useCallback(() => {
|
||||||
if (puzzleFormDraftPayload) {
|
if (puzzleSession?.sessionId) {
|
||||||
void createPuzzleDraftFromForm(puzzleFormDraftPayload);
|
const retryPayload =
|
||||||
|
puzzleFormDraftPayload ??
|
||||||
|
buildPuzzleFormPayloadFromSession(puzzleSession);
|
||||||
|
void executePuzzleAction(
|
||||||
|
buildPuzzleCompileActionFromFormPayload(retryPayload),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
void executePuzzleAction(
|
if (puzzleFormDraftPayload) {
|
||||||
buildPuzzleCompileActionFromFormPayload(puzzleFormDraftPayload),
|
void createPuzzleDraftFromForm(puzzleFormDraftPayload);
|
||||||
);
|
}
|
||||||
}, [createPuzzleDraftFromForm, executePuzzleAction, puzzleFormDraftPayload]);
|
}, [
|
||||||
|
createPuzzleDraftFromForm,
|
||||||
|
executePuzzleAction,
|
||||||
|
puzzleFormDraftPayload,
|
||||||
|
puzzleSession,
|
||||||
|
]);
|
||||||
|
|
||||||
const retryVisualNovelDraftGeneration = useCallback(() => {
|
const retryVisualNovelDraftGeneration = useCallback(() => {
|
||||||
if (!visualNovelFormDraftPayload) {
|
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);
|
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 () => {
|
test('running match3d persisted draft reopens progress instead of unfinished result', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const runningSession = buildMockMatch3DAgentSession({
|
const runningSession = buildMockMatch3DAgentSession({
|
||||||
@@ -4916,6 +5025,113 @@ test('failed parallel puzzle generations stay as separate non-generating drafts'
|
|||||||
.toBeTruthy();
|
.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 () => {
|
test('running puzzle draft opens generation progress from draft tab', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const runningSession = buildMockPuzzleAgentSession({
|
const runningSession = buildMockPuzzleAgentSession({
|
||||||
|
|||||||
Reference in New Issue
Block a user