use super::*; pub(crate) fn map_puzzle_generation_endpoint_error(error: AppError) -> AppError { if error.code() == "UPSTREAM_ERROR" { let body_text = error.body_text(); return AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": PUZZLE_AGENT_API_BASE_PROVIDER, "message": format!("拼图图片生成失败:{body_text}"), })); } error } pub(crate) fn should_fallback_puzzle_reference_edit_to_generation(error: &AppError) -> bool { error.status_code() == StatusCode::GATEWAY_TIMEOUT || is_puzzle_request_timeout_message(error.body_text().as_str()) } pub(crate) async fn generate_puzzle_image_candidates( state: &AppState, owner_user_id: &str, session_id: &str, level_name: &str, prompt: &str, reference_image_src: Option<&str>, use_reference_image_edit: bool, image_model: Option<&str>, candidate_count: u32, candidate_start_index: usize, ) -> Result, AppError> { let total_started_at = Instant::now(); let count = candidate_count.clamp(1, 1); let resolved_model = resolve_puzzle_image_model(image_model); let http_client = build_puzzle_image_http_client(state, resolved_model)?; let has_reference_image = has_puzzle_reference_image(reference_image_src); let should_use_reference_image_edit = should_use_puzzle_reference_image_edit(reference_image_src, use_reference_image_edit); let actual_prompt = build_puzzle_vector_engine_generation_prompt( build_puzzle_image_prompt(level_name, prompt).as_str(), should_use_reference_image_edit, ); tracing::info!( provider = resolved_model.provider_name(), image_model = resolved_model.request_model_name(), session_id, level_name, prompt_chars = prompt.chars().count(), actual_prompt_chars = actual_prompt.chars().count(), has_reference_image, use_reference_image_edit = should_use_reference_image_edit, "拼图图片生成请求已准备" ); let reference_image_started_at = Instant::now(); let reference_image = match reference_image_src .map(str::trim) .filter(|value| !value.is_empty()) .filter(|_| should_use_reference_image_edit) { Some(source) => { let resolved = resolve_puzzle_reference_image_as_data_url(state, &http_client, source).await?; tracing::info!( provider = resolved_model.provider_name(), image_model = resolved_model.request_model_name(), session_id, level_name, reference_mime = %resolved.mime_type, reference_bytes = resolved.bytes_len, elapsed_ms = reference_image_started_at.elapsed().as_millis() as u64, "拼图参考图解析完成" ); Some(resolved) } None => None, }; if !should_use_reference_image_edit { tracing::info!( provider = resolved_model.provider_name(), image_model = resolved_model.request_model_name(), session_id, level_name, has_reference_image, use_reference_image_edit = should_use_reference_image_edit, elapsed_ms = reference_image_started_at.elapsed().as_millis() as u64, "拼图参考图解析跳过" ); } // 中文注释:SpacetimeDB reducer 不能做外部 I/O,参考图读取与外部生图都必须停留在 api-server。 // 中文注释:拼图作品资产统一按 1:1 正方形生成,前端运行时也按正方形棋盘切块承载。 let settings = require_puzzle_vector_engine_settings(state)?; let vector_engine_started_at = Instant::now(); let generated = if should_use_reference_image_edit { let reference_image = reference_image.as_ref().ok_or_else(|| { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "puzzle", "field": "referenceImageSrc", "message": "AI 重绘需要提供参考图。", })) })?; let edit_result = create_puzzle_vector_engine_image_edit( &http_client, &settings, actual_prompt.as_str(), PUZZLE_DEFAULT_NEGATIVE_PROMPT, PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE, count, reference_image, ) .await; match edit_result { Ok(generated) => Ok(generated), Err(error) if should_fallback_puzzle_reference_edit_to_generation(&error) => { tracing::warn!( provider = resolved_model.provider_name(), image_model = resolved_model.request_model_name(), session_id, level_name, reference_mime = %reference_image.mime_type, reference_bytes = reference_image.bytes_len, error = %error, "拼图参考图编辑接口超时,降级为带参考图的生成接口" ); create_puzzle_vector_engine_image_generation( &http_client, &settings, resolved_model, actual_prompt.as_str(), PUZZLE_DEFAULT_NEGATIVE_PROMPT, PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE, count, Some(reference_image), ) .await } Err(error) => Err(error), } } else { create_puzzle_vector_engine_image_generation( &http_client, &settings, resolved_model, actual_prompt.as_str(), PUZZLE_DEFAULT_NEGATIVE_PROMPT, PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE, count, None, ) .await } .map_err(map_puzzle_generation_endpoint_error)?; tracing::info!( provider = resolved_model.provider_name(), image_model = resolved_model.request_model_name(), session_id, level_name, generated_image_count = generated.images.len(), elapsed_ms = vector_engine_started_at.elapsed().as_millis() as u64, "拼图 VectorEngine 生图与下载完成" ); let mut items = Vec::with_capacity(generated.images.len()); for (index, image) in generated.images.into_iter().enumerate() { let candidate_id = format!( "{session_id}-candidate-{}", candidate_start_index + index + 1 ); let downloaded_image = image.clone(); let persist_started_at = Instant::now(); let asset = persist_puzzle_generated_asset( state, owner_user_id, session_id, level_name, candidate_id.as_str(), generated.task_id.as_str(), image, current_utc_micros(), ) .await .map_err(map_puzzle_generation_endpoint_error)?; tracing::info!( provider = resolved_model.provider_name(), image_model = resolved_model.request_model_name(), session_id, level_name, candidate_id = %candidate_id, image_bytes = downloaded_image.bytes.len(), image_mime = %downloaded_image.mime_type, elapsed_ms = persist_started_at.elapsed().as_millis() as u64, "拼图生成图片已写入 OSS 与资产索引" ); items.push(GeneratedPuzzleImageCandidate { record: PuzzleGeneratedImageCandidateRecord { candidate_id, image_src: asset.image_src, asset_id: asset.asset_id, prompt: prompt.to_string(), actual_prompt: Some(actual_prompt.clone()), source_type: resolved_model.candidate_source_type().to_string(), // 单图生成结果总是直接成为当前正式图。 selected: index == 0, }, downloaded_image, }); } tracing::info!( provider = resolved_model.provider_name(), image_model = resolved_model.request_model_name(), session_id, level_name, candidate_count = items.len(), has_reference_image, elapsed_ms = total_started_at.elapsed().as_millis() as u64, "拼图图片候选生成完成" ); Ok(items) } pub(crate) async fn generate_puzzle_ui_background_image( state: &AppState, owner_user_id: &str, session_id: &str, level_name: &str, prompt: &str, ) -> Result { let settings = require_openai_image_settings(state)?; let http_client = build_openai_image_http_client(&settings)?; let generated = create_openai_image_generation( &http_client, &settings, build_puzzle_ui_background_generation_prompt(level_name, prompt).as_str(), Some("文字、水印、按钮文字、数字、教程浮层、拼图碎片、完整拼图图像、拼图槽、棋盘、拼图区边框、物品槽、HUD、角色手指"), "9:16", 1, &[], "拼图 UI 背景图生成失败", ) .await?; let image = generated.images.into_iter().next().ok_or_else(|| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": VECTOR_ENGINE_PROVIDER, "message": "拼图 UI 背景图生成失败:未返回图片", })) })?; persist_puzzle_ui_background_image( state, owner_user_id, session_id, level_name, generated.task_id.as_str(), image, ) .await } #[cfg(test)] pub(crate) fn build_puzzle_ui_background_request_prompt_for_test( level_name: &str, prompt: &str, ) -> String { build_puzzle_ui_background_generation_prompt(level_name, prompt) }