use super::*; pub(crate) fn build_puzzle_form_seed_text(payload: &CreatePuzzleAgentSessionRequest) -> String { build_puzzle_form_seed_prompt(PuzzleFormSeedPromptParts { title: None, work_description: None, picture_description: payload .picture_description .as_deref() .or(payload.seed_text.as_deref()), }) } pub(crate) fn build_puzzle_form_seed_text_from_parts( title: Option<&str>, work_description: Option<&str>, picture_description: Option<&str>, ) -> String { build_puzzle_form_seed_prompt(PuzzleFormSeedPromptParts { title, work_description, picture_description, }) } pub(crate) async fn save_puzzle_form_payload_before_compile( state: &AppState, request_context: &RequestContext, session_id: &str, owner_user_id: &str, payload: &ExecutePuzzleAgentActionRequest, now: i64, ) -> Result { let seed_text = build_puzzle_form_seed_text_from_parts( None, None, payload .picture_description .as_deref() .or(payload.prompt_text.as_deref()), ); if seed_text.trim().is_empty() { return Ok(session_id.to_string()); } let save_result = state .spacetime_client() .save_puzzle_form_draft(PuzzleFormDraftSaveRecordInput { session_id: session_id.to_string(), owner_user_id: owner_user_id.to_string(), seed_text: seed_text.clone(), saved_at_micros: now, }) .await .map(|_| ()); match save_result { Ok(()) => Ok(session_id.to_string()), Err(error) if is_missing_puzzle_form_draft_procedure_error(&error) => { create_seeded_puzzle_session_when_form_save_missing( state, request_context, session_id, owner_user_id, seed_text, now, &error, ) .await } Err(error) => Err(puzzle_error_response( request_context, PUZZLE_AGENT_API_BASE_PROVIDER, map_puzzle_client_error(error), )), } } pub(crate) async fn create_seeded_puzzle_session_when_form_save_missing( state: &AppState, request_context: &RequestContext, session_id: &str, owner_user_id: &str, seed_text: String, now: i64, original_error: &SpacetimeClientError, ) -> Result { let current_session = state .spacetime_client() .get_puzzle_agent_session(session_id.to_string(), owner_user_id.to_string()) .await .map_err(|error| { puzzle_error_response( request_context, PUZZLE_AGENT_API_BASE_PROVIDER, map_puzzle_client_error(error), ) })?; if !current_session.seed_text.trim().is_empty() { tracing::warn!( provider = PUZZLE_AGENT_API_BASE_PROVIDER, session_id, owner_user_id, error = %original_error, "拼图表单草稿保存 procedure 缺失,沿用已有 seed_text 编译" ); return Ok(session_id.to_string()); } // 中文注释:旧 wasm 缺自动保存 procedure 时,空 session 无法被编译;这里重建带表单 seed 的 session 保证生成主链可继续。 let replacement_session_id = build_prefixed_uuid_id("puzzle-session-"); let replacement = state .spacetime_client() .create_puzzle_agent_session(PuzzleAgentSessionCreateRecordInput { session_id: replacement_session_id.clone(), owner_user_id: owner_user_id.to_string(), seed_text: seed_text.clone(), welcome_message_id: build_prefixed_uuid_id("puzzle-message-"), welcome_message_text: build_puzzle_welcome_text(&seed_text), created_at_micros: now, }) .await .map_err(|error| { puzzle_error_response( request_context, PUZZLE_AGENT_API_BASE_PROVIDER, map_puzzle_client_error(error), ) })?; tracing::warn!( provider = PUZZLE_AGENT_API_BASE_PROVIDER, old_session_id = %session_id, new_session_id = %replacement.session_id, owner_user_id, error = %original_error, "拼图表单草稿保存 procedure 缺失,已创建带表单 seed 的替代 session" ); Ok(replacement.session_id) } pub(crate) fn select_puzzle_level_for_api( draft: &PuzzleResultDraftRecord, level_id: Option<&str>, ) -> Result { let normalized_level_id = level_id.map(str::trim).filter(|value| !value.is_empty()); if let Some(target_id) = normalized_level_id { return draft .levels .iter() .find(|level| level.level_id == target_id) .cloned() .ok_or_else(|| { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": PUZZLE_AGENT_API_BASE_PROVIDER, "message": format!("拼图关卡不存在:{target_id}"), })) }); } let level = draft.levels.first().cloned(); level.ok_or_else(|| { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": PUZZLE_AGENT_API_BASE_PROVIDER, "message": "拼图草稿缺少可编辑关卡", })) }) } pub(crate) fn parse_puzzle_level_records_from_module_json( value: &str, ) -> Result, AppError> { let levels: Vec = serde_json::from_str(value).map_err(|error| { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": PUZZLE_AGENT_API_BASE_PROVIDER, "message": format!("拼图关卡列表 JSON 非法:{error}"), })) })?; Ok(levels .into_iter() .map(|level| PuzzleDraftLevelRecord { level_id: level.level_id, level_name: level.level_name, picture_description: level.picture_description, picture_reference: level.picture_reference, ui_background_prompt: level.ui_background_prompt, ui_background_image_src: level.ui_background_image_src, ui_background_image_object_key: level.ui_background_image_object_key, background_music: level .background_music .map(map_puzzle_audio_asset_domain_record), candidates: level .candidates .into_iter() .map(|candidate| PuzzleGeneratedImageCandidateRecord { candidate_id: candidate.candidate_id, image_src: candidate.image_src, asset_id: candidate.asset_id, prompt: candidate.prompt, actual_prompt: candidate.actual_prompt, source_type: candidate.source_type, selected: candidate.selected, }) .collect(), 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, }) .collect()) } pub(crate) async fn get_puzzle_session_for_image_generation( state: &AppState, session_id: String, owner_user_id: String, payload: &ExecutePuzzleAgentActionRequest, normalized_levels_json: Option<&str>, now: i64, ) -> Result { match state .spacetime_client() .get_puzzle_agent_session(session_id.clone(), owner_user_id.clone()) .await { Ok(session) => Ok(session), Err(error) if should_skip_asset_operation_billing_for_connectivity(&error) => { // 中文注释:结果页已经带有当前草稿快照;Maincloud 读取 session 短暂 503 时不应阻断外部生图。 let fallback_session = build_puzzle_session_snapshot_from_action_payload( session_id.as_str(), payload, normalized_levels_json, now, )?; tracing::warn!( provider = PUZZLE_AGENT_API_BASE_PROVIDER, session_id = %session_id, owner_user_id = %owner_user_id, error = %error, "拼图图片生成读取 session 因 SpacetimeDB 连接不可用而降级使用前端草稿快照" ); Ok(fallback_session) } Err(error) => Err(map_puzzle_client_error(error)), } } pub(crate) fn build_puzzle_session_snapshot_from_action_payload( session_id: &str, payload: &ExecutePuzzleAgentActionRequest, normalized_levels_json: Option<&str>, now: i64, ) -> Result { let levels_json = normalized_levels_json.ok_or_else(|| { AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ "provider": "spacetimedb", "message": "SpacetimeDB 暂不可用,且请求缺少拼图关卡快照,无法继续生成图片", })) })?; let levels = parse_puzzle_level_records_from_module_json(levels_json)?; let first_level = levels.first().cloned().ok_or_else(|| { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": PUZZLE_AGENT_API_BASE_PROVIDER, "message": "拼图草稿缺少可编辑关卡", })) })?; let work_title = payload .work_title .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or(first_level.level_name.as_str()) .to_string(); let work_description = payload .work_description .as_deref() .map(str::trim) .unwrap_or_default() .to_string(); let summary = payload .summary .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or(first_level.picture_description.as_str()) .to_string(); let theme_tags = payload.theme_tags.clone().unwrap_or_default(); let anchor_pack = map_puzzle_domain_anchor_pack(module_puzzle::empty_anchor_pack()); let draft = PuzzleResultDraftRecord { work_title, work_description, level_name: first_level.level_name.clone(), summary, theme_tags, forbidden_directives: Vec::new(), creator_intent: None, anchor_pack: anchor_pack.clone(), candidates: first_level.candidates.clone(), selected_candidate_id: first_level.selected_candidate_id.clone(), cover_image_src: first_level.cover_image_src.clone(), cover_asset_id: first_level.cover_asset_id.clone(), generation_status: first_level.generation_status.clone(), levels, form_draft: None, }; Ok(PuzzleAgentSessionRecord { session_id: session_id.to_string(), seed_text: String::new(), current_turn: 0, progress_percent: 94, stage: "ready_to_publish".to_string(), anchor_pack, draft: Some(draft), messages: Vec::new(), last_assistant_reply: None, published_profile_id: None, suggested_actions: Vec::new(), result_preview: None, updated_at: format_timestamp_micros(now), }) } pub(crate) fn map_puzzle_domain_anchor_pack( anchor_pack: module_puzzle::PuzzleAnchorPack, ) -> PuzzleAnchorPackRecord { PuzzleAnchorPackRecord { theme_promise: map_puzzle_domain_anchor_item(anchor_pack.theme_promise), visual_subject: map_puzzle_domain_anchor_item(anchor_pack.visual_subject), visual_mood: map_puzzle_domain_anchor_item(anchor_pack.visual_mood), composition_hooks: map_puzzle_domain_anchor_item(anchor_pack.composition_hooks), tags_and_forbidden: map_puzzle_domain_anchor_item(anchor_pack.tags_and_forbidden), } } pub(crate) fn map_puzzle_domain_anchor_item( anchor: module_puzzle::PuzzleAnchorItem, ) -> PuzzleAnchorItemRecord { PuzzleAnchorItemRecord { key: anchor.key, label: anchor.label, value: anchor.value, status: anchor.status.as_str().to_string(), } } pub(crate) fn serialize_puzzle_levels_response( request_context: &RequestContext, levels: &[PuzzleDraftLevelResponse], ) -> Result { let payload = levels .iter() .map(|level| { json!({ "level_id": level.level_id, "level_name": level.level_name, "picture_description": level.picture_description, "picture_reference": level.picture_reference, "ui_background_prompt": level.ui_background_prompt, "ui_background_image_src": level.ui_background_image_src, "ui_background_image_object_key": level.ui_background_image_object_key, "background_music": puzzle_audio_asset_response_module_json(&level.background_music), "candidates": level .candidates .iter() .map(|candidate| { json!({ "candidate_id": candidate.candidate_id, "image_src": candidate.image_src, "asset_id": candidate.asset_id, "prompt": candidate.prompt, "actual_prompt": candidate.actual_prompt, "source_type": candidate.source_type, "selected": candidate.selected, }) }) .collect::>(), "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, }) }) .collect::>(); serde_json::to_string(&payload).map_err(|error| { puzzle_error_response( request_context, PUZZLE_WORKS_PROVIDER, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": PUZZLE_WORKS_PROVIDER, "message": format!("拼图关卡列表序列化失败:{error}"), })), ) }) } pub(crate) fn normalize_puzzle_levels_json_for_module( value: Option<&str>, ) -> Result, String> { let Some(raw) = value.map(str::trim).filter(|raw| !raw.is_empty()) else { return Ok(None); }; let levels: Vec = serde_json::from_str(raw).map_err(|error| format!("拼图关卡列表 JSON 非法:{error}"))?; let payload = levels .iter() .map(|level| { json!({ "level_id": level.level_id, "level_name": level.level_name, "picture_description": level.picture_description, "picture_reference": level.picture_reference, "ui_background_prompt": level.ui_background_prompt, "ui_background_image_src": level.ui_background_image_src, "ui_background_image_object_key": level.ui_background_image_object_key, "background_music": puzzle_audio_asset_response_module_json(&level.background_music), "candidates": level .candidates .iter() .map(|candidate| { json!({ "candidate_id": candidate.candidate_id, "image_src": candidate.image_src, "asset_id": candidate.asset_id, "prompt": candidate.prompt, "actual_prompt": candidate.actual_prompt, "source_type": candidate.source_type, "selected": candidate.selected, }) }) .collect::>(), "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, }) }) .collect::>(); serde_json::to_string(&payload) .map(Some) .map_err(|error| format!("拼图关卡列表序列化失败:{error}")) } pub(crate) fn build_stable_puzzle_work_ids(session_id: &str) -> (String, String) { let stable_suffix = session_id .strip_prefix("puzzle-session-") .unwrap_or(session_id); ( format!("puzzle-work-{stable_suffix}"), format!("puzzle-profile-{stable_suffix}"), ) } #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) struct PuzzleLevelNaming { pub(crate) level_name: String, pub(crate) work_description: Option, pub(crate) work_tags: Vec, pub(crate) ui_background_prompt: Option, } impl PuzzleLevelNaming { fn fallback(picture_description: &str) -> Self { Self { level_name: build_fallback_puzzle_first_level_name(picture_description), work_description: None, work_tags: Vec::new(), ui_background_prompt: None, } } } pub(crate) async fn generate_puzzle_first_level_name( state: &AppState, picture_description: &str, ) -> PuzzleLevelNaming { if let Some(llm_client) = state.llm_client() { let user_prompt = build_puzzle_first_level_name_user_prompt(picture_description); let response = llm_client .request_text( LlmTextRequest::new(vec![ LlmMessage::system(PUZZLE_FIRST_LEVEL_NAME_SYSTEM_PROMPT), LlmMessage::user(user_prompt), ]) .with_model(CREATION_TEMPLATE_LLM_MODEL) .with_responses_api(), ) .await; match response { Ok(response) => { if let Some(naming) = parse_puzzle_level_naming_from_text(response.content.as_str()) { return naming; } tracing::warn!( provider = PUZZLE_AGENT_API_BASE_PROVIDER, picture_chars = picture_description.chars().count(), "拼图首关名模型返回非法,降级使用关键词名" ); } Err(error) => { tracing::warn!( provider = PUZZLE_AGENT_API_BASE_PROVIDER, picture_chars = picture_description.chars().count(), error = %error, "拼图首关名生成失败,降级使用关键词名" ); } } } PuzzleLevelNaming::fallback(picture_description) } pub(crate) async fn generate_puzzle_first_level_name_from_image( state: &AppState, picture_description: &str, image: &PuzzleDownloadedImage, ) -> Option { let Some(llm_client) = state.creative_agent_gpt5_client() else { return None; }; let Some(image_data_url) = build_puzzle_level_name_image_data_url(image) else { tracing::warn!( provider = PUZZLE_AGENT_API_BASE_PROVIDER, picture_chars = picture_description.chars().count(), "拼图首关名图片输入压缩失败,保留文本关卡名" ); return None; }; let user_text = build_puzzle_first_level_name_vision_user_text(picture_description); let response = llm_client .request_text( LlmTextRequest::new(vec![ LlmMessage::system(PUZZLE_FIRST_LEVEL_NAME_SYSTEM_PROMPT), LlmMessage::user_multimodal(vec![ LlmMessageContentPart::InputText { text: user_text }, LlmMessageContentPart::InputImage { image_url: image_data_url, }, ]), ]) .with_model(PUZZLE_LEVEL_NAME_VISION_LLM_MODEL) .with_max_tokens(PUZZLE_LEVEL_NAME_VISION_MAX_TOKENS), ) .await; match response { Ok(response) => { parse_puzzle_level_naming_from_text(response.content.as_str()).or_else(|| { tracing::warn!( provider = PUZZLE_AGENT_API_BASE_PROVIDER, model = PUZZLE_LEVEL_NAME_VISION_LLM_MODEL, picture_chars = picture_description.chars().count(), "拼图首关名视觉模型返回非法,保留文本关卡名" ); None }) } Err(error) => { tracing::warn!( provider = PUZZLE_AGENT_API_BASE_PROVIDER, model = PUZZLE_LEVEL_NAME_VISION_LLM_MODEL, picture_chars = picture_description.chars().count(), error = %error, "拼图首关名视觉生成失败,保留文本关卡名" ); None } } } pub(crate) fn build_puzzle_level_name_image_data_url( image: &PuzzleDownloadedImage, ) -> Option { let bytes = resize_puzzle_level_name_image_bytes(image.bytes.as_slice()) .unwrap_or_else(|| image.bytes.clone()); let mime_type = if bytes.starts_with(b"\x89PNG\r\n\x1A\n") { "image/png" } else { image.mime_type.as_str() }; Some(format!( "data:{};base64,{}", normalize_puzzle_downloaded_image_mime_type(mime_type), BASE64_STANDARD.encode(bytes) )) } pub(crate) fn resize_puzzle_level_name_image_bytes(bytes: &[u8]) -> Option> { let image = image::load_from_memory(bytes).ok()?; let resized = image.resize( PUZZLE_LEVEL_NAME_VISION_IMAGE_MAX_SIDE, PUZZLE_LEVEL_NAME_VISION_IMAGE_MAX_SIDE, image::imageops::FilterType::Triangle, ); let mut cursor = std::io::Cursor::new(Vec::new()); resized.write_to(&mut cursor, ImageFormat::Png).ok()?; Some(cursor.into_inner()) } pub(crate) fn parse_puzzle_level_naming_from_text(text: &str) -> Option { let trimmed = text.trim(); let json_text = if let Some(start) = trimmed.find('{') && let Some(end) = trimmed.rfind('}') && end > start { &trimmed[start..=end] } else { trimmed }; let parsed = serde_json::from_str::(json_text).ok(); if parsed.is_none() && looks_like_puzzle_json_fragment(trimmed) { return None; } let raw_name = parsed .as_ref() .and_then(|value| value.get("levelName").and_then(Value::as_str)) .or_else(|| { parsed .as_ref() .and_then(|value| value.get("level_name").and_then(Value::as_str)) }) .unwrap_or(trimmed); let level_name = normalize_puzzle_first_level_name(raw_name)?; let work_description = parsed .as_ref() .and_then(parse_puzzle_generated_work_description_field); let work_tags = parsed .as_ref() .and_then(parse_puzzle_generated_work_tags_field) .unwrap_or_default(); let ui_background_prompt = parsed .as_ref() .and_then(parse_puzzle_ui_background_prompt_field); Some(PuzzleLevelNaming { level_name, work_description, work_tags, ui_background_prompt, }) } #[cfg(test)] pub(crate) fn parse_puzzle_first_level_name_from_text(text: &str) -> Option { parse_puzzle_level_naming_from_text(text).map(|naming| naming.level_name) } pub(crate) fn parse_puzzle_ui_background_prompt_field(value: &Value) -> Option { value .get("uiBackgroundPrompt") .and_then(Value::as_str) .or_else(|| value.get("ui_background_prompt").and_then(Value::as_str)) .and_then(normalize_puzzle_generated_ui_background_prompt) } pub(crate) fn parse_puzzle_generated_work_description_field(value: &Value) -> Option { value .get("workDescription") .and_then(Value::as_str) .or_else(|| value.get("work_description").and_then(Value::as_str)) .and_then(normalize_puzzle_generated_work_description) } pub(crate) fn normalize_puzzle_generated_work_description(value: &str) -> Option { let normalized = value .trim() .trim_matches(|ch: char| { ch.is_ascii_punctuation() || matches!( ch, ',' | '。' | '、' | ';' | ':' | '!' | '?' | '“' | '”' | '《' | '》' ) }) .split_whitespace() .collect::>() .join(""); let description = normalized.chars().take(80).collect::(); (description.chars().count() >= 8 && !looks_like_puzzle_json_field_name(&description)) .then_some(description) } pub(crate) fn parse_puzzle_generated_work_tags_field(value: &Value) -> Option> { let tags_value = value .get("workTags") .or_else(|| value.get("work_tags")) .or_else(|| value.get("themeTags")) .or_else(|| value.get("theme_tags")) .or_else(|| value.get("tags"))?; let raw_tags = match tags_value { Value::Array(items) => items .iter() .filter_map(Value::as_str) .map(ToString::to_string) .collect::>(), Value::String(text) => text .split([',', ',', '、', '\n', '|', '/']) .map(ToString::to_string) .collect::>(), _ => Vec::new(), }; let tags = normalize_puzzle_generated_work_tag_candidates(raw_tags); (tags.len() == module_puzzle::PUZZLE_MAX_TAG_COUNT).then_some(tags) } pub(crate) fn normalize_puzzle_generated_work_tag_candidates( candidates: impl IntoIterator, ) -> Vec where S: AsRef, { let mut tags = Vec::new(); for candidate in candidates { let normalized = normalize_puzzle_tag(candidate.as_ref()); if normalized.is_empty() || looks_like_puzzle_json_field_name(&normalized) || tags.iter().any(|tag| tag == &normalized) { continue; } tags.push(normalized); if tags.len() >= module_puzzle::PUZZLE_MAX_TAG_COUNT { break; } } tags } pub(crate) fn normalize_puzzle_generated_ui_background_prompt(value: &str) -> Option { let normalized = value .trim() .trim_matches(|ch: char| { ch.is_ascii_punctuation() || matches!( ch, ',' | '。' | '、' | ';' | ':' | '!' | '?' | '“' | '”' | '《' | '》' ) }) .split_whitespace() .collect::>() .join(""); let filtered = normalized .replace("拼图槽", "") .replace("棋盘", "") .replace("HUD", "") .replace("按钮", "") .replace("文字", "") .replace("水印", "") .replace("数字", "") .replace("拼图碎片", "") .replace("完整拼图图像", "") .replace("教程浮层", ""); let prompt = filtered .chars() .take(160) .collect::() .trim() .trim_matches(|ch: char| matches!(ch, ',' | '。' | '、' | ';' | ':')) .to_string(); if prompt.chars().count() >= 12 { Some(prompt) } else { None } } pub(crate) fn normalize_puzzle_first_level_name(value: &str) -> Option { let normalized = value .trim() .trim_matches(|ch: char| { ch.is_ascii_punctuation() || matches!( ch, ',' | '。' | '、' | ';' | ':' | '!' | '?' | '“' | '”' | '《' | '》' ) }) .trim_start_matches(|ch: char| ch.is_ascii_digit() || matches!(ch, '.' | '、' | ')' | ')')) .chars() .filter(|ch| { !matches!( ch, '#' | '"' | '\'' | '`' | ' ' | '\t' | '\r' | '\n' | ',' | '。' | '、' | ';' | ':' | '!' | '?' | '“' | '”' | '《' | '》' ) }) .take(12) .collect::(); let normalized = strip_puzzle_level_name_generic_words(normalized); if normalized.chars().count() >= 2 && !matches!( normalized.as_str(), "第一关" | "画面" | "拼图" | "作品" | "关卡" ) && !looks_like_puzzle_json_field_name(&normalized) { Some(normalized) } else { None } } pub(crate) fn looks_like_puzzle_json_field_name(value: &str) -> bool { let normalized = value.trim().trim_matches(|ch: char| { ch.is_ascii_punctuation() || matches!( ch, ',' | '。' | '、' | ';' | ':' | '!' | '?' | '“' | '”' | '《' | '》' ) }); let compact = normalized.to_ascii_lowercase().replace('_', ""); matches!(compact.as_str(), "levelnam" | "levelname") || [ "levelname", "workdescription", "worktags", "themetags", "uibackgroundprompt", ] .iter() .any(|field| { compact == *field || (compact.len() >= 6 && field.starts_with(compact.as_str())) || compact.starts_with(field) }) } pub(crate) fn looks_like_puzzle_json_fragment(value: &str) -> bool { let trimmed = value.trim(); if trimmed.starts_with('{') || trimmed.starts_with('[') { return true; } let lower = trimmed.to_ascii_lowercase(); [ "\"levelnam", "\"levelname\"", "\"level_name\"", "\"workdescription\"", "\"work_description\"", "\"worktags\"", "\"work_tags\"", "\"uibackgroundprompt\"", "\"ui_background_prompt\"", ] .iter() .any(|field| lower.contains(field)) } pub(crate) fn strip_puzzle_level_name_generic_words(mut value: String) -> String { for prefix in ["第一关", "关卡名", "关卡"] { value = value.trim_start_matches(prefix).to_string(); } for suffix in ["第一关", "关卡名", "关卡", "画面", "拼图", "作品"] { value = value.trim_end_matches(suffix).to_string(); } value.chars().take(8).collect() } pub(crate) fn build_fallback_puzzle_first_level_name(picture_description: &str) -> String { let source = picture_description.trim(); if source.contains("猫") && (source.contains("雨夜") || source.contains('雨')) { return "雨夜猫街".to_string(); } if source.contains("猫") && source.contains('灯') { return "暖灯猫街".to_string(); } for (keyword, level_name) in [ ("雨夜", "雨夜灯街"), ("猫", "暖灯猫街"), ("狗", "花园小狗"), ("神庙", "神庙遗光"), ("遗迹", "遗迹谜光"), ("森林", "森林秘境"), ("城市", "霓虹城市"), ("机械", "机械迷城"), ("蒸汽", "蒸汽街区"), ("海", "海岸微光"), ("花", "花园晨光"), ("雪", "雪境小径"), ("龙", "龙影高塔"), ("灯", "暖灯街角"), ("塔", "塔顶星光"), ] { if source.contains(keyword) { return level_name.to_string(); } } "奇境初见".to_string() } pub(crate) fn build_puzzle_levels_with_primary_update( draft: &PuzzleResultDraftRecord, target_level: &PuzzleDraftLevelRecord, picture_reference: Option<&str>, ) -> Vec { let mut levels = draft.levels.clone(); if let Some(index) = levels .iter() .position(|level| level.level_id == target_level.level_id) .or_else(|| (!levels.is_empty()).then_some(0)) { levels[index].level_name = target_level.level_name.clone(); levels[index].ui_background_prompt = target_level.ui_background_prompt.clone(); levels[index].ui_background_image_src = target_level.ui_background_image_src.clone(); levels[index].ui_background_image_object_key = target_level.ui_background_image_object_key.clone(); if let Some(picture_reference) = picture_reference .map(str::trim) .filter(|value| !value.is_empty()) { levels[index].picture_reference = Some(picture_reference.to_string()); } } levels } pub(crate) fn attach_selected_puzzle_candidate_to_levels( levels: &mut [PuzzleDraftLevelRecord], target_level_id: &str, candidate: &PuzzleGeneratedImageCandidateRecord, ) { if let Some(index) = levels .iter() .position(|level| level.level_id == target_level_id) .or_else(|| (!levels.is_empty()).then_some(0)) { let level = &mut levels[index]; level.candidates.clear(); let mut candidate = candidate.clone(); candidate.selected = true; level.selected_candidate_id = Some(candidate.candidate_id.clone()); level.cover_image_src = Some(candidate.image_src.clone()); level.cover_asset_id = Some(candidate.asset_id.clone()); level.candidates.push(candidate); level.generation_status = "ready".to_string(); } } pub(crate) fn resolve_puzzle_initial_ui_background_prompt( draft: &PuzzleResultDraftRecord, target_level: &PuzzleDraftLevelRecord, ) -> String { target_level .ui_background_prompt .as_deref() .and_then(normalize_puzzle_generated_ui_background_prompt) .unwrap_or_else(|| normalize_puzzle_ui_background_prompt("", draft, target_level)) } pub(crate) fn normalize_puzzle_ui_background_prompt( raw_prompt: &str, draft: &PuzzleResultDraftRecord, target_level: &PuzzleDraftLevelRecord, ) -> String { let prompt = raw_prompt.trim(); if !prompt.is_empty() { return prompt.chars().take(420).collect(); } let title = draft.work_title.trim(); let title = if title.is_empty() { target_level.level_name.trim() } else { title }; let tags = draft .theme_tags .iter() .map(|tag| tag.trim()) .filter(|tag| !tag.is_empty()) .collect::>() .join(","); [ title, draft.work_description.trim(), target_level.picture_description.trim(), tags.as_str(), PUZZLE_UI_BACKGROUND_PROMPT_FALLBACK_MARKER, ] .into_iter() .filter(|value| !value.is_empty()) .collect::>() .join("。") .chars() .take(420) .collect() } pub(crate) fn build_puzzle_ui_background_generation_prompt( level_name: &str, prompt: &str, ) -> String { let level_name = level_name.trim(); let title_clause = if level_name.is_empty() { String::new() } else { format!("当前拼图关卡名称:{level_name}。") }; format!( "{title_clause}{prompt}\n生成一张 9:16 竖屏拼图游戏纯背景图,只表现题材氛围、色彩层次和环境空间。画面不得出现拼图槽、棋盘、拼图区边框、物品槽、HUD、按钮、按钮文字、数字、文字、水印、拼图碎片、完整拼图图像、教程浮层或角色手指。中央区域保持干净通透,方便运行态后续叠加默认拼图槽和正式拼图图块。" ) } pub(crate) fn attach_puzzle_level_ui_background( levels: &mut [PuzzleDraftLevelRecord], level_id: &str, prompt: String, generated: GeneratedPuzzleUiBackgroundResponse, ) { let Some(index) = levels .iter() .position(|level| level.level_id == level_id) .or_else(|| (!levels.is_empty()).then_some(0)) else { return; }; levels[index].ui_background_prompt = Some(prompt); levels[index].ui_background_image_src = Some(generated.image_src); levels[index].ui_background_image_object_key = Some(generated.object_key); } pub(crate) async fn generate_puzzle_background_music_required( state: &AppState, owner_user_id: &str, profile_id: &str, title: &str, ) -> Result { let normalized_title = title.trim(); if normalized_title.is_empty() { return Err( AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": PUZZLE_AGENT_API_BASE_PROVIDER, "message": "拼图草稿背景音乐名称为空,无法完成背景音乐生成", })), ); } generate_background_music_asset_for_creation( state, owner_user_id, String::new(), normalized_title.to_string(), Some("轻快, 拼图, 循环, instrumental".to_string()), None, GeneratedCreationAudioTarget { entity_kind: PUZZLE_ENTITY_KIND.to_string(), entity_id: profile_id.to_string(), slot: PUZZLE_BACKGROUND_MUSIC_SLOT.to_string(), asset_kind: PUZZLE_BACKGROUND_MUSIC_ASSET_KIND.to_string(), profile_id: Some(profile_id.to_string()), storage_prefix: LegacyAssetPrefix::PuzzleAssets, }, ) .await } pub(crate) async fn generate_puzzle_initial_ui_background_required( state: &AppState, owner_user_id: &str, session_id: &str, draft: &PuzzleResultDraftRecord, target_level: &PuzzleDraftLevelRecord, ) -> Result<(String, GeneratedPuzzleUiBackgroundResponse), AppError> { let prompt = resolve_puzzle_initial_ui_background_prompt(draft, target_level); let generated = generate_puzzle_ui_background_image( state, owner_user_id, session_id, target_level.level_name.as_str(), prompt.as_str(), ) .await?; Ok((prompt, generated)) } pub(crate) fn ensure_puzzle_initial_level_assets_ready( level: &PuzzleDraftLevelRecord, ) -> Result<(), AppError> { let has_ui_background = level .ui_background_image_src .as_deref() .map(str::trim) .is_some_and(|value| !value.is_empty()) || level .ui_background_image_object_key .as_deref() .map(str::trim) .is_some_and(|value| !value.is_empty()); if has_ui_background { return Ok(()); } let mut missing = Vec::new(); if !has_ui_background { missing.push("UI背景图"); } Err( AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": PUZZLE_AGENT_API_BASE_PROVIDER, "message": format!("拼图草稿资源生成未完成:缺少{}", missing.join("、")), "missingAssets": missing, })), ) } pub(crate) fn find_puzzle_level_for_initial_asset_check<'a>( levels: &'a [PuzzleDraftLevelRecord], level_id: &str, ) -> Option<&'a PuzzleDraftLevelRecord> { levels .iter() .find(|level| level.level_id == level_id) .or_else(|| levels.first()) } pub(crate) async fn compile_puzzle_draft_with_initial_cover( state: &AppState, session_id: String, owner_user_id: String, prompt_text: Option<&str>, reference_image_src: Option<&str>, image_model: Option<&str>, now: i64, ) -> Result { let compiled_session = state .spacetime_client() .compile_puzzle_agent_draft(session_id.clone(), owner_user_id.clone(), now) .await .map_err(map_puzzle_compile_error)?; let draft = compiled_session.draft.clone().ok_or_else(|| { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": PUZZLE_AGENT_API_BASE_PROVIDER, "message": "拼图结果页草稿尚未生成", })) })?; let mut target_level = select_puzzle_level_for_api(&draft, None)?; let fallback_level_name = target_level.level_name.clone(); let image_prompt = resolve_puzzle_draft_cover_prompt( prompt_text, &target_level.picture_description, &draft.summary, ); let generated_naming = generate_puzzle_first_level_name(state, &target_level.picture_description).await; target_level.level_name = generated_naming.level_name.clone(); target_level.ui_background_prompt = generated_naming.ui_background_prompt.clone(); let mut generated_metadata = generated_naming; // 点击生成草稿时一次性完成首图生成、UI 背景生成与正式图选定,前端只展示进度,不再承担业务编排。 let candidates_future = generate_puzzle_image_candidates( state, owner_user_id.as_str(), &compiled_session.session_id, &target_level.level_name, &image_prompt, reference_image_src, true, image_model, 1, target_level.candidates.len(), ); let ui_background_future = generate_puzzle_initial_ui_background_required( state, owner_user_id.as_str(), compiled_session.session_id.as_str(), &draft, &target_level, ); // 中文注释:命名稳定后并行发起首关图与 UI 背景,避免两次外部生图串行等待。 let (candidates_result, ui_background_result) = tokio::join!(candidates_future, ui_background_future); let mut candidates = candidates_result?; if let Some(first_candidate) = candidates.first() && let Some(refined_naming) = generate_puzzle_first_level_name_from_image( state, target_level.picture_description.as_str(), &first_candidate.downloaded_image, ) .await { target_level.level_name = refined_naming.level_name; if refined_naming.work_description.is_some() { generated_metadata.work_description = refined_naming.work_description; } if refined_naming.work_tags.len() == module_puzzle::PUZZLE_MAX_TAG_COUNT { generated_metadata.work_tags = refined_naming.work_tags; } generated_metadata.level_name = target_level.level_name.clone(); generated_metadata.ui_background_prompt = target_level.ui_background_prompt.clone(); } let generated_level_name = target_level.level_name.clone(); let mut updated_levels = build_puzzle_levels_with_primary_update(&draft, &target_level, reference_image_src); for candidate in &mut candidates { candidate.record.prompt = image_prompt.clone(); } let selected_candidate_id = candidates .iter() .find(|candidate| candidate.record.selected) .or_else(|| candidates.first()) .map(|candidate| candidate.record.candidate_id.clone()) .ok_or_else(|| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": PUZZLE_AGENT_API_BASE_PROVIDER, "message": "拼图候选图生成结果为空", })) })?; // 中文注释:拼图草稿音频生成临时关闭,首版生成只补首图与 UI 背景。 let (ui_prompt, ui_background) = ui_background_result?; attach_puzzle_level_ui_background( &mut updated_levels, target_level.level_id.as_str(), ui_prompt, ui_background, ); if let Some(selected_candidate) = candidates .iter() .find(|candidate| candidate.record.selected) .or_else(|| candidates.first()) { attach_selected_puzzle_candidate_to_levels( &mut updated_levels, target_level.level_id.as_str(), &selected_candidate.record, ); } let ready_level = find_puzzle_level_for_initial_asset_check(&updated_levels, target_level.level_id.as_str()) .ok_or_else(|| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": PUZZLE_AGENT_API_BASE_PROVIDER, "message": "拼图草稿资源生成完成后未找到目标关卡", })) })?; ensure_puzzle_initial_level_assets_ready(ready_level)?; let levels_json_with_generated_name = Some(serialize_puzzle_level_records_for_module(&updated_levels)?); let work_title = if draft.work_title.trim().is_empty() || draft.work_title.trim() == fallback_level_name.trim() { generated_level_name.clone() } else { draft.work_title.clone() }; let work_description = if draft.work_description.trim().is_empty() { generated_metadata .work_description .clone() .unwrap_or_else(|| draft.work_description.clone()) } else { draft.work_description.clone() }; let theme_tags = if draft.theme_tags.is_empty() && generated_metadata.work_tags.len() == module_puzzle::PUZZLE_MAX_TAG_COUNT { generated_metadata.work_tags.clone() } else { draft.theme_tags.clone() }; let candidates_json = serde_json::to_string( &candidates .iter() .map(|candidate| to_puzzle_generated_image_candidate(&candidate.record)) .collect::>(), ) .map_err(|error| { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": PUZZLE_AGENT_API_BASE_PROVIDER, "message": format!("拼图候选图序列化失败:{error}"), })) })?; let (saved_session, save_used_fallback) = state .spacetime_client() .save_puzzle_generated_images(PuzzleGeneratedImagesSaveRecordInput { session_id: compiled_session.session_id.clone(), owner_user_id: owner_user_id.clone(), level_id: Some(target_level.level_id.clone()), levels_json: levels_json_with_generated_name.clone(), candidates_json, saved_at_micros: current_utc_micros(), }) .await .map_err(map_puzzle_client_error) .map(|session| (session, false)) .or_else(|error| { if is_spacetimedb_connectivity_app_error(&error) { // 中文注释:首图已落 OSS 时,SpacetimeDB 短暂不可用先返回本地快照,避免整次 VectorEngine 生图被判失败。 tracing::warn!( provider = PUZZLE_AGENT_API_BASE_PROVIDER, session_id = %compiled_session.session_id, owner_user_id = %owner_user_id, message = %error.body_text(), "拼图首图已生成但 SpacetimeDB 草稿回写不可用,降级返回本次生成快照" ); let session = apply_generated_puzzle_candidates_to_session_snapshot( apply_generated_puzzle_levels_to_session_snapshot( apply_generated_puzzle_first_level_name_to_session_snapshot( compiled_session.clone(), target_level.level_id.as_str(), generated_level_name.as_str(), fallback_level_name.as_str(), now, ), updated_levels.clone(), now, ), target_level.level_id.as_str(), candidates.into_records(), reference_image_src, now, ); Ok((session, true)) } else { Err(error) } })?; let (_, profile_id) = build_stable_puzzle_work_ids(&compiled_session.session_id); match state .spacetime_client() .update_puzzle_work(PuzzleWorkUpsertRecordInput { profile_id, owner_user_id: owner_user_id.clone(), work_title, work_description: work_description.clone(), level_name: generated_level_name.clone(), summary: work_description, theme_tags, cover_image_src: ready_level.cover_image_src.clone(), cover_asset_id: ready_level.cover_asset_id.clone(), levels_json: levels_json_with_generated_name.clone(), updated_at_micros: now, }) .await .map_err(map_puzzle_client_error) { Ok(_) => {} Err(error) if is_spacetimedb_connectivity_app_error(&error) => { tracing::warn!( provider = PUZZLE_AGENT_API_BASE_PROVIDER, session_id = %compiled_session.session_id, owner_user_id = %owner_user_id, message = %error.body_text(), "拼图首图生成后作品元信息投影回写不可用,继续使用会话草稿快照" ); } Err(error) => return Err(error), } let saved_session = apply_generated_puzzle_initial_metadata_to_session_snapshot( saved_session, &generated_metadata, fallback_level_name.as_str(), now, ); if save_used_fallback { return Ok(saved_session); } match state .spacetime_client() .select_puzzle_cover_image(PuzzleSelectCoverImageRecordInput { session_id, owner_user_id, level_id: Some(target_level.level_id), candidate_id: selected_candidate_id, selected_at_micros: current_utc_micros(), }) .await { Ok(session) => Ok(apply_generated_puzzle_initial_metadata_to_session_snapshot( session, &generated_metadata, fallback_level_name.as_str(), now, )), Err(error) if should_skip_asset_operation_billing_for_connectivity(&error) => { tracing::warn!( provider = PUZZLE_AGENT_API_BASE_PROVIDER, session_id = %saved_session.session_id, error = %error, "拼图首图选定回写因 SpacetimeDB 连接不可用而降级使用已生成快照" ); Ok(saved_session) } Err(error) => Err(map_puzzle_client_error(error)), } } pub(crate) async fn compile_puzzle_draft_with_uploaded_cover( state: &AppState, session_id: String, owner_user_id: String, prompt_text: Option<&str>, reference_image_src: Option<&str>, now: i64, ) -> Result { let uploaded_image_src = reference_image_src .map(str::trim) .filter(|value| !value.is_empty()) .ok_or_else(|| { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": PUZZLE_AGENT_API_BASE_PROVIDER, "field": "referenceImageSrc", "message": "关闭 AI 重绘时必须上传拼图图片。", })) })?; 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) .await .map_err(map_puzzle_compile_error)?; let draft = compiled_session.draft.clone().ok_or_else(|| { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": PUZZLE_AGENT_API_BASE_PROVIDER, "message": "拼图结果页草稿尚未生成", })) })?; let mut target_level = select_puzzle_level_for_api(&draft, None)?; let fallback_level_name = target_level.level_name.clone(); let image_prompt = resolve_puzzle_draft_cover_prompt( prompt_text, &target_level.picture_description, &draft.summary, ); // 中文注释:关闭 AI 重绘时首关图不请求 VectorEngine;上传图直接成为首关正式图候选。 let candidate_id = format!( "{}-candidate-{}", compiled_session.session_id, target_level.candidates.len() + 1 ); 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( state, target_level.picture_description.as_str(), &uploaded_downloaded_image, ); let (mut generated_naming, refined_naming) = tokio::join!(level_name_future, image_level_name_future); if let Some(refined_naming) = refined_naming { generated_naming.level_name = refined_naming.level_name; if refined_naming.ui_background_prompt.is_some() { generated_naming.ui_background_prompt = refined_naming.ui_background_prompt; } if refined_naming.work_description.is_some() { generated_naming.work_description = refined_naming.work_description; } if refined_naming.work_tags.len() == module_puzzle::PUZZLE_MAX_TAG_COUNT { generated_naming.work_tags = refined_naming.work_tags; } } target_level.level_name = generated_naming.level_name.clone(); target_level.ui_background_prompt = generated_naming.ui_background_prompt.clone(); let mut generated_metadata = generated_naming; generated_metadata.level_name = target_level.level_name.clone(); generated_metadata.ui_background_prompt = target_level.ui_background_prompt.clone(); let generated_level_name = target_level.level_name.clone(); let mut updated_levels = build_puzzle_levels_with_primary_update(&draft, &target_level, reference_image_src); let persist_upload_future = persist_puzzle_generated_asset( state, owner_user_id.as_str(), &compiled_session.session_id, target_level.level_name.as_str(), candidate_id.as_str(), "uploaded-direct", uploaded_downloaded_image.clone(), current_utc_micros(), ); let ui_background_future = generate_puzzle_initial_ui_background_required( state, owner_user_id.as_str(), compiled_session.session_id.as_str(), &draft, &target_level, ); // 中文注释:直用上传图时并行完成上传图持久化与 UI 背景生成;音频生成入口临时关闭。 let (persisted_upload_result, ui_background_result) = tokio::join!(persist_upload_future, ui_background_future); let persisted_upload = persisted_upload_result?; let (ui_prompt, ui_background) = ui_background_result?; attach_puzzle_level_ui_background( &mut updated_levels, target_level.level_id.as_str(), ui_prompt, ui_background, ); attach_selected_puzzle_candidate_to_levels( &mut updated_levels, target_level.level_id.as_str(), &PuzzleGeneratedImageCandidateRecord { candidate_id: candidate_id.clone(), image_src: persisted_upload.image_src.clone(), asset_id: persisted_upload.asset_id.clone(), prompt: image_prompt.clone(), actual_prompt: None, source_type: "uploaded".to_string(), selected: true, }, ); let ready_level = find_puzzle_level_for_initial_asset_check(&updated_levels, target_level.level_id.as_str()) .ok_or_else(|| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": PUZZLE_AGENT_API_BASE_PROVIDER, "message": "拼图草稿资源生成完成后未找到目标关卡", })) })?; ensure_puzzle_initial_level_assets_ready(ready_level)?; let levels_json_with_generated_name = Some(serialize_puzzle_level_records_for_module(&updated_levels)?); let work_title = if draft.work_title.trim().is_empty() || draft.work_title.trim() == fallback_level_name.trim() { generated_level_name.clone() } else { draft.work_title.clone() }; let work_description = if draft.work_description.trim().is_empty() { generated_metadata .work_description .clone() .unwrap_or_else(|| draft.work_description.clone()) } else { draft.work_description.clone() }; let theme_tags = if draft.theme_tags.is_empty() && generated_metadata.work_tags.len() == module_puzzle::PUZZLE_MAX_TAG_COUNT { generated_metadata.work_tags.clone() } else { draft.theme_tags.clone() }; let candidate = PuzzleGeneratedImageCandidateRecord { candidate_id: candidate_id.clone(), image_src: persisted_upload.image_src, asset_id: persisted_upload.asset_id, prompt: image_prompt, actual_prompt: None, source_type: "uploaded".to_string(), selected: true, }; let candidates_json = serde_json::to_string(&vec![to_puzzle_generated_image_candidate( &candidate, )]) .map_err(|error| { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": PUZZLE_AGENT_API_BASE_PROVIDER, "message": format!("拼图上传图候选序列化失败:{error}"), })) })?; let (saved_session, save_used_fallback) = state .spacetime_client() .save_puzzle_generated_images(PuzzleGeneratedImagesSaveRecordInput { session_id: compiled_session.session_id.clone(), owner_user_id: owner_user_id.clone(), level_id: Some(target_level.level_id.clone()), levels_json: levels_json_with_generated_name.clone(), candidates_json, saved_at_micros: current_utc_micros(), }) .await .map_err(map_puzzle_client_error) .map(|session| (session, false)) .or_else(|error| { if is_spacetimedb_connectivity_app_error(&error) { tracing::warn!( provider = PUZZLE_AGENT_API_BASE_PROVIDER, session_id = %compiled_session.session_id, owner_user_id = %owner_user_id, message = %error.body_text(), "拼图上传图草稿回写不可用,降级返回本地快照" ); let session = apply_generated_puzzle_candidates_to_session_snapshot( apply_generated_puzzle_levels_to_session_snapshot( apply_generated_puzzle_first_level_name_to_session_snapshot( compiled_session.clone(), target_level.level_id.as_str(), generated_level_name.as_str(), fallback_level_name.as_str(), now, ), updated_levels.clone(), now, ), target_level.level_id.as_str(), vec![candidate.clone()], reference_image_src, now, ); Ok((session, true)) } else { Err(error) } })?; let (_, profile_id) = build_stable_puzzle_work_ids(&compiled_session.session_id); match state .spacetime_client() .update_puzzle_work(PuzzleWorkUpsertRecordInput { profile_id, owner_user_id: owner_user_id.clone(), work_title, work_description: work_description.clone(), level_name: generated_level_name.clone(), summary: work_description, theme_tags, cover_image_src: ready_level.cover_image_src.clone(), cover_asset_id: ready_level.cover_asset_id.clone(), levels_json: levels_json_with_generated_name.clone(), updated_at_micros: now, }) .await .map_err(map_puzzle_client_error) { Ok(_) => {} Err(error) if is_spacetimedb_connectivity_app_error(&error) => { tracing::warn!( provider = PUZZLE_AGENT_API_BASE_PROVIDER, session_id = %compiled_session.session_id, owner_user_id = %owner_user_id, message = %error.body_text(), "拼图上传图草稿作品元信息投影回写不可用,继续使用会话草稿快照" ); } Err(error) => return Err(error), } let saved_session = apply_generated_puzzle_initial_metadata_to_session_snapshot( saved_session, &generated_metadata, fallback_level_name.as_str(), now, ); if save_used_fallback { return Ok(saved_session); } match state .spacetime_client() .select_puzzle_cover_image(PuzzleSelectCoverImageRecordInput { session_id, owner_user_id, level_id: Some(target_level.level_id), candidate_id, selected_at_micros: current_utc_micros(), }) .await { Ok(session) => Ok(apply_generated_puzzle_initial_metadata_to_session_snapshot( session, &generated_metadata, fallback_level_name.as_str(), now, )), Err(error) if should_skip_asset_operation_billing_for_connectivity(&error) => { tracing::warn!( provider = PUZZLE_AGENT_API_BASE_PROVIDER, session_id = %saved_session.session_id, error = %error, "拼图上传图选定回写因 SpacetimeDB 连接不可用而降级使用已保存快照" ); Ok(saved_session) } Err(error) => Err(map_puzzle_client_error(error)), } } pub(crate) fn apply_generated_puzzle_candidates_to_session_snapshot( mut session: PuzzleAgentSessionRecord, target_level_id: &str, candidates: Vec, picture_reference: Option<&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; }; let mut candidates = candidates .into_iter() .take(1) .map(|mut candidate| { candidate.selected = true; candidate }) .collect::>(); let Some(selected) = candidates.first().cloned() else { return session; }; let level = &mut draft.levels[target_index]; level.candidates.clear(); level.candidates.append(&mut candidates); level.selected_candidate_id = Some(selected.candidate_id.clone()); level.cover_image_src = Some(selected.image_src.clone()); level.cover_asset_id = Some(selected.asset_id.clone()); if let Some(picture_reference) = picture_reference .map(str::trim) .filter(|value| !value.is_empty()) { level.picture_reference = Some(picture_reference.to_string()); } level.generation_status = "ready".to_string(); if target_index == 0 { sync_puzzle_primary_draft_fields_from_level(draft); } session.progress_percent = session.progress_percent.max(94); session.stage = "ready_to_publish".to_string(); session.last_assistant_reply = Some("拼图图片已经生成,并已替换当前正式图。".to_string()); session.updated_at = format_timestamp_micros(updated_at_micros); session } pub(crate) fn apply_generated_puzzle_levels_to_session_snapshot( mut session: PuzzleAgentSessionRecord, levels: Vec, updated_at_micros: i64, ) -> PuzzleAgentSessionRecord { let Some(draft) = session.draft.as_mut() else { return session; }; if levels.is_empty() { return session; } draft.levels = levels; sync_puzzle_primary_draft_fields_from_level(draft); session.updated_at = format_timestamp_micros(updated_at_micros); session } pub(crate) fn apply_generated_puzzle_first_level_name_to_session_snapshot( mut session: PuzzleAgentSessionRecord, target_level_id: &str, level_name: &str, previous_level_name: &str, updated_at_micros: i64, ) -> PuzzleAgentSessionRecord { let Some(draft) = session.draft.as_mut() else { return session; }; let normalized_name = level_name.trim(); if normalized_name.is_empty() { 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 = normalized_name.to_string(); let should_default_work_title = draft.work_title.trim().is_empty() || draft.work_title.trim() == previous_level_name.trim(); if target_index == 0 && should_default_work_title { draft.work_title = normalized_name.to_string(); } 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_session_snapshot( mut session: PuzzleAgentSessionRecord, metadata: &PuzzleLevelNaming, previous_level_name: &str, updated_at_micros: i64, ) -> PuzzleAgentSessionRecord { let Some(draft) = session.draft.as_mut() else { return session; }; apply_generated_puzzle_initial_metadata_to_draft( draft, metadata, previous_level_name, updated_at_micros, ); session.updated_at = format_timestamp_micros(updated_at_micros); 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, previous_level_name: &str, _updated_at_micros: i64, ) { let should_default_work_title = draft.work_title.trim().is_empty() || draft.work_title.trim() == previous_level_name.trim(); if should_default_work_title { draft.work_title = metadata.level_name.clone(); } if draft.work_description.trim().is_empty() && let Some(description) = metadata.work_description.as_ref() { draft.work_description = description.clone(); draft.summary = description.clone(); } if draft.theme_tags.is_empty() && metadata.work_tags.len() == module_puzzle::PUZZLE_MAX_TAG_COUNT { draft.theme_tags = metadata.work_tags.clone(); } sync_puzzle_primary_draft_fields_from_level(draft); } pub(crate) fn sync_puzzle_primary_draft_fields_from_level(draft: &mut PuzzleResultDraftRecord) { let Some(primary_level) = draft.levels.first() else { return; }; draft.level_name = primary_level.level_name.clone(); draft.candidates = primary_level.candidates.clone(); draft.selected_candidate_id = primary_level.selected_candidate_id.clone(); draft.cover_image_src = primary_level.cover_image_src.clone(); draft.cover_asset_id = primary_level.cover_asset_id.clone(); draft.generation_status = primary_level.generation_status.clone(); draft.summary = draft.work_description.clone(); if draft.form_draft.is_some() { draft.form_draft = Some(PuzzleFormDraftRecord { work_title: (!draft.work_title.trim().is_empty()).then_some(draft.work_title.clone()), work_description: (!draft.work_description.trim().is_empty()) .then_some(draft.work_description.clone()), picture_description: (!primary_level.picture_description.trim().is_empty()) .then_some(primary_level.picture_description.clone()), }); } } pub(crate) fn replace_puzzle_session_draft_snapshot( mut session: PuzzleAgentSessionRecord, draft: PuzzleResultDraftRecord, updated_at_micros: i64, ) -> PuzzleAgentSessionRecord { session.draft = Some(draft); session.updated_at = format_timestamp_micros(updated_at_micros); session } pub(crate) fn apply_generated_puzzle_ui_background_to_session_snapshot( mut session: PuzzleAgentSessionRecord, target_level_id: &str, prompt: String, image_src: String, image_object_key: Option, 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; }; let level = &mut draft.levels[target_index]; level.ui_background_prompt = Some(prompt); level.ui_background_image_src = Some(image_src); level.ui_background_image_object_key = image_object_key; if target_index == 0 { sync_puzzle_primary_draft_fields_from_level(draft); } session.progress_percent = session.progress_percent.max(96); session.last_assistant_reply = Some("拼图 UI 背景图已生成。".to_string()); session.updated_at = format_timestamp_micros(updated_at_micros); session }