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

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