Merge remote-tracking branch 'origin/master'
This commit is contained in:
@@ -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>,
|
||||
|
||||
Reference in New Issue
Block a user