Puzzle: support history images & partial generation
Allow history-generated image paths to be submitted where Data URLs were previously required and avoid treating partial/result-page generations as blocking the whole draft. Backend: resolve history /generated-* references via resolve_puzzle_reference_image_as_data_url and convert to PuzzleDownloadedImage; add PuzzleDownloadedImage::from_resolved_reference_image; extend draft handling to apply generated level metadata (auto-naming) and normalize generation_status to treat levels with images as ready. API: add shouldAutoNameLevel to action contracts and use it to request/refine generated level names. Spacetime/module and mappers: normalize completed level statuses when saving/reading so result-page background or per-level generation doesn't mask completed drafts. Frontend: expose resolver helpers, only mark a work as generating when no usable cover or ready level exists, keep level controls enabled during UI-background regeneration, and add tests covering history-image submission, auto-naming, and UI-background/partial-generation behaviors.
This commit is contained in:
@@ -936,6 +936,22 @@
|
||||
- 验证:`npm run test -- src/components/puzzle-agent/PuzzleAgentWorkspace.interaction.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx`,并执行 `npm run check:encoding`。
|
||||
- 关联:`src/services/puzzle-works/puzzleHistoryAsset.ts`、`src/components/puzzle-agent/PuzzleHistoryAssetPickerDialog.tsx`、`docs/technical/ASSET_HISTORY_PUZZLE_COVER_KIND_FIX_2026-04-27.md`。
|
||||
|
||||
## 拼图历史图关闭 AI 重绘不要强制 Data URL
|
||||
|
||||
- 现象:拼图创作页从历史生成图片中选择主图,再关闭 AI 重绘生成草稿时,后端报“上传图必须是图片 Data URL”。
|
||||
- 原因:历史图 `imageSrc` 是 `/generated-puzzle-assets/...` 私有兼容路径;AI 重绘开启时后端参考图分支会解析该路径,但关闭 AI 重绘的“直用上传图”分支旧实现只调用 `parse_puzzle_image_data_url`。
|
||||
- 处理:关闭 AI 重绘时也复用拼图参考图解析入口,允许 Data URL 与 `/generated-*` 历史路径统一转成 `PuzzleDownloadedImage` 后持久化;前端不需要下载历史图再转 base64。
|
||||
- 验证:`npm run test -- src/components/puzzle-agent/PuzzleAgentWorkspace.interaction.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx`、`cargo test -p api-server puzzle_uploaded_cover_can_reuse_resolved_history_image --manifest-path server-rs\Cargo.toml`、`npm run dev:api-server` 后检查 `/healthz`。
|
||||
- 关联:`server-rs/crates/api-server/src/puzzle/draft.rs`、`server-rs/crates/api-server/src/puzzle/vector_engine.rs`、`src/components/puzzle-agent/PuzzleAgentWorkspace.interaction.test.tsx`。
|
||||
|
||||
## 拼图结果页局部生图不要污染草稿生成态
|
||||
|
||||
- 现象:拼图草稿已经生成完成后,在结果页重新生成 UI 背景或追加关卡生成图片,草稿页仍显示整卡“生成中”,点击草稿会回到生成过程页,无法查看已有结果;UI 背景生成中还会禁用“新增关卡”和关卡图生成。
|
||||
- 原因:结果页局部 action 复用了全局 `isPuzzleBusy` / 持久化 `generationStatus=generating` 语义,作品架没有区分“初始草稿不可查看”和“已有结果上的局部关卡生成”。
|
||||
- 处理:作品架只在拼图没有可用封面、首关候选图或任一可查看关卡时才把 `generationStatus=generating` 解释为初始草稿生成;结果页 UI 背景和关卡图走 background action,不设置全局 busy,UI 背景只禁用自己的按钮;SpacetimeDB/API mapper 读写时把已有图片但状态仍是 `generating` 的历史关卡归一为 `ready`。
|
||||
- 验证:`npm run test -- src/components/custom-world-home/CustomWorldCreationHub.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx`、`cargo test -p api-server puzzle --manifest-path server-rs\Cargo.toml`。
|
||||
- 关联:`src/components/custom-world-home/creationWorkShelf.ts`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/puzzle-result/PuzzleResultView.tsx`、`server-rs/crates/api-server/src/puzzle/mappers.rs`、`server-rs/crates/spacetime-module/src/puzzle.rs`。
|
||||
|
||||
## Jenkins 数据库导入导出脚本先补 Node 工具链 PATH
|
||||
|
||||
- 现象:`Genarrative-Database-Import` 或 `Genarrative-Database-Export` 运行到迁移脚本时,`bash` 报 `node: command not found`,常见在日志里表现为某个 `sh` 块内第 61 行直接调用 `node` 失败。
|
||||
|
||||
@@ -31,13 +31,16 @@
|
||||
当前口径:
|
||||
|
||||
- 图像输入复用 `CreativeImageInputPanel`。
|
||||
- 支持画面描述生图、多参考图生图、上传主图后 AI 重绘、上传主图后不重绘。
|
||||
- 支持画面描述生图、多参考图生图、上传或历史生成主图后 AI 重绘、上传或历史生成主图后不重绘;关闭 AI 重绘时,前端可提交本地上传 Data URL 或历史 `/generated-*` 图片路径,后端统一解析为首关正式图后再持久化。
|
||||
- 草稿生成会先持久化 `generationStatus=generating` 的作品摘要,生成完成并回写关卡图、UI 背景后再变为 `ready`;首关关卡图和 UI 背景在命名稳定后并行启动,当前不自动生成背景音乐。
|
||||
- 作品架拼图草稿的“生成中”遮罩只表示初始草稿还没有可查看结果;只要作品摘要、首关封面或任一关卡候选图已经可用,后续 UI 背景重生成和追加关卡生图都必须作为结果页局部生成态处理,不能阻止打开草稿结果页。
|
||||
- 拼图草稿编译是长耗时 action,前端 action 请求默认等待 `1_000_000ms` 且不自动重试,生成页预计完成时间按 `5` 分钟展示;生成页恢复时必须沿用作品摘要 `updatedAt` 作为原始 `startedAtMs`,失败/完成态用 `finishedAtMs` 冻结耗时,不能在锁屏或返回草稿页后重新从 0 计时。
|
||||
- 若浏览器锁屏、息屏或网络切换导致 compile 请求失败,前端在标记失败前必须先复读 `getPuzzleAgentSession(sessionId)`;只有最新 session 仍缺 `draft.coverImageSrc`、首关 `coverImageSrc` 或候选图时才展示失败,复读到已生成草稿时按成功收尾、刷新作品架并继续自动试玩/结果页链路。
|
||||
- 拼图参考图 AI 重绘优先走 VectorEngine `/v1/images/edits`;若编辑接口超时,`api-server` 会降级为 `/v1/images/generations`,并把同一参考图塞进 `image` 数组继续生成,避免参考图草稿整单失败。
|
||||
- 结果页素材配置当前只保留 UI 相关能力;旧背景音乐入口隐藏。
|
||||
- 结果页允许多关卡并行编辑和生成;某一关卡图片生成完成回包只静默更新该关卡素材与生成态,不得自动打开或切换关卡详情面板,避免打断用户正在编辑的其它关卡。
|
||||
- 结果页 UI 背景重生成只禁用 UI 背景自己的按钮和确认动作,不禁用“新增关卡”、关卡图片生成、关卡详情编辑和结果页导航;关卡图片生成也只标记对应关卡的局部生成进度。
|
||||
- 结果页生成关卡图时若关卡名为空,前端必须传 `shouldAutoNameLevel=true`,后端复用首关命名契约先按画面描述生成关卡名,再在图片生成后用视觉命名结果精修,并把生成名和 UI 背景提示词随本次关卡快照写回。
|
||||
- 拼图 UI 背景是作品运行态背景,不只属于第一关;本地试玩、直达指定关卡和正式 `next-level` 推进时,目标关卡缺 `uiBackgroundImageSrc/uiBackgroundImageObjectKey` 必须继承同作品首个可用 UI 背景,仍缺失时才沿用当前运行态快照背景或默认 UI。
|
||||
- 拼图运行态棋盘不叠加分块蒙版、描边、阴影、选中底色或合并块 SVG 轮廓;拼图片本体需要裁切为圆角形状,单块使用独立圆角裁切,合并块使用 SVG 原生 `clipPath` 裁切整体外轮廓,外凸角和内凹角分别计算半径,内凹角半径要比外凸角更明显以避免手机 WebView 中看起来仍是直角。原图道具只在用户主动确认后打开独立原图查看层,不在当前拼图棋盘上叠加原图。
|
||||
- 拼图运行态拖拽必须完全跟随手指或鼠标位置,`pointermove` 期间即时写入可见拼块的 transform,不依赖等待后端回包、React 重渲染或下一帧动画队列;进入拖动后不展示拼块选中态或“已选择”提示,松手后再提交目标格同步规则真相。
|
||||
|
||||
@@ -76,6 +76,7 @@ export type PuzzleAgentActionRequest =
|
||||
imageModel?: string | null;
|
||||
aiRedraw?: boolean;
|
||||
candidateCount?: number;
|
||||
shouldAutoNameLevel?: boolean;
|
||||
workTitle?: string;
|
||||
workDescription?: string;
|
||||
summary?: string;
|
||||
|
||||
@@ -1415,13 +1415,22 @@ pub(crate) async fn compile_puzzle_draft_with_uploaded_cover(
|
||||
"message": "关闭 AI 重绘时必须上传拼图图片。",
|
||||
}))
|
||||
})?;
|
||||
let uploaded_image = parse_puzzle_image_data_url(uploaded_image_src).ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
"field": "referenceImageSrc",
|
||||
"message": "关闭 AI 重绘时上传图必须是图片 Data URL。",
|
||||
}))
|
||||
})?;
|
||||
let http_client = reqwest::Client::new();
|
||||
let uploaded_downloaded_image =
|
||||
resolve_puzzle_reference_image_as_data_url(state, &http_client, uploaded_image_src)
|
||||
.await
|
||||
.map(PuzzleDownloadedImage::from_resolved_reference_image)
|
||||
.map_err(|error| {
|
||||
if error.status_code() == StatusCode::BAD_REQUEST {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
"field": "referenceImageSrc",
|
||||
"message": "关闭 AI 重绘时上传图必须是图片 Data URL 或历史生成图片路径。",
|
||||
}))
|
||||
} else {
|
||||
error
|
||||
}
|
||||
})?;
|
||||
let compiled_session = state
|
||||
.spacetime_client()
|
||||
.compile_puzzle_agent_draft(session_id.clone(), owner_user_id.clone(), now)
|
||||
@@ -1446,11 +1455,6 @@ pub(crate) async fn compile_puzzle_draft_with_uploaded_cover(
|
||||
compiled_session.session_id,
|
||||
target_level.candidates.len() + 1
|
||||
);
|
||||
let uploaded_downloaded_image = PuzzleDownloadedImage {
|
||||
extension: puzzle_mime_to_extension(uploaded_image.mime_type.as_str()).to_string(),
|
||||
mime_type: normalize_puzzle_downloaded_image_mime_type(uploaded_image.mime_type.as_str()),
|
||||
bytes: uploaded_image.bytes,
|
||||
};
|
||||
let level_name_future =
|
||||
generate_puzzle_first_level_name(state, &target_level.picture_description);
|
||||
let image_level_name_future = generate_puzzle_first_level_name_from_image(
|
||||
@@ -1807,6 +1811,45 @@ pub(crate) fn apply_generated_puzzle_initial_metadata_to_session_snapshot(
|
||||
session
|
||||
}
|
||||
|
||||
pub(crate) fn apply_generated_puzzle_metadata_to_session_snapshot(
|
||||
mut session: PuzzleAgentSessionRecord,
|
||||
target_level_id: &str,
|
||||
metadata: &PuzzleLevelNaming,
|
||||
previous_level_name: &str,
|
||||
updated_at_micros: i64,
|
||||
) -> PuzzleAgentSessionRecord {
|
||||
let Some(draft) = session.draft.as_mut() else {
|
||||
return session;
|
||||
};
|
||||
let Some(target_index) = draft
|
||||
.levels
|
||||
.iter()
|
||||
.position(|level| level.level_id == target_level_id)
|
||||
.or_else(|| (!draft.levels.is_empty()).then_some(0))
|
||||
else {
|
||||
return session;
|
||||
};
|
||||
|
||||
draft.levels[target_index].level_name = metadata.level_name.clone();
|
||||
if metadata.ui_background_prompt.is_some() {
|
||||
draft.levels[target_index].ui_background_prompt = metadata.ui_background_prompt.clone();
|
||||
}
|
||||
|
||||
if target_index == 0 {
|
||||
apply_generated_puzzle_initial_metadata_to_draft(
|
||||
draft,
|
||||
metadata,
|
||||
previous_level_name,
|
||||
updated_at_micros,
|
||||
);
|
||||
} else {
|
||||
sync_puzzle_primary_draft_fields_from_level(draft);
|
||||
}
|
||||
|
||||
session.updated_at = format_timestamp_micros(updated_at_micros);
|
||||
session
|
||||
}
|
||||
|
||||
pub(crate) fn apply_generated_puzzle_initial_metadata_to_draft(
|
||||
draft: &mut PuzzleResultDraftRecord,
|
||||
metadata: &PuzzleLevelNaming,
|
||||
|
||||
@@ -769,6 +769,21 @@ pub async fn execute_puzzle_agent_action(
|
||||
payload.prompt_text.as_deref(),
|
||||
&target_level.picture_description,
|
||||
);
|
||||
let should_auto_name_level = payload
|
||||
.should_auto_name_level
|
||||
.unwrap_or_else(|| target_level.level_name.trim().is_empty());
|
||||
let mut generated_naming = if should_auto_name_level {
|
||||
let naming = generate_puzzle_first_level_name(
|
||||
&state,
|
||||
target_level.picture_description.as_str(),
|
||||
)
|
||||
.await;
|
||||
target_level.level_name = naming.level_name.clone();
|
||||
target_level.ui_background_prompt = naming.ui_background_prompt.clone();
|
||||
Some(naming)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let reference_image_sources = collect_puzzle_reference_image_sources(
|
||||
payload.reference_image_src.as_deref(),
|
||||
payload.reference_image_srcs.as_slice(),
|
||||
@@ -806,11 +821,14 @@ pub async fn execute_puzzle_agent_action(
|
||||
&candidates[0].downloaded_image,
|
||||
)
|
||||
.await
|
||||
.filter(|_| should_auto_name_level)
|
||||
{
|
||||
target_level.level_name = refined_naming.level_name;
|
||||
target_level.level_name = refined_naming.level_name.clone();
|
||||
if refined_naming.ui_background_prompt.is_some() {
|
||||
target_level.ui_background_prompt = refined_naming.ui_background_prompt;
|
||||
target_level.ui_background_prompt =
|
||||
refined_naming.ui_background_prompt.clone();
|
||||
}
|
||||
generated_naming = Some(refined_naming);
|
||||
}
|
||||
let generated_level_name = target_level.level_name.clone();
|
||||
let levels_json_with_generated_name =
|
||||
@@ -859,19 +877,36 @@ pub async fn execute_puzzle_agent_action(
|
||||
);
|
||||
let fallback_session =
|
||||
replace_puzzle_session_draft_snapshot(session, draft, now);
|
||||
Ok(apply_generated_puzzle_candidates_to_session_snapshot(
|
||||
let fallback_session = if should_auto_name_level {
|
||||
apply_generated_puzzle_first_level_name_to_session_snapshot(
|
||||
fallback_session,
|
||||
target_level.level_id.as_str(),
|
||||
generated_level_name.as_str(),
|
||||
fallback_level_name.as_str(),
|
||||
now,
|
||||
),
|
||||
target_level.level_id.as_str(),
|
||||
candidates.into_records(),
|
||||
primary_reference_image_src,
|
||||
now,
|
||||
))
|
||||
)
|
||||
} else {
|
||||
fallback_session
|
||||
};
|
||||
let mut fallback_session =
|
||||
apply_generated_puzzle_candidates_to_session_snapshot(
|
||||
fallback_session,
|
||||
target_level.level_id.as_str(),
|
||||
candidates.into_records(),
|
||||
primary_reference_image_src,
|
||||
now,
|
||||
);
|
||||
if let Some(generated_naming) = generated_naming.as_ref() {
|
||||
fallback_session =
|
||||
apply_generated_puzzle_metadata_to_session_snapshot(
|
||||
fallback_session,
|
||||
target_level.level_id.as_str(),
|
||||
generated_naming,
|
||||
fallback_level_name.as_str(),
|
||||
now,
|
||||
);
|
||||
}
|
||||
Ok(fallback_session)
|
||||
}
|
||||
Err(error) => Err(map_puzzle_client_error(error)),
|
||||
}
|
||||
|
||||
@@ -96,6 +96,7 @@ pub(super) fn map_puzzle_form_draft_response(
|
||||
pub(super) fn map_puzzle_draft_level_response(
|
||||
level: PuzzleDraftLevelRecord,
|
||||
) -> PuzzleDraftLevelResponse {
|
||||
let generation_status = resolve_puzzle_level_generation_status(&level);
|
||||
PuzzleDraftLevelResponse {
|
||||
level_id: level.level_id,
|
||||
level_name: level.level_name,
|
||||
@@ -115,7 +116,7 @@ pub(super) fn map_puzzle_draft_level_response(
|
||||
selected_candidate_id: level.selected_candidate_id,
|
||||
cover_image_src: level.cover_image_src,
|
||||
cover_asset_id: level.cover_asset_id,
|
||||
generation_status: level.generation_status,
|
||||
generation_status,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -279,23 +280,66 @@ pub(super) fn map_puzzle_result_preview_finding_response(
|
||||
}
|
||||
|
||||
fn resolve_puzzle_work_generation_status(item: &PuzzleWorkProfileRecord) -> Option<String> {
|
||||
let has_viewable_result = item
|
||||
.cover_image_src
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.is_some_and(|value| !value.is_empty())
|
||||
|| item.levels.iter().any(has_puzzle_level_image);
|
||||
if has_viewable_result {
|
||||
return Some("ready".to_string());
|
||||
}
|
||||
|
||||
item.levels
|
||||
.iter()
|
||||
.map(|level| level.generation_status.trim())
|
||||
.find(|status| *status == "generating")
|
||||
.map(resolve_puzzle_level_generation_status)
|
||||
.find(|status| status.as_str() == "generating")
|
||||
.or_else(|| {
|
||||
item.levels
|
||||
.iter()
|
||||
.map(|level| level.generation_status.trim())
|
||||
.find(|status| *status == "ready")
|
||||
.map(resolve_puzzle_level_generation_status)
|
||||
.find(|status| status.as_str() == "ready")
|
||||
})
|
||||
.or_else(|| {
|
||||
item.levels
|
||||
.iter()
|
||||
.map(|level| level.generation_status.trim())
|
||||
.map(resolve_puzzle_level_generation_status)
|
||||
.find(|status| !status.is_empty())
|
||||
})
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
fn resolve_puzzle_level_generation_status(level: &PuzzleDraftLevelRecord) -> String {
|
||||
if level.generation_status.trim() == "generating" && has_puzzle_level_image(level) {
|
||||
return "ready".to_string();
|
||||
}
|
||||
|
||||
level.generation_status.trim().to_string()
|
||||
}
|
||||
|
||||
fn has_puzzle_level_image(level: &PuzzleDraftLevelRecord) -> bool {
|
||||
let has_cover = level
|
||||
.cover_image_src
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.is_some_and(|value| !value.is_empty());
|
||||
let has_selected_candidate = level
|
||||
.selected_candidate_id
|
||||
.as_deref()
|
||||
.and_then(|candidate_id| {
|
||||
level
|
||||
.candidates
|
||||
.iter()
|
||||
.find(|candidate| candidate.candidate_id == candidate_id)
|
||||
})
|
||||
.map(|candidate| candidate.image_src.trim())
|
||||
.is_some_and(|value| !value.is_empty());
|
||||
let has_fallback_candidate = level
|
||||
.candidates
|
||||
.last()
|
||||
.map(|candidate| candidate.image_src.trim())
|
||||
.is_some_and(|value| !value.is_empty());
|
||||
|
||||
has_cover || has_selected_candidate || has_fallback_candidate
|
||||
}
|
||||
|
||||
pub(super) fn map_puzzle_work_summary_response(
|
||||
@@ -338,7 +382,11 @@ pub(super) fn map_puzzle_work_summary_response(
|
||||
.saturating_sub(item.point_incentive_claimed_points),
|
||||
publish_ready: item.publish_ready,
|
||||
generation_status,
|
||||
levels: item.levels.iter().map(|x|map_puzzle_draft_level_response(x.clone())).collect(),
|
||||
levels: item
|
||||
.levels
|
||||
.iter()
|
||||
.map(|x| map_puzzle_draft_level_response(x.clone()))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -603,4 +651,4 @@ pub(super) fn build_puzzle_welcome_text(seed_text: &str) -> String {
|
||||
}
|
||||
|
||||
"拼图创作信息已准备好。".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,6 +253,7 @@ fn puzzle_image_generation_builds_fallback_session_from_levels_snapshot() {
|
||||
image_model: Some(PUZZLE_IMAGE_MODEL_GPT_IMAGE_2.to_string()),
|
||||
ai_redraw: None,
|
||||
candidate_count: Some(1),
|
||||
should_auto_name_level: None,
|
||||
candidate_id: None,
|
||||
level_id: Some("puzzle-level-1".to_string()),
|
||||
work_title: Some("暖灯猫街作品".to_string()),
|
||||
@@ -376,6 +377,21 @@ fn puzzle_level_name_image_data_url_downsizes_generated_image() {
|
||||
assert!(data_url.len() > "data:image/png;base64,".len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_uploaded_cover_can_reuse_resolved_history_image() {
|
||||
let resolved = PuzzleResolvedReferenceImage {
|
||||
mime_type: "image/png".to_string(),
|
||||
bytes_len: 8,
|
||||
bytes: b"pngbytes".to_vec(),
|
||||
};
|
||||
|
||||
let downloaded = PuzzleDownloadedImage::from_resolved_reference_image(resolved);
|
||||
|
||||
assert_eq!(downloaded.extension, "png");
|
||||
assert_eq!(downloaded.mime_type, "image/png");
|
||||
assert_eq!(downloaded.bytes, b"pngbytes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_first_level_name_snapshot_defaults_work_title() {
|
||||
let levels_json = serde_json::to_string(&vec![json!({
|
||||
@@ -397,6 +413,7 @@ fn puzzle_first_level_name_snapshot_defaults_work_title() {
|
||||
image_model: Some(PUZZLE_IMAGE_MODEL_GPT_IMAGE_2.to_string()),
|
||||
ai_redraw: None,
|
||||
candidate_count: Some(1),
|
||||
should_auto_name_level: None,
|
||||
candidate_id: None,
|
||||
level_id: Some("puzzle-level-1".to_string()),
|
||||
work_title: Some("猫画面".to_string()),
|
||||
@@ -619,7 +636,7 @@ fn puzzle_work_summary_response_keeps_levels_for_shelf_cover() {
|
||||
selected_candidate_id: Some("candidate-1".to_string()),
|
||||
cover_image_src: Some("/generated-puzzle-assets/session/cover.png".to_string()),
|
||||
cover_asset_id: Some("asset-1".to_string()),
|
||||
generation_status: "ready".to_string(),
|
||||
generation_status: "generating".to_string(),
|
||||
};
|
||||
|
||||
let response = map_puzzle_work_summary_response(
|
||||
@@ -654,6 +671,7 @@ fn puzzle_work_summary_response_keeps_levels_for_shelf_cover() {
|
||||
|
||||
assert_eq!(response.levels.len(), 1);
|
||||
assert_eq!(response.generation_status.as_deref(), Some("ready"));
|
||||
assert_eq!(response.levels[0].generation_status, "ready");
|
||||
assert_eq!(
|
||||
response.levels[0].cover_image_src.as_deref(),
|
||||
Some("/generated-puzzle-assets/session/cover.png")
|
||||
|
||||
@@ -69,6 +69,16 @@ pub(crate) struct PuzzleDownloadedImage {
|
||||
pub(crate) bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
impl PuzzleDownloadedImage {
|
||||
pub(crate) fn from_resolved_reference_image(image: PuzzleResolvedReferenceImage) -> Self {
|
||||
Self {
|
||||
extension: puzzle_mime_to_extension(image.mime_type.as_str()).to_string(),
|
||||
mime_type: normalize_puzzle_downloaded_image_mime_type(image.mime_type.as_str()),
|
||||
bytes: image.bytes,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct ParsedPuzzleImageDataUrl {
|
||||
pub(crate) mime_type: String,
|
||||
pub(crate) bytes: Vec<u8>,
|
||||
|
||||
@@ -49,6 +49,8 @@ pub struct ExecutePuzzleAgentActionRequest {
|
||||
#[serde(default)]
|
||||
pub candidate_count: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub should_auto_name_level: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub candidate_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub level_id: Option<String>,
|
||||
|
||||
@@ -1063,6 +1063,7 @@ fn save_puzzle_generated_images_tx(
|
||||
if let Some(levels) = deserialize_optional_levels_input(input.levels_json.as_deref())? {
|
||||
// 中文注释:结果页新增关卡可能还没等到自动保存,生成图时以本次 action 携带的关卡快照作为写回目标。
|
||||
draft.levels = levels;
|
||||
draft = normalize_completed_puzzle_level_generation_status(draft);
|
||||
module_puzzle::sync_primary_level_fields(&mut draft);
|
||||
// 中文注释:入口直创会在 api-server 生成首关名后随 levels_json 写入;作品名仍是旧首关名或空值时才跟随首关名,避免覆盖用户手动命名。
|
||||
sync_generated_primary_level_name_as_default_work_title(
|
||||
@@ -1092,6 +1093,7 @@ fn save_puzzle_generated_images_tx(
|
||||
next_level.cover_asset_id = Some(selected.asset_id);
|
||||
}
|
||||
draft = replace_puzzle_level(&draft, next_level).map_err(|error| error.to_string())?;
|
||||
draft = normalize_completed_puzzle_level_generation_status(draft);
|
||||
|
||||
let saved_at = Timestamp::from_micros_since_unix_epoch(input.saved_at_micros);
|
||||
let next_stage = if build_result_preview(&draft, Some("陶泥儿主")).publish_ready {
|
||||
@@ -1143,6 +1145,7 @@ fn save_puzzle_ui_background_tx(
|
||||
if let Some(levels) = deserialize_optional_levels_input(input.levels_json.as_deref())? {
|
||||
// 中文注释:UI 背景可以在自动保存前立即生成,写回前优先使用本次 action 携带的关卡快照。
|
||||
draft.levels = levels;
|
||||
draft = normalize_completed_puzzle_level_generation_status(draft);
|
||||
module_puzzle::sync_primary_level_fields(&mut draft);
|
||||
}
|
||||
let target_level = selected_puzzle_level(&draft, input.level_id.as_deref())
|
||||
@@ -1155,6 +1158,7 @@ fn save_puzzle_ui_background_tx(
|
||||
(!trimmed.is_empty()).then_some(trimmed)
|
||||
});
|
||||
let draft = replace_puzzle_level(&draft, next_level).map_err(|error| error.to_string())?;
|
||||
let draft = normalize_completed_puzzle_level_generation_status(draft);
|
||||
|
||||
let saved_at = Timestamp::from_micros_since_unix_epoch(input.saved_at_micros);
|
||||
let next_stage = if build_result_preview(&draft, Some("陶泥儿主")).publish_ready {
|
||||
@@ -1208,6 +1212,52 @@ fn sync_generated_primary_level_name_as_default_work_title(
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_completed_puzzle_level_generation_status(
|
||||
mut draft: PuzzleResultDraft,
|
||||
) -> PuzzleResultDraft {
|
||||
draft.levels = normalize_completed_puzzle_levels_generation_status(draft.levels);
|
||||
module_puzzle::sync_primary_level_fields(&mut draft);
|
||||
draft
|
||||
}
|
||||
|
||||
fn normalize_completed_puzzle_levels_generation_status(
|
||||
mut levels: Vec<module_puzzle::PuzzleDraftLevel>,
|
||||
) -> Vec<module_puzzle::PuzzleDraftLevel> {
|
||||
for level in &mut levels {
|
||||
if level.generation_status.trim() == "generating" && has_completed_puzzle_level_image(level)
|
||||
{
|
||||
level.generation_status = "ready".to_string();
|
||||
}
|
||||
}
|
||||
levels
|
||||
}
|
||||
|
||||
fn has_completed_puzzle_level_image(level: &module_puzzle::PuzzleDraftLevel) -> bool {
|
||||
let has_cover = level
|
||||
.cover_image_src
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.is_some_and(|value| !value.is_empty());
|
||||
let has_selected_candidate = level
|
||||
.selected_candidate_id
|
||||
.as_deref()
|
||||
.and_then(|candidate_id| {
|
||||
level
|
||||
.candidates
|
||||
.iter()
|
||||
.find(|candidate| candidate.candidate_id == candidate_id)
|
||||
})
|
||||
.map(|candidate| candidate.image_src.trim())
|
||||
.is_some_and(|value| !value.is_empty());
|
||||
let has_fallback_candidate = level
|
||||
.candidates
|
||||
.last()
|
||||
.map(|candidate| candidate.image_src.trim())
|
||||
.is_some_and(|value| !value.is_empty());
|
||||
|
||||
has_cover || has_selected_candidate || has_fallback_candidate
|
||||
}
|
||||
|
||||
fn select_puzzle_cover_image_tx(
|
||||
ctx: &TxContext,
|
||||
input: PuzzleSelectCoverImageInput,
|
||||
@@ -1391,12 +1441,13 @@ fn update_puzzle_work_tx(
|
||||
if theme_tags.len() > PUZZLE_MAX_TAG_COUNT {
|
||||
return Err("拼图标签数量不合法".to_string());
|
||||
}
|
||||
let levels = deserialize_optional_levels_input(input.levels_json.as_deref())?
|
||||
let mut levels = deserialize_optional_levels_input(input.levels_json.as_deref())?
|
||||
.map(|levels| {
|
||||
normalize_puzzle_levels(levels, &theme_tags).map_err(|error| error.to_string())
|
||||
})
|
||||
.transpose()?
|
||||
.unwrap_or_else(|| build_profile_levels_from_row(&row).unwrap_or_default());
|
||||
levels = normalize_completed_puzzle_levels_generation_status(levels);
|
||||
let preview_draft = PuzzleResultDraft {
|
||||
work_title: input.work_title.clone(),
|
||||
work_description: input.work_description.clone(),
|
||||
@@ -2547,6 +2598,10 @@ fn build_puzzle_gallery_card_view_row(
|
||||
fn resolve_puzzle_gallery_generation_status(
|
||||
levels: &[module_puzzle::PuzzleDraftLevel],
|
||||
) -> Option<String> {
|
||||
if levels.iter().any(has_completed_puzzle_level_image) {
|
||||
return Some("ready".to_string());
|
||||
}
|
||||
|
||||
levels
|
||||
.iter()
|
||||
.map(|level| level.generation_status.trim())
|
||||
@@ -2822,6 +2877,7 @@ fn replace_puzzle_work_profile(
|
||||
}
|
||||
|
||||
fn upsert_puzzle_work_profile(ctx: &TxContext, profile: PuzzleWorkProfile) -> Result<(), String> {
|
||||
let levels = normalize_completed_puzzle_levels_generation_status(profile.levels);
|
||||
if let Some(existing) = ctx
|
||||
.db
|
||||
.puzzle_work_profile()
|
||||
@@ -2844,7 +2900,7 @@ fn upsert_puzzle_work_profile(ctx: &TxContext, profile: PuzzleWorkProfile) -> Re
|
||||
theme_tags_json: serialize_json(&profile.theme_tags),
|
||||
cover_image_src: profile.cover_image_src,
|
||||
cover_asset_id: profile.cover_asset_id,
|
||||
levels_json: serialize_json(&profile.levels),
|
||||
levels_json: serialize_json(&levels),
|
||||
publication_status: profile.publication_status,
|
||||
// 二次编辑发布同一个 profile 时,作品内容可以覆盖,但历史游玩数属于
|
||||
// 广场消费数据,不能因为重新发布被清零。
|
||||
@@ -2882,7 +2938,7 @@ fn upsert_puzzle_work_profile(ctx: &TxContext, profile: PuzzleWorkProfile) -> Re
|
||||
theme_tags_json: serialize_json(&profile.theme_tags),
|
||||
cover_image_src: profile.cover_image_src,
|
||||
cover_asset_id: profile.cover_asset_id,
|
||||
levels_json: serialize_json(&profile.levels),
|
||||
levels_json: serialize_json(&levels),
|
||||
publication_status: profile.publication_status,
|
||||
play_count: profile.play_count,
|
||||
remix_count: profile.remix_count,
|
||||
@@ -3532,7 +3588,9 @@ fn deserialize_levels_json(value: &str) -> Result<Vec<module_puzzle::PuzzleDraft
|
||||
if value.trim().is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
json_from_str(value).map_err(|error| format!("拼图 levels JSON 非法: {error}"))
|
||||
json_from_str(value)
|
||||
.map(normalize_completed_puzzle_levels_generation_status)
|
||||
.map_err(|error| format!("拼图 levels JSON 非法: {error}"))
|
||||
}
|
||||
|
||||
fn deserialize_optional_levels_input(
|
||||
|
||||
@@ -221,6 +221,81 @@ test('creation hub marks generating and newly completed drafts', () => {
|
||||
expect(html).toContain('creation-work-card__spinner');
|
||||
});
|
||||
|
||||
test('creation hub does not mask completed puzzle drafts while a later level image is generating', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<CustomWorldCreationHub
|
||||
items={[]}
|
||||
puzzleItems={[
|
||||
{
|
||||
workId: 'puzzle-work-session-1',
|
||||
profileId: 'puzzle-profile-session-1',
|
||||
ownerUserId: 'user-1',
|
||||
authorDisplayName: '测试作者',
|
||||
workTitle: '潮雾拼图草稿',
|
||||
workDescription: '已经有可查看的首关结果。',
|
||||
levelName: '潮雾拼图',
|
||||
summary: '已经有可查看的首关结果。',
|
||||
themeTags: ['潮雾'],
|
||||
coverImageSrc: '/generated-puzzle-assets/session/cover.png',
|
||||
publicationStatus: 'draft',
|
||||
updatedAt: new Date('2026-04-22T10:00:00.000Z').toISOString(),
|
||||
publishedAt: null,
|
||||
publishReady: false,
|
||||
sourceSessionId: 'puzzle-session-1',
|
||||
generationStatus: 'generating',
|
||||
levels: [
|
||||
{
|
||||
levelId: 'puzzle-level-1',
|
||||
levelName: '潮雾拼图',
|
||||
pictureDescription: '潮雾港口。',
|
||||
pictureReference: null,
|
||||
candidates: [
|
||||
{
|
||||
candidateId: 'candidate-1',
|
||||
imageSrc: '/generated-puzzle-assets/session/cover.png',
|
||||
assetId: 'asset-1',
|
||||
prompt: '潮雾港口',
|
||||
actualPrompt: null,
|
||||
sourceType: 'generated',
|
||||
selected: true,
|
||||
},
|
||||
],
|
||||
selectedCandidateId: 'candidate-1',
|
||||
coverImageSrc: '/generated-puzzle-assets/session/cover.png',
|
||||
coverAssetId: 'asset-1',
|
||||
generationStatus: 'ready',
|
||||
},
|
||||
{
|
||||
levelId: 'puzzle-level-2',
|
||||
levelName: '灯塔',
|
||||
pictureDescription: '灯塔新关卡。',
|
||||
pictureReference: null,
|
||||
candidates: [],
|
||||
selectedCandidateId: null,
|
||||
coverImageSrc: null,
|
||||
coverAssetId: null,
|
||||
generationStatus: 'generating',
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
loading={false}
|
||||
error={null}
|
||||
onRetry={() => {}}
|
||||
onCreateType={noopCreateType}
|
||||
onOpenDraft={() => {}}
|
||||
onEnterPublished={() => {}}
|
||||
entryConfig={testEntryConfig}
|
||||
creationTypes={testCreationTypes}
|
||||
onOpenPuzzleDetail={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).not.toContain('生成中...');
|
||||
expect(html).not.toContain('creation-work-card__spinner');
|
||||
expect(html).toContain('继续创作《潮雾拼图草稿》');
|
||||
});
|
||||
|
||||
test('creation hub published work uses unified list card layout', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<CustomWorldCreationHub
|
||||
|
||||
@@ -641,7 +641,7 @@ function isCreationTypeReferenceCoverImageSrc(value?: string | null) {
|
||||
);
|
||||
}
|
||||
|
||||
function resolvePuzzleWorkCoverImageSrc(item: PuzzleWorkSummary) {
|
||||
export function resolvePuzzleWorkCoverImageSrc(item: PuzzleWorkSummary) {
|
||||
const directCoverImageSrc = normalizeCoverImageSrc(item.coverImageSrc);
|
||||
if (
|
||||
directCoverImageSrc &&
|
||||
@@ -651,33 +651,44 @@ function resolvePuzzleWorkCoverImageSrc(item: PuzzleWorkSummary) {
|
||||
}
|
||||
|
||||
for (const level of item.levels ?? []) {
|
||||
const levelCoverImageSrc = normalizeCoverImageSrc(level.coverImageSrc);
|
||||
if (
|
||||
levelCoverImageSrc &&
|
||||
!isCreationTypeReferenceCoverImageSrc(levelCoverImageSrc)
|
||||
) {
|
||||
return levelCoverImageSrc;
|
||||
const levelImageSrc = resolvePuzzleLevelCoverImageSrc(level);
|
||||
if (levelImageSrc) {
|
||||
return levelImageSrc;
|
||||
}
|
||||
}
|
||||
|
||||
const selectedCandidateImageSrc =
|
||||
level.selectedCandidateId && level.candidates.length > 0
|
||||
? normalizeCoverImageSrc(
|
||||
level.candidates.find(
|
||||
(candidate) => candidate.candidateId === level.selectedCandidateId,
|
||||
)?.imageSrc,
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolvePuzzleLevelCoverImageSrc(
|
||||
level: NonNullable<PuzzleWorkSummary['levels']>[number],
|
||||
) {
|
||||
const levelCoverImageSrc = normalizeCoverImageSrc(level.coverImageSrc);
|
||||
if (
|
||||
levelCoverImageSrc &&
|
||||
!isCreationTypeReferenceCoverImageSrc(levelCoverImageSrc)
|
||||
) {
|
||||
return levelCoverImageSrc;
|
||||
}
|
||||
|
||||
const selectedCandidateImageSrc =
|
||||
level.selectedCandidateId && level.candidates.length > 0
|
||||
? normalizeCoverImageSrc(
|
||||
level.candidates.find(
|
||||
(candidate) => candidate.candidateId === level.selectedCandidateId,
|
||||
)?.imageSrc,
|
||||
)
|
||||
: null;
|
||||
const fallbackCandidateImageSrc = normalizeCoverImageSrc(
|
||||
level.candidates[level.candidates.length - 1]?.imageSrc,
|
||||
);
|
||||
const candidateImageSrc = selectedCandidateImageSrc || fallbackCandidateImageSrc;
|
||||
: null;
|
||||
const fallbackCandidateImageSrc = normalizeCoverImageSrc(
|
||||
level.candidates[level.candidates.length - 1]?.imageSrc,
|
||||
);
|
||||
const candidateImageSrc = selectedCandidateImageSrc || fallbackCandidateImageSrc;
|
||||
|
||||
if (
|
||||
candidateImageSrc &&
|
||||
!isCreationTypeReferenceCoverImageSrc(candidateImageSrc)
|
||||
) {
|
||||
return candidateImageSrc;
|
||||
}
|
||||
if (
|
||||
candidateImageSrc &&
|
||||
!isCreationTypeReferenceCoverImageSrc(candidateImageSrc)
|
||||
) {
|
||||
return candidateImageSrc;
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -804,12 +815,26 @@ function isPersistedCreationWorkGenerating(item: CreationWorkShelfItem) {
|
||||
case 'match3d':
|
||||
return item.source.item.generationStatus === 'generating';
|
||||
case 'puzzle':
|
||||
return item.source.item.generationStatus === 'generating';
|
||||
return isPersistedPuzzleDraftGenerating(item.source.item);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function isPersistedPuzzleDraftGenerating(item: PuzzleWorkSummary) {
|
||||
if (item.generationStatus !== 'generating') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const hasUsableCover = Boolean(resolvePuzzleWorkCoverImageSrc(item));
|
||||
const hasReadyLevel = (item.levels ?? []).some((level) =>
|
||||
Boolean(resolvePuzzleLevelCoverImageSrc(level)),
|
||||
);
|
||||
|
||||
// 中文注释:作品架“生成中”只表示初始草稿还没有可查看结果;结果页追加关卡或重绘局部图片不能锁住整张草稿卡。
|
||||
return !hasUsableCover && !hasReadyLevel;
|
||||
}
|
||||
|
||||
function buildRpgWorkShelfActions(
|
||||
item: CustomWorldWorkSummary,
|
||||
adapter: RpgWorkShelfAdapter,
|
||||
|
||||
@@ -300,7 +300,11 @@ import { PublishShareModal } from '../common/PublishShareModal';
|
||||
import type { PublishShareModalPayload } from '../common/publishShareModalModel';
|
||||
import { UnifiedModal } from '../common/UnifiedModal';
|
||||
import { resolveCreativeAgentTargetSelectionStage } from '../creative-agent/creativeAgentViewModel';
|
||||
import type { CreationWorkShelfItem } from '../custom-world-home/creationWorkShelf';
|
||||
import {
|
||||
isPersistedPuzzleDraftGenerating,
|
||||
resolvePuzzleWorkCoverImageSrc,
|
||||
type CreationWorkShelfItem,
|
||||
} from '../custom-world-home/creationWorkShelf';
|
||||
import {
|
||||
isBigFishGalleryEntry,
|
||||
isEdutainmentGalleryEntry,
|
||||
@@ -3403,9 +3407,12 @@ export function PlatformEntryFlowShellImpl({
|
||||
const notice = getDraftGenerationNotice(
|
||||
getGenerationNoticeShelfKeys(item),
|
||||
);
|
||||
const isNoticeGenerating =
|
||||
notice?.status === 'generating' &&
|
||||
(item.source.kind !== 'puzzle' ||
|
||||
!resolvePuzzleWorkCoverImageSrc(item.source.item));
|
||||
return {
|
||||
isGenerating:
|
||||
notice?.status === 'generating' || item.isGenerating === true,
|
||||
isGenerating: isNoticeGenerating || item.isGenerating === true,
|
||||
hasUnreadUpdate: notice?.status === 'ready' && !notice.seen,
|
||||
};
|
||||
},
|
||||
@@ -6495,7 +6502,10 @@ export function PlatformEntryFlowShellImpl({
|
||||
}
|
||||
}
|
||||
|
||||
if (payload.action === 'generate_puzzle_images') {
|
||||
if (
|
||||
payload.action === 'generate_puzzle_images' ||
|
||||
payload.action === 'generate_puzzle_ui_background'
|
||||
) {
|
||||
void executePuzzleBackgroundAction(payload);
|
||||
return;
|
||||
}
|
||||
@@ -8748,13 +8758,16 @@ export function PlatformEntryFlowShellImpl({
|
||||
buildPuzzleResultWorkId(item.sourceSessionId),
|
||||
buildPuzzleResultProfileId(item.sourceSessionId),
|
||||
]);
|
||||
const isMarkedGenerating = isDraftNoticeGenerating('puzzle', [
|
||||
const hasGeneratingNotice = isDraftNoticeGenerating('puzzle', [
|
||||
item.workId,
|
||||
item.profileId,
|
||||
item.sourceSessionId,
|
||||
buildPuzzleResultWorkId(item.sourceSessionId),
|
||||
buildPuzzleResultProfileId(item.sourceSessionId),
|
||||
]) || isPersistedDraftGenerating(item.generationStatus);
|
||||
]);
|
||||
const isMarkedGenerating =
|
||||
(hasGeneratingNotice && !resolvePuzzleWorkCoverImageSrc(item)) ||
|
||||
isPersistedPuzzleDraftGenerating(item);
|
||||
setPuzzleOperation(null);
|
||||
setPuzzleRun(null);
|
||||
setPuzzleRuntimeAuthMode('default');
|
||||
|
||||
@@ -535,6 +535,60 @@ test('puzzle workspace hides prompt and cost when AI redraw is off', async () =>
|
||||
});
|
||||
});
|
||||
|
||||
test('puzzle workspace submits history image when AI redraw is off', async () => {
|
||||
const onCreateFromForm = vi.fn();
|
||||
vi.mocked(puzzleAssetClient.listHistoryAssets).mockResolvedValue([
|
||||
{
|
||||
assetObjectId: 'asset-history-1',
|
||||
assetKind: 'puzzle_cover_image',
|
||||
imageSrc: '/generated-puzzle-assets/history/image.png',
|
||||
ownerUserId: 'user-1',
|
||||
ownerLabel: '账号 user-1',
|
||||
profileId: null,
|
||||
entityId: 'puzzle-session-1',
|
||||
createdAt: '1713686400.000000Z',
|
||||
updatedAt: '1713686400.000000Z',
|
||||
},
|
||||
]);
|
||||
|
||||
render(
|
||||
<PuzzleAgentWorkspace
|
||||
session={null}
|
||||
onBack={() => {}}
|
||||
onSubmitMessage={() => {}}
|
||||
onExecuteAction={() => {}}
|
||||
onCreateFromForm={onCreateFromForm}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '选择历史图片' }));
|
||||
const picker = await screen.findByRole('dialog', {
|
||||
name: '选择历史图片',
|
||||
});
|
||||
fireEvent.click(
|
||||
await within(picker).findByRole('button', { name: /image\.png/u }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('dialog', { name: '选择历史图片' })).toBeNull();
|
||||
});
|
||||
|
||||
const aiRedrawSwitch = screen.getByRole('switch', { name: 'AI重绘' });
|
||||
fireEvent.click(aiRedrawSwitch);
|
||||
expect(screen.queryByLabelText('画面AI重绘要求(提示词)')).toBeNull();
|
||||
expect(screen.queryByText('消耗2泥点')).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /生成拼图游戏草稿/u }));
|
||||
|
||||
expect(onCreateFromForm).toHaveBeenCalledWith({
|
||||
seedText: '历史素材 · image.png',
|
||||
pictureDescription: '历史素材 · image.png',
|
||||
referenceImageSrc: '/generated-puzzle-assets/history/image.png',
|
||||
referenceImageSrcs: [],
|
||||
imageModel: 'gpt-image-2',
|
||||
aiRedraw: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('puzzle workspace submits uploaded reference image when AI redraw is on', async () => {
|
||||
const onCreateFromForm = vi.fn();
|
||||
const uploadedDataUrl = 'data:image/png;base64,uploaded-square';
|
||||
|
||||
@@ -317,6 +317,7 @@ describe('PuzzleResultView', () => {
|
||||
imageModel: 'gpt-image-2',
|
||||
aiRedraw: true,
|
||||
candidateCount: 1,
|
||||
shouldAutoNameLevel: false,
|
||||
workTitle: '暖灯猫街作品',
|
||||
workDescription: '一套雨夜猫街主题拼图。',
|
||||
summary: '一套雨夜猫街主题拼图。',
|
||||
@@ -466,6 +467,7 @@ describe('PuzzleResultView', () => {
|
||||
imageModel: 'gpt-image-2',
|
||||
aiRedraw: true,
|
||||
candidateCount: 1,
|
||||
shouldAutoNameLevel: true,
|
||||
workTitle: '暖灯猫街作品',
|
||||
workDescription: '一套雨夜猫街主题拼图。',
|
||||
summary: '一套雨夜猫街主题拼图。',
|
||||
@@ -485,6 +487,42 @@ describe('PuzzleResultView', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test('requests automatic level naming when generating an unnamed level image', () => {
|
||||
vi.spyOn(Date, 'now').mockReturnValue(1_775_000_000_000);
|
||||
const onExecuteAction = vi.fn();
|
||||
|
||||
render(
|
||||
<PuzzleResultView
|
||||
session={createSession()}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={onExecuteAction}
|
||||
/>,
|
||||
);
|
||||
|
||||
openPuzzleLevelsTab();
|
||||
fireEvent.click(screen.getByRole('button', { name: /新增关卡/u }));
|
||||
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
|
||||
fireEvent.change(within(dialog).getByLabelText('画面描述'), {
|
||||
target: { value: '新关卡里有一座发光钟楼。' },
|
||||
});
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: /生成画面/u }));
|
||||
fireEvent.click(
|
||||
within(screen.getByRole('dialog', { name: '确认消耗泥点' })).getByRole(
|
||||
'button',
|
||||
{ name: '确定' },
|
||||
),
|
||||
);
|
||||
|
||||
expect(onExecuteAction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: 'generate_puzzle_images',
|
||||
levelId: 'puzzle-level-1775000000000-2',
|
||||
promptText: '新关卡里有一座发光钟楼。',
|
||||
shouldAutoNameLevel: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps generation progress visible after closing and reopening level dialog', () => {
|
||||
const onExecuteAction = vi.fn();
|
||||
|
||||
@@ -567,6 +605,90 @@ describe('PuzzleResultView', () => {
|
||||
).toHaveProperty('disabled', true);
|
||||
});
|
||||
|
||||
test('keeps level controls enabled while regenerating the UI background', () => {
|
||||
const onExecuteAction = vi.fn();
|
||||
|
||||
render(
|
||||
<PuzzleResultView
|
||||
session={createSession()}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={onExecuteAction}
|
||||
isBusy={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
|
||||
fireEvent.change(screen.getByLabelText('拼图UI背景提示词'), {
|
||||
target: { value: '雨夜猫街竖屏拼图UI背景' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /生成UI背景/u }));
|
||||
fireEvent.click(
|
||||
within(screen.getByRole('dialog', { name: /确认消耗泥点/u })).getByRole(
|
||||
'button',
|
||||
{ name: '确定' },
|
||||
),
|
||||
);
|
||||
|
||||
expect(onExecuteAction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: 'generate_puzzle_ui_background',
|
||||
promptText: '雨夜猫街竖屏拼图UI背景',
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
screen.getByRole('button', { name: /生成中/u }),
|
||||
).toHaveProperty('disabled', true);
|
||||
|
||||
openPuzzleLevelsTab();
|
||||
const addLevelButton = screen.getByRole('button', { name: /新增关卡/u });
|
||||
expect(addLevelButton).toHaveProperty('disabled', false);
|
||||
fireEvent.click(addLevelButton);
|
||||
expect(screen.getByRole('dialog', { name: '关卡详情' })).toBeTruthy();
|
||||
});
|
||||
|
||||
test('restores UI background generate button when background generation fails', () => {
|
||||
const onExecuteAction = vi.fn();
|
||||
const { rerender } = render(
|
||||
<PuzzleResultView
|
||||
session={createSession()}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={onExecuteAction}
|
||||
isBusy={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
|
||||
fireEvent.change(screen.getByLabelText('拼图UI背景提示词'), {
|
||||
target: { value: '雨夜猫街竖屏拼图UI背景' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /生成UI背景/u }));
|
||||
fireEvent.click(
|
||||
within(screen.getByRole('dialog', { name: /确认消耗泥点/u })).getByRole(
|
||||
'button',
|
||||
{ name: '确定' },
|
||||
),
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: /生成中/u })).toHaveProperty(
|
||||
'disabled',
|
||||
true,
|
||||
);
|
||||
|
||||
rerender(
|
||||
<PuzzleResultView
|
||||
session={createSession()}
|
||||
error="UI背景生成失败"
|
||||
onBack={() => {}}
|
||||
onExecuteAction={onExecuteAction}
|
||||
isBusy={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
const generateButton = screen.getByRole('button', { name: /生成UI背景/u });
|
||||
expect(generateButton).toHaveProperty('disabled', false);
|
||||
expect(screen.queryByRole('button', { name: /生成中/u })).toBeNull();
|
||||
});
|
||||
|
||||
test('keeps the current level dialog open when another level generation completes', () => {
|
||||
const base = createSession();
|
||||
const firstLevel = base.draft!.levels![0]!;
|
||||
@@ -1143,6 +1265,7 @@ describe('PuzzleResultView', () => {
|
||||
imageModel: 'gpt-image-2',
|
||||
aiRedraw: true,
|
||||
candidateCount: 1,
|
||||
shouldAutoNameLevel: false,
|
||||
workTitle: '暖灯猫街作品',
|
||||
workDescription: '一套雨夜猫街主题拼图。',
|
||||
summary: '一套雨夜猫街主题拼图。',
|
||||
|
||||
@@ -93,6 +93,11 @@ type PuzzleLevelGenerationRuntime = {
|
||||
estimateSeconds: number;
|
||||
};
|
||||
|
||||
type PuzzleUiBackgroundGenerationState = {
|
||||
levelId: string;
|
||||
prompt: string;
|
||||
} | null;
|
||||
|
||||
function resolvePuzzleLevelGenerationProgress(
|
||||
level: PuzzleDraftLevel,
|
||||
runtime: PuzzleLevelGenerationRuntime | null,
|
||||
@@ -1409,16 +1414,22 @@ function PuzzleUiAssetsTab({
|
||||
editState,
|
||||
imageRefreshKey,
|
||||
isBusy,
|
||||
uiBackgroundGeneration,
|
||||
onChange,
|
||||
onGenerate,
|
||||
}: {
|
||||
editState: DraftEditState;
|
||||
imageRefreshKey: string;
|
||||
isBusy: boolean;
|
||||
uiBackgroundGeneration: PuzzleUiBackgroundGenerationState;
|
||||
onChange: (nextState: DraftEditState) => void;
|
||||
onGenerate: (prompt: string) => void;
|
||||
}) {
|
||||
const firstLevel = editState.levels[0] ?? null;
|
||||
const isGeneratingUiBackground = Boolean(
|
||||
firstLevel &&
|
||||
uiBackgroundGeneration?.levelId === firstLevel.levelId,
|
||||
);
|
||||
const formalImageSrc = firstLevel ? resolveLevelFormalImageSrc(firstLevel) : '';
|
||||
const defaultPrompt = buildDefaultPuzzleUiBackgroundPrompt(
|
||||
editState,
|
||||
@@ -1490,21 +1501,30 @@ function PuzzleUiAssetsTab({
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!firstLevel || !normalizedPrompt || isBusy}
|
||||
disabled={
|
||||
!firstLevel ||
|
||||
!normalizedPrompt ||
|
||||
isBusy ||
|
||||
isGeneratingUiBackground
|
||||
}
|
||||
onClick={() => {
|
||||
if (!firstLevel || !normalizedPrompt) {
|
||||
return;
|
||||
}
|
||||
setIsCostConfirmOpen(true);
|
||||
}}
|
||||
className={`platform-button platform-button--primary min-h-11 justify-center gap-2 px-4 py-3 ${!firstLevel || !normalizedPrompt || isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
|
||||
className={`platform-button platform-button--primary min-h-11 justify-center gap-2 px-4 py-3 ${!firstLevel || !normalizedPrompt || isBusy || isGeneratingUiBackground ? 'cursor-not-allowed opacity-55' : ''}`}
|
||||
>
|
||||
{isBusy ? (
|
||||
{isBusy || isGeneratingUiBackground ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Wand2 className="h-4 w-4" />
|
||||
)}
|
||||
{hasGeneratedUiBackground ? '重新生成' : '生成UI背景'} · {PUZZLE_IMAGE_GENERATION_POINT_COST}泥点
|
||||
{isGeneratingUiBackground
|
||||
? '生成中'
|
||||
: hasGeneratedUiBackground
|
||||
? '重新生成'
|
||||
: '生成UI背景'} · {PUZZLE_IMAGE_GENERATION_POINT_COST}泥点
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1547,7 +1567,12 @@ function PuzzleUiAssetsTab({
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!firstLevel || !normalizedPrompt || isBusy}
|
||||
disabled={
|
||||
!firstLevel ||
|
||||
!normalizedPrompt ||
|
||||
isBusy ||
|
||||
isGeneratingUiBackground
|
||||
}
|
||||
onClick={() => {
|
||||
if (!firstLevel || !normalizedPrompt) {
|
||||
return;
|
||||
@@ -1559,7 +1584,7 @@ function PuzzleUiAssetsTab({
|
||||
setIsCostConfirmOpen(false);
|
||||
onGenerate(normalizedPrompt);
|
||||
}}
|
||||
className={`platform-button platform-button--primary justify-center ${!firstLevel || !normalizedPrompt || isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
|
||||
className={`platform-button platform-button--primary justify-center ${!firstLevel || !normalizedPrompt || isBusy || isGeneratingUiBackground ? 'cursor-not-allowed opacity-55' : ''}`}
|
||||
>
|
||||
确定
|
||||
</button>
|
||||
@@ -1696,6 +1721,7 @@ function PuzzleAssetConfigTab({
|
||||
editState,
|
||||
imageRefreshKey,
|
||||
isBusy,
|
||||
uiBackgroundGeneration,
|
||||
onAssetConfigTabChange,
|
||||
onChange,
|
||||
onGenerateUiBackground,
|
||||
@@ -1704,6 +1730,7 @@ function PuzzleAssetConfigTab({
|
||||
editState: DraftEditState;
|
||||
imageRefreshKey: string;
|
||||
isBusy: boolean;
|
||||
uiBackgroundGeneration: PuzzleUiBackgroundGenerationState;
|
||||
onAssetConfigTabChange: (tab: PuzzleAssetConfigTabId) => void;
|
||||
onChange: (nextState: DraftEditState) => void;
|
||||
onGenerateUiBackground: (prompt: string) => void;
|
||||
@@ -1719,6 +1746,7 @@ function PuzzleAssetConfigTab({
|
||||
editState={editState}
|
||||
imageRefreshKey={imageRefreshKey}
|
||||
isBusy={isBusy}
|
||||
uiBackgroundGeneration={uiBackgroundGeneration}
|
||||
onChange={onChange}
|
||||
onGenerate={onGenerateUiBackground}
|
||||
/>
|
||||
@@ -1829,6 +1857,8 @@ export function PuzzleResultView({
|
||||
const [tagGenerationError, setTagGenerationError] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [uiBackgroundGeneration, setUiBackgroundGeneration] =
|
||||
useState<PuzzleUiBackgroundGenerationState>(null);
|
||||
const [generationRuntimeByLevelId, setGenerationRuntimeByLevelId] = useState<
|
||||
Record<string, PuzzleLevelGenerationRuntime>
|
||||
>({});
|
||||
@@ -1844,11 +1874,18 @@ export function PuzzleResultView({
|
||||
latestEditStateRef.current = editState;
|
||||
}, [editState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
setUiBackgroundGeneration(null);
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!draft) {
|
||||
setEditState(null);
|
||||
latestEditStateRef.current = null;
|
||||
setActiveLevelId(null);
|
||||
setUiBackgroundGeneration(null);
|
||||
setAutoSaveState('idle');
|
||||
setAutoSaveError(null);
|
||||
setTagGenerationError(null);
|
||||
@@ -1884,6 +1921,19 @@ export function PuzzleResultView({
|
||||
setAutoSaveState('idle');
|
||||
setAutoSaveError(null);
|
||||
setTagGenerationError(null);
|
||||
setUiBackgroundGeneration((current) => {
|
||||
if (
|
||||
current &&
|
||||
mergedState.levels.some(
|
||||
(level) =>
|
||||
level.levelId === current.levelId &&
|
||||
resolvePuzzleUiBackgroundSource(level),
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return current;
|
||||
});
|
||||
}, [draft]);
|
||||
|
||||
const syncedDraft = useMemo(() => {
|
||||
@@ -2163,6 +2213,7 @@ export function PuzzleResultView({
|
||||
editState={editState}
|
||||
imageRefreshKey={imageRefreshKey}
|
||||
isBusy={isBusy}
|
||||
uiBackgroundGeneration={uiBackgroundGeneration}
|
||||
onAssetConfigTabChange={setActiveAssetConfigTab}
|
||||
onChange={setEditState}
|
||||
onGenerateUiBackground={(prompt) => {
|
||||
@@ -2170,6 +2221,10 @@ export function PuzzleResultView({
|
||||
if (!firstLevel) {
|
||||
return;
|
||||
}
|
||||
setUiBackgroundGeneration({
|
||||
levelId: firstLevel.levelId,
|
||||
prompt,
|
||||
});
|
||||
onExecuteAction({
|
||||
action: 'generate_puzzle_ui_background',
|
||||
levelId: firstLevel.levelId,
|
||||
@@ -2256,6 +2311,7 @@ export function PuzzleResultView({
|
||||
imageModel: imageModel ?? PUZZLE_IMAGE_MODEL_GPT_IMAGE_2,
|
||||
aiRedraw: true,
|
||||
candidateCount: 1,
|
||||
shouldAutoNameLevel: !nextLevel.levelName.trim(),
|
||||
workTitle: editState.workTitle.trim(),
|
||||
workDescription: editState.workDescription.trim(),
|
||||
summary: editState.workDescription.trim(),
|
||||
|
||||
Reference in New Issue
Block a user