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:
2026-05-19 10:02:13 +08:00
parent 5e03b3d2f2
commit 7b37271f17
16 changed files with 653 additions and 73 deletions

View File

@@ -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不设置全局 busyUI 背景只禁用自己的按钮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` 失败。

View File

@@ -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 重渲染或下一帧动画队列;进入拖动后不展示拼块选中态或“已选择”提示,松手后再提交目标格同步规则真相。

View File

@@ -76,6 +76,7 @@ export type PuzzleAgentActionRequest =
imageModel?: string | null;
aiRedraw?: boolean;
candidateCount?: number;
shouldAutoNameLevel?: boolean;
workTitle?: string;
workDescription?: string;
summary?: string;

View File

@@ -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,

View File

@@ -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)),
}

View File

@@ -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()
}
}

View File

@@ -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")

View File

@@ -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>,

View File

@@ -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>,

View File

@@ -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(

View File

@@ -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

View File

@@ -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,

View File

@@ -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');

View File

@@ -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';

View File

@@ -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: '一套雨夜猫街主题拼图。',

View File

@@ -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(),