From 5e03b3d2f2a20a511ab19a97dc0792a557fa4b6c Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Tue, 19 May 2026 00:07:43 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=86=B2=E7=AA=81=E9=94=99?= =?UTF-8?q?=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server-rs/crates/api-server/src/puzzle.rs | 3901 ----------------- .../crates/api-server/src/puzzle/draft.rs | 125 +- .../crates/api-server/src/puzzle/handlers.rs | 4 +- .../crates/api-server/src/puzzle/mappers.rs | 4 +- 4 files changed, 62 insertions(+), 3972 deletions(-) diff --git a/server-rs/crates/api-server/src/puzzle.rs b/server-rs/crates/api-server/src/puzzle.rs index 6aef164c..018c02c7 100644 --- a/server-rs/crates/api-server/src/puzzle.rs +++ b/server-rs/crates/api-server/src/puzzle.rs @@ -134,3916 +134,15 @@ const PUZZLE_REFERENCE_IMAGE_SOURCE_LIMIT: usize = 5; const PUZZLE_VECTOR_ENGINE_IMAGE_EDIT_MODEL: &str = "gpt-image-2"; const PUZZLE_UI_BACKGROUND_PROMPT_FALLBACK_MARKER: &str = "移动端拼图游戏纯背景,题材氛围清晰,不包含拼图槽或 UI 元素"; -<<<<<<< Updated upstream mod handlers; pub(crate) use self::handlers::*; -======= -pub async fn create_puzzle_agent_session( - State(state): State, - Extension(request_context): Extension, - Extension(authenticated): Extension, - payload: Result, JsonRejection>, -) -> Result, Response> { - let Json(payload) = payload.map_err(|error| { - puzzle_error_response( - &request_context, - PUZZLE_AGENT_API_BASE_PROVIDER, - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": PUZZLE_AGENT_API_BASE_PROVIDER, - "message": error.body_text(), - })), - ) - })?; - - let seed_text = build_puzzle_form_seed_text(&payload); - let session = state - .spacetime_client() - .create_puzzle_agent_session(PuzzleAgentSessionCreateRecordInput { - session_id: build_prefixed_uuid_id("puzzle-session-"), - owner_user_id: authenticated.claims().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: current_utc_micros(), - }) - .await - .map_err(|error| { - puzzle_error_response( - &request_context, - PUZZLE_AGENT_API_BASE_PROVIDER, - map_puzzle_client_error(error), - ) - })?; - - Ok(json_success_body( - Some(&request_context), - PuzzleAgentSessionResponse { - session: map_puzzle_agent_session_response(session), - }, - )) -} - -pub async fn generate_puzzle_onboarding_work( - State(state): State, - Extension(request_context): Extension, - payload: Result, JsonRejection>, -) -> Result, Response> { - let Json(payload) = payload.map_err(|error| { - puzzle_error_response( - &request_context, - PUZZLE_AGENT_API_BASE_PROVIDER, - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": PUZZLE_AGENT_API_BASE_PROVIDER, - "message": error.body_text(), - })), - ) - })?; - - let prompt_text = payload.prompt_text.trim().to_string(); - ensure_non_empty( - &request_context, - PUZZLE_AGENT_API_BASE_PROVIDER, - &prompt_text, - "promptText", - )?; - - let now = current_utc_micros(); - let session_id = build_prefixed_uuid_id("puzzle-onboarding-"); - let naming = generate_puzzle_first_level_name(&state, prompt_text.as_str()).await; - let tags = - generate_puzzle_work_tags(&state, naming.level_name.as_str(), prompt_text.as_str()).await; - let candidates = generate_puzzle_image_candidates( - &state, - "onboarding-guest", - session_id.as_str(), - naming.level_name.as_str(), - prompt_text.as_str(), - None, - false, - Some(PUZZLE_IMAGE_MODEL_GPT_IMAGE_2), - 1, - 0, - ) - .await - .map_err(|error| { - puzzle_error_response( - &request_context, - PUZZLE_AGENT_API_BASE_PROVIDER, - map_puzzle_generation_endpoint_error(error), - ) - })? - .into_records(); - let selected = candidates.first().cloned().ok_or_else(|| { - puzzle_error_response( - &request_context, - PUZZLE_AGENT_API_BASE_PROVIDER, - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": PUZZLE_AGENT_API_BASE_PROVIDER, - "message": "新手引导拼图图片生成结果为空", - })), - ) - })?; - let level = PuzzleDraftLevelRecord { - level_id: "onboarding-level-1".to_string(), - level_name: naming.level_name.clone(), - picture_description: prompt_text.clone(), - picture_reference: None, - ui_background_prompt: naming.ui_background_prompt.clone(), - ui_background_image_src: None, - ui_background_image_object_key: None, - background_music: None, - candidates, - selected_candidate_id: Some(selected.candidate_id.clone()), - cover_image_src: Some(selected.image_src.clone()), - cover_asset_id: Some(selected.asset_id.clone()), - generation_status: "ready".to_string(), - }; - let anchor_pack = map_puzzle_domain_anchor_pack(module_puzzle::build_form_anchor_pack( - naming.level_name.as_str(), - level.picture_description.as_str(), - )); - let item = PuzzleWorkProfileRecord { - work_id: format!("onboarding-work-{now}"), - profile_id: format!("onboarding-profile-{now}"), - owner_user_id: "onboarding-guest".to_string(), - source_session_id: None, - author_display_name: "陶泥儿主".to_string(), - work_title: naming.level_name.clone(), - work_description: prompt_text.clone(), - level_name: naming.level_name, - summary: prompt_text, - theme_tags: tags, - cover_image_src: level.cover_image_src.clone(), - cover_asset_id: level.cover_asset_id.clone(), - publication_status: "draft".to_string(), - updated_at: format_timestamp_micros(now), - published_at: None, - play_count: 0, - remix_count: 0, - like_count: 0, - recent_play_count_7d: 0, - point_incentive_total_half_points: 0, - point_incentive_claimed_points: 0, - anchor_pack, - publish_ready: true, - levels: vec![level.clone()], - }; - - Ok(json_success_body( - Some(&request_context), - PuzzleOnboardingGenerateResponse { - item: map_puzzle_work_profile_response(&state, item.clone()).summary, - level: map_puzzle_draft_level_response(level), - }, - )) -} - -pub async fn save_puzzle_onboarding_work( - State(state): State, - Extension(request_context): Extension, - Extension(authenticated): Extension, - payload: Result, JsonRejection>, -) -> Result, Response> { - let Json(payload) = 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": error.body_text(), - })), - ) - })?; - - let prompt_text = payload.prompt_text.trim().to_string(); - ensure_non_empty( - &request_context, - PUZZLE_WORKS_PROVIDER, - &prompt_text, - "promptText", - )?; - - let first_level = payload.item.levels.first().cloned().ok_or_else(|| { - puzzle_error_response( - &request_context, - PUZZLE_WORKS_PROVIDER, - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": PUZZLE_WORKS_PROVIDER, - "message": "新手引导拼图缺少可保存关卡", - })), - ) - })?; - let levels_json = serialize_puzzle_levels_response(&request_context, &payload.item.levels)?; - let work_title = payload.item.work_title.trim(); - let work_title = if work_title.is_empty() { - first_level.level_name.clone() - } else { - work_title.to_string() - }; - let work_description = payload.item.work_description.trim(); - let work_description = if work_description.is_empty() { - prompt_text.clone() - } else { - work_description.to_string() - }; - let summary = payload.item.summary.trim(); - let summary = if summary.is_empty() { - first_level.picture_description.clone() - } else { - summary.to_string() - }; - let now = current_utc_micros(); - let owner_user_id = authenticated.claims().user_id().to_string(); - let session_id = build_prefixed_uuid_id("puzzle-session-"); - state - .spacetime_client() - .create_puzzle_agent_session(PuzzleAgentSessionCreateRecordInput { - session_id: session_id.clone(), - owner_user_id: owner_user_id.clone(), - seed_text: prompt_text.clone(), - welcome_message_id: build_prefixed_uuid_id("puzzle-message-"), - welcome_message_text: build_puzzle_welcome_text(&prompt_text), - created_at_micros: now, - }) - .await - .map_err(|error| { - puzzle_error_response( - &request_context, - PUZZLE_WORKS_PROVIDER, - map_puzzle_client_error(error), - ) - })?; - - let (_, profile_id) = build_stable_puzzle_work_ids(session_id.as_str()); - let item = state - .spacetime_client() - .update_puzzle_work(PuzzleWorkUpsertRecordInput { - profile_id, - owner_user_id, - work_title, - work_description, - level_name: first_level.level_name, - summary, - theme_tags: payload.item.theme_tags, - cover_image_src: first_level.cover_image_src, - cover_asset_id: first_level.cover_asset_id, - levels_json: Some(levels_json), - updated_at_micros: now, - }) - .await - .map_err(|error| { - puzzle_error_response( - &request_context, - PUZZLE_WORKS_PROVIDER, - map_puzzle_client_error(error), - ) - })?; - - Ok(json_success_body( - Some(&request_context), - PuzzleWorkMutationResponse { - item: map_puzzle_work_profile_response(&state, item), - }, - )) -} - -pub async fn get_puzzle_agent_session( - State(state): State, - AxumPath(session_id): AxumPath, - Extension(request_context): Extension, - Extension(authenticated): Extension, -) -> Result, Response> { - ensure_non_empty( - &request_context, - PUZZLE_AGENT_API_BASE_PROVIDER, - &session_id, - "sessionId", - )?; - - let session = state - .spacetime_client() - .get_puzzle_agent_session(session_id, authenticated.claims().user_id().to_string()) - .await - .map_err(|error| { - puzzle_error_response( - &request_context, - PUZZLE_AGENT_API_BASE_PROVIDER, - map_puzzle_client_error(error), - ) - })?; - - Ok(json_success_body( - Some(&request_context), - PuzzleAgentSessionResponse { - session: map_puzzle_agent_session_response(session), - }, - )) -} - -pub async fn submit_puzzle_agent_message( - State(state): State, - AxumPath(session_id): AxumPath, - Extension(request_context): Extension, - Extension(authenticated): Extension, - payload: Result, JsonRejection>, -) -> Result, Response> { - let Json(payload) = payload.map_err(|error| { - puzzle_error_response( - &request_context, - PUZZLE_AGENT_API_BASE_PROVIDER, - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": PUZZLE_AGENT_API_BASE_PROVIDER, - "message": error.body_text(), - })), - ) - })?; - ensure_non_empty( - &request_context, - PUZZLE_AGENT_API_BASE_PROVIDER, - &session_id, - "sessionId", - )?; - - let client_message_id = payload.client_message_id.trim().to_string(); - let message_text = payload.text.trim().to_string(); - if client_message_id.is_empty() || message_text.is_empty() { - return Err(puzzle_bad_request( - &request_context, - PUZZLE_AGENT_API_BASE_PROVIDER, - "clientMessageId and text are required", - )); - } - - let owner_user_id = authenticated.claims().user_id().to_string(); - let submitted_session = state - .spacetime_client() - .submit_puzzle_agent_message(PuzzleAgentMessageSubmitRecordInput { - session_id: session_id.clone(), - owner_user_id: owner_user_id.clone(), - user_message_id: client_message_id, - user_message_text: message_text, - submitted_at_micros: current_utc_micros(), - }) - .await - .map_err(|error| { - puzzle_error_response( - &request_context, - PUZZLE_AGENT_API_BASE_PROVIDER, - map_puzzle_client_error(error), - ) - })?; - let turn_result = run_puzzle_agent_turn( - PuzzleAgentTurnRequest { - llm_client: state.llm_client(), - session: &submitted_session, - quick_fill_requested: payload.quick_fill_requested.unwrap_or(false), - enable_web_search: state.config.creation_agent_llm_web_search_enabled, - }, - |_| {}, - ) - .await; - let finalize_input = match turn_result { - Ok(turn_result) => build_finalize_record_input( - session_id.clone(), - owner_user_id.clone(), - format!("assistant-{session_id}-{}", current_utc_micros()), - turn_result, - current_utc_micros(), - ), - Err(error) => build_failed_finalize_record_input( - session_id.clone(), - owner_user_id.clone(), - &submitted_session, - error.to_string(), - current_utc_micros(), - ), - }; - let session = state - .spacetime_client() - .finalize_puzzle_agent_message(finalize_input) - .await - .map_err(|error| { - puzzle_error_response( - &request_context, - PUZZLE_AGENT_API_BASE_PROVIDER, - map_puzzle_client_error(error), - ) - })?; - - Ok(json_success_body( - Some(&request_context), - PuzzleAgentSessionResponse { - session: map_puzzle_agent_session_response(session), - }, - )) -} - -pub async fn stream_puzzle_agent_message( - State(state): State, - AxumPath(session_id): AxumPath, - Extension(request_context): Extension, - Extension(authenticated): Extension, - payload: Result, JsonRejection>, -) -> Result { - let Json(payload) = payload.map_err(|error| { - puzzle_error_response( - &request_context, - PUZZLE_AGENT_API_BASE_PROVIDER, - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": PUZZLE_AGENT_API_BASE_PROVIDER, - "message": error.body_text(), - })), - ) - })?; - ensure_non_empty( - &request_context, - PUZZLE_AGENT_API_BASE_PROVIDER, - &session_id, - "sessionId", - )?; - - let owner_user_id = authenticated.claims().user_id().to_string(); - let quick_fill_requested = payload.quick_fill_requested.unwrap_or(false); - let session = state - .spacetime_client() - .submit_puzzle_agent_message(PuzzleAgentMessageSubmitRecordInput { - session_id: session_id.clone(), - owner_user_id: owner_user_id.clone(), - user_message_id: payload.client_message_id.trim().to_string(), - user_message_text: payload.text.trim().to_string(), - submitted_at_micros: current_utc_micros(), - }) - .await - .map_err(|error| { - puzzle_error_response( - &request_context, - PUZZLE_AGENT_API_BASE_PROVIDER, - map_puzzle_client_error(error), - ) - })?; - let state = state.clone(); - let session_id_for_stream = session_id.clone(); - let owner_user_id_for_stream = owner_user_id.clone(); - let stream = async_stream::stream! { - let mut draft_writer = AiGenerationDraftWriter::new(AiGenerationDraftContext::new( - "puzzle", - owner_user_id_for_stream.as_str(), - session_id_for_stream.as_str(), - payload.client_message_id.as_str(), - "拼图模板生成草稿", - )); - if let Err(error) = draft_writer.ensure_started(state.spacetime_client()).await { - tracing::warn!(error = %error, "拼图模板生成草稿任务启动失败,主生成流程继续执行"); - } - let (reply_tx, mut reply_rx) = tokio::sync::mpsc::unbounded_channel::(); - let turn_result = { - let run_turn = run_puzzle_agent_turn( - PuzzleAgentTurnRequest { - llm_client: state.llm_client(), - session: &session, - quick_fill_requested, - enable_web_search: state.config.creation_agent_llm_web_search_enabled, - }, - move |text| { - let _ = reply_tx.send(text.to_string()); - }, - ); - tokio::pin!(run_turn); - - loop { - tokio::select! { - result = &mut run_turn => break result, - maybe_text = reply_rx.recv() => { - if let Some(text) = maybe_text { - draft_writer.persist_visible_text(state.spacetime_client(), text.as_str()).await; - yield Ok::(puzzle_sse_json_event_or_error( - "reply_delta", - json!({ "text": text }), - )); - } - } - } - } - }; - - while let Some(text) = reply_rx.recv().await { - draft_writer.persist_visible_text(state.spacetime_client(), text.as_str()).await; - yield Ok::(puzzle_sse_json_event_or_error( - "reply_delta", - json!({ "text": text }), - )); - } - - let finalize_input = match turn_result { - Ok(turn_result) => build_finalize_record_input( - session_id_for_stream.clone(), - owner_user_id_for_stream.clone(), - format!("assistant-{session_id_for_stream}-{}", current_utc_micros()), - turn_result, - current_utc_micros(), - ), - Err(error) => build_failed_finalize_record_input( - session_id_for_stream.clone(), - owner_user_id_for_stream.clone(), - &session, - error.to_string(), - current_utc_micros(), - ), - }; - let finalize_result = state - .spacetime_client() - .finalize_puzzle_agent_message(finalize_input) - .await; - let _final_session = match finalize_result { - Ok(session) => session, - Err(error) => { - yield Ok::(puzzle_sse_json_event_or_error( - "error", - json!({ "message": error.to_string() }), - )); - return; - } - }; - let final_session = match state - .spacetime_client() - .get_puzzle_agent_session(session_id_for_stream, owner_user_id_for_stream) - .await - { - Ok(session) => session, - Err(error) => { - yield Ok::(puzzle_sse_json_event_or_error( - "error", - json!({ "message": error.to_string() }), - )); - return; - } - }; - let session_response = map_puzzle_agent_session_response(final_session); - yield Ok::(puzzle_sse_json_event_or_error( - "session", - json!({ "session": session_response }), - )); - yield Ok::(puzzle_sse_json_event_or_error( - "done", - json!({ "ok": true }), - )); - }; - Ok(Sse::new(stream).into_response()) -} - -pub async fn execute_puzzle_agent_action( - State(state): State, - AxumPath(session_id): AxumPath, - Extension(request_context): Extension, - Extension(authenticated): Extension, - payload: Result, JsonRejection>, -) -> Result, Response> { - let Json(payload) = payload.map_err(|error| { - puzzle_error_response( - &request_context, - PUZZLE_AGENT_API_BASE_PROVIDER, - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": PUZZLE_AGENT_API_BASE_PROVIDER, - "message": error.body_text(), - })), - ) - })?; - ensure_non_empty( - &request_context, - PUZZLE_AGENT_API_BASE_PROVIDER, - &session_id, - "sessionId", - )?; - - let owner_user_id = authenticated.claims().user_id().to_string(); - let now = current_utc_micros(); - let action = payload.action.trim().to_string(); - let billing_asset_id = format!("{session_id}:{now}"); - tracing::info!( - provider = PUZZLE_AGENT_API_BASE_PROVIDER, - session_id = %session_id, - owner_user_id = %owner_user_id, - action = %action, - image_model = resolve_puzzle_image_model(payload.image_model.as_deref()).request_model_name(), - prompt_chars = payload - .prompt_text - .as_deref() - .map(|value| value.chars().count()) - .unwrap_or(0), - has_reference_image = has_puzzle_reference_images( - payload.reference_image_src.as_deref(), - payload.reference_image_srcs.as_slice(), - ), - "拼图 Agent action 开始执行" - ); - let (operation_type, phase_label, phase_detail, session) = match action.as_str() { - "compile_puzzle_draft" => { - let ai_redraw = payload.ai_redraw.unwrap_or(true); - let reference_image_sources = collect_puzzle_reference_image_sources( - payload.reference_image_src.as_deref(), - payload.reference_image_srcs.as_slice(), - ); - let primary_reference_image_src = reference_image_sources.first().map(String::as_str); - let prompt_text = payload - .picture_description - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .or_else(|| payload.prompt_text.as_deref()); - let compile_session_id = match save_puzzle_form_payload_before_compile( - &state, - &request_context, - &session_id, - &owner_user_id, - &payload, - now, - ) - .await - { - Ok(next_session_id) => next_session_id, - Err(response) => return Err(response), - }; - let session = if ai_redraw { - execute_billable_asset_operation_with_cost( - &state, - &owner_user_id, - "puzzle_initial_image", - &billing_asset_id, - PUZZLE_IMAGE_GENERATION_POINTS_COST, - async { - compile_puzzle_draft_with_initial_cover( - &state, - compile_session_id.clone(), - owner_user_id.clone(), - prompt_text, - primary_reference_image_src, - payload.image_model.as_deref(), - now, - ) - .await - }, - ) - .await - } else { - compile_puzzle_draft_with_uploaded_cover( - &state, - compile_session_id.clone(), - owner_user_id.clone(), - prompt_text, - payload.reference_image_src.as_deref(), - now, - ) - .await - } - .map_err(|error| { - puzzle_error_response(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, error) - }); - ( - "compile_puzzle_draft", - "首关拼图草稿", - if ai_redraw { - "已编译首关草稿、并行生成首关画面和 UI 背景并写入正式草稿。" - } else { - "已编译首关草稿,并直接应用上传图片、生成 UI 背景为第一关图片。" - }, - session, - ) - } - "save_puzzle_form_draft" => { - let seed_text = build_puzzle_form_seed_text_from_parts( - None, - None, - payload - .picture_description - .as_deref() - .or(payload.prompt_text.as_deref()), - ); - let save_result = state - .spacetime_client() - .save_puzzle_form_draft(PuzzleFormDraftSaveRecordInput { - session_id: session_id.clone(), - owner_user_id: owner_user_id.clone(), - seed_text, - saved_at_micros: now, - }) - .await; - let session = match save_result { - Ok(session) => Ok(session), - Err(error) if is_missing_puzzle_form_draft_procedure_error(&error) => { - // 中文注释:旧 wasm 缺少该自动保存 procedure 时,返回当前 session,避免填表页被非关键错误打断。 - tracing::warn!( - provider = PUZZLE_AGENT_API_BASE_PROVIDER, - session_id = %session_id, - owner_user_id = %owner_user_id, - error = %error, - "拼图表单自动保存 procedure 缺失,降级返回当前会话" - ); - state - .spacetime_client() - .get_puzzle_agent_session(session_id.clone(), owner_user_id.clone()) - .await - .map_err(|fallback_error| { - puzzle_error_response( - &request_context, - PUZZLE_AGENT_API_BASE_PROVIDER, - map_puzzle_client_error(fallback_error), - ) - }) - } - Err(error) => Err(puzzle_error_response( - &request_context, - PUZZLE_AGENT_API_BASE_PROVIDER, - map_puzzle_client_error(error), - )), - }; - ( - "save_puzzle_form_draft", - "表单草稿保存", - "拼图表单草稿已保存。", - session, - ) - } - "generate_puzzle_images" => { - let target_level_id = payload.level_id.clone(); - let levels_json = normalize_puzzle_levels_json_for_module( - payload.levels_json.as_deref(), - ) - .map_err(|message| { - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": PUZZLE_AGENT_API_BASE_PROVIDER, - "message": message, - })) - }); - let session = execute_billable_asset_operation_with_cost( - &state, - &owner_user_id, - "puzzle_generated_image", - &billing_asset_id, - PUZZLE_IMAGE_GENERATION_POINTS_COST, - async { - let levels_json = levels_json?; - let session = get_puzzle_session_for_image_generation( - &state, - session_id.clone(), - owner_user_id.clone(), - &payload, - levels_json.as_deref(), - now, - ) - .await?; - let mut draft = session.draft.clone().ok_or_else(|| { - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": PUZZLE_AGENT_API_BASE_PROVIDER, - "message": "拼图结果页草稿尚未生成", - })) - })?; - if let Some(levels_json) = levels_json.as_ref() { - draft.levels = parse_puzzle_level_records_from_module_json(levels_json)?; - } - let mut target_level = - select_puzzle_level_for_api(&draft, target_level_id.as_deref())?; - let fallback_level_name = target_level.level_name.clone(); - let prompt = resolve_puzzle_level_image_prompt( - payload.prompt_text.as_deref(), - &target_level.picture_description, - ); - let reference_image_sources = collect_puzzle_reference_image_sources( - payload.reference_image_src.as_deref(), - payload.reference_image_srcs.as_slice(), - ); - let primary_reference_image_src = - reference_image_sources.first().map(String::as_str); - // 拼图结果页从多候选抽卡收口为单图替换,前端传入的旧 candidateCount 只做兼容忽略。 - let candidate_count = 1; - let candidate_start_index = target_level.candidates.len(); - let candidates = generate_puzzle_image_candidates( - &state, - owner_user_id.as_str(), - &session.session_id, - &target_level.level_name, - &prompt, - primary_reference_image_src, - payload.ai_redraw.unwrap_or(true), - payload.image_model.as_deref(), - candidate_count, - candidate_start_index, - ) - .await - .map_err(map_puzzle_generation_endpoint_error)?; - if candidates.is_empty() { - return Err(AppError::from_status(StatusCode::BAD_GATEWAY).with_details( - json!({ - "provider": PUZZLE_AGENT_API_BASE_PROVIDER, - "message": "拼图候选图生成结果为空", - }), - )); - } - if let Some(refined_naming) = generate_puzzle_first_level_name_from_image( - &state, - target_level.picture_description.as_str(), - &candidates[0].downloaded_image, - ) - .await - { - target_level.level_name = refined_naming.level_name; - if refined_naming.ui_background_prompt.is_some() { - target_level.ui_background_prompt = refined_naming.ui_background_prompt; - } - } - let generated_level_name = target_level.level_name.clone(); - let levels_json_with_generated_name = - Some(serialize_puzzle_level_records_for_module( - &build_puzzle_levels_with_primary_update( - &draft, - &target_level, - primary_reference_image_src, - ), - )?); - 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 save_result = state - .spacetime_client() - .save_puzzle_generated_images(PuzzleGeneratedImagesSaveRecordInput { - session_id: 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, - candidates_json, - saved_at_micros: now, - }) - .await; - match save_result { - Ok(session) => Ok(session), - Err(error) - if should_skip_asset_operation_billing_for_connectivity(&error) => - { - // 中文注释:VectorEngine/OSS 已生成真实图片时,SpacetimeDB 短暂 503 不应让前端看不到本次图片;先返回内存合成快照,待后续操作恢复正常持久化。 - tracing::warn!( - provider = PUZZLE_AGENT_API_BASE_PROVIDER, - session_id = %session.session_id, - owner_user_id = %owner_user_id, - error = %error, - "拼图图片已生成但 SpacetimeDB 草稿回写不可用,降级返回本次生成快照" - ); - let fallback_session = - replace_puzzle_session_draft_snapshot(session, draft, now); - Ok(apply_generated_puzzle_candidates_to_session_snapshot( - 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, - )) - } - Err(error) => Err(map_puzzle_client_error(error)), - } - }, - ) - .await - .map_err(|error| { - puzzle_error_response(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, error) - }); - ( - "generate_puzzle_images", - "拼图图片生成", - "已生成并替换当前拼图图片。", - session, - ) - } - "generate_puzzle_ui_background" => { - let target_level_id = payload.level_id.clone(); - let raw_prompt = payload - .prompt_text - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .unwrap_or_default() - .to_string(); - let levels_json = normalize_puzzle_levels_json_for_module( - payload.levels_json.as_deref(), - ) - .map_err(|message| { - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": PUZZLE_AGENT_API_BASE_PROVIDER, - "message": message, - })) - }); - let session = execute_billable_asset_operation_with_cost( - &state, - &owner_user_id, - "puzzle_ui_background_image", - &billing_asset_id, - PUZZLE_IMAGE_GENERATION_POINTS_COST, - async { - let levels_json = levels_json?; - let session = get_puzzle_session_for_image_generation( - &state, - session_id.clone(), - owner_user_id.clone(), - &payload, - levels_json.as_deref(), - now, - ) - .await?; - let mut draft = session.draft.clone().ok_or_else(|| { - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": PUZZLE_AGENT_API_BASE_PROVIDER, - "message": "拼图结果页草稿尚未生成", - })) - })?; - if let Some(levels_json) = levels_json.as_ref() { - draft.levels = parse_puzzle_level_records_from_module_json(levels_json)?; - } - let target_level = - select_puzzle_level_for_api(&draft, target_level_id.as_deref())?; - let resolved_prompt = normalize_puzzle_ui_background_prompt( - raw_prompt.as_str(), - &draft, - &target_level, - ); - let generated = generate_puzzle_ui_background_image( - &state, - owner_user_id.as_str(), - &session.session_id, - &target_level.level_name, - resolved_prompt.as_str(), - ) - .await - .map_err(map_puzzle_generation_endpoint_error)?; - let save_result = state - .spacetime_client() - .save_puzzle_ui_background(PuzzleUiBackgroundSaveRecordInput { - session_id: session.session_id.clone(), - owner_user_id: owner_user_id.clone(), - level_id: Some(target_level.level_id.clone()), - levels_json, - prompt: resolved_prompt.clone(), - image_src: generated.image_src.clone(), - image_object_key: Some(generated.object_key.clone()), - saved_at_micros: now, - }) - .await; - match save_result { - Ok(session) => Ok(session), - Err(error) - if should_skip_asset_operation_billing_for_connectivity(&error) => - { - tracing::warn!( - provider = PUZZLE_AGENT_API_BASE_PROVIDER, - session_id = %session.session_id, - owner_user_id = %owner_user_id, - error = %error, - "拼图 UI 背景图已生成但 SpacetimeDB 草稿回写不可用,降级返回本次生成快照" - ); - let fallback_session = - replace_puzzle_session_draft_snapshot(session, draft, now); - Ok(apply_generated_puzzle_ui_background_to_session_snapshot( - fallback_session, - target_level.level_id.as_str(), - resolved_prompt, - generated.image_src, - Some(generated.object_key), - now, - )) - } - Err(error) => Err(map_puzzle_client_error(error)), - } - }, - ) - .await - .map_err(|error| { - puzzle_error_response(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, error) - }); - ( - "generate_puzzle_ui_background", - "UI 背景图生成", - "已生成拼图 UI 背景图。", - session, - ) - } - "generate_puzzle_tags" => { - let work_title = payload - .work_title - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .ok_or_else(|| { - puzzle_bad_request( - &request_context, - PUZZLE_AGENT_API_BASE_PROVIDER, - "作品名称不能为空", - ) - })?; - let work_description = payload - .work_description - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .ok_or_else(|| { - puzzle_bad_request( - &request_context, - PUZZLE_AGENT_API_BASE_PROVIDER, - "作品描述不能为空", - ) - })?; - let levels_json = normalize_puzzle_levels_json_for_module( - payload.levels_json.as_deref(), - ) - .map_err(|message| { - puzzle_error_response( - &request_context, - PUZZLE_AGENT_API_BASE_PROVIDER, - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": PUZZLE_AGENT_API_BASE_PROVIDER, - "message": message, - })), - ) - })?; - let generated_tags = - generate_puzzle_work_tags(&state, work_title, work_description).await; - let session = save_generated_puzzle_tags_to_session( - &state, - &session_id, - &owner_user_id, - &payload, - generated_tags, - levels_json, - now, - ) - .await - .map_err(|error| { - puzzle_error_response(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, error) - }); - ( - "generate_puzzle_tags", - "作品标签生成", - "已生成 6 个作品标签。", - session, - ) - } - "select_puzzle_image" => { - let candidate_id = payload - .candidate_id - .clone() - .filter(|value| !value.trim().is_empty()) - .ok_or_else(|| { - puzzle_bad_request( - &request_context, - PUZZLE_AGENT_API_BASE_PROVIDER, - "candidateId is required", - ) - })?; - let session = state - .spacetime_client() - .select_puzzle_cover_image(PuzzleSelectCoverImageRecordInput { - session_id: session_id.clone(), - owner_user_id: owner_user_id.clone(), - level_id: payload.level_id.clone(), - candidate_id, - selected_at_micros: now, - }) - .await - .map_err(|error| { - puzzle_error_response( - &request_context, - PUZZLE_AGENT_API_BASE_PROVIDER, - map_puzzle_client_error(error), - ) - }); - ( - "select_puzzle_image", - "正式图确认", - "已应用正式拼图图片。", - session, - ) - } - "publish_puzzle_work" => { - let levels_json = normalize_puzzle_levels_json_for_module( - payload.levels_json.as_deref(), - ) - .map_err(|error| { - puzzle_error_response( - &request_context, - PUZZLE_AGENT_API_BASE_PROVIDER, - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": PUZZLE_AGENT_API_BASE_PROVIDER, - "message": error, - })), - ) - })?; - let (work_id, profile_id) = build_stable_puzzle_work_ids(&session_id); - let author_display_name = resolve_author_display_name(&state, &authenticated); - let profile = execute_billable_asset_operation( - &state, - &owner_user_id, - "puzzle_publish_work", - &work_id, - async { - state - .spacetime_client() - .publish_puzzle_work(PuzzlePublishRecordInput { - session_id: session_id.clone(), - owner_user_id: owner_user_id.clone(), - // 发布沿用 session 派生的稳定作品 ID,避免草稿卡与已发布卡重复。 - work_id: work_id.clone(), - profile_id, - author_display_name, - work_title: payload.work_title.clone(), - work_description: payload.work_description.clone(), - level_name: payload.level_name.clone(), - summary: payload.summary.clone(), - theme_tags: payload.theme_tags.clone(), - levels_json, - published_at_micros: now, - }) - .await - .map_err(map_puzzle_client_error) - }, - ) - .await - .map_err(|error| { - puzzle_error_response(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, error) - })?; - - let session = state - .spacetime_client() - .get_puzzle_agent_session(session_id.clone(), owner_user_id.clone()) - .await - .map_err(|error| { - puzzle_error_response( - &request_context, - PUZZLE_AGENT_API_BASE_PROVIDER, - map_puzzle_client_error(error), - ) - })?; - - return Ok(json_success_body( - Some(&request_context), - PuzzleAgentActionResponse { - operation: PuzzleAgentOperationResponse { - operation_id: profile.profile_id.clone(), - operation_type: "publish_puzzle_work".to_string(), - status: "completed".to_string(), - phase_label: "作品发布".to_string(), - phase_detail: "拼图作品已发布到广场。".to_string(), - progress: 100, - error: None, - }, - session: map_puzzle_agent_session_response(session), - }, - )); - } - other => { - return Err(puzzle_bad_request( - &request_context, - PUZZLE_AGENT_API_BASE_PROVIDER, - format!("action `{other}` is not supported").as_str(), - )); - } - }; - - let session = session?; - - Ok(json_success_body( - Some(&request_context), - PuzzleAgentActionResponse { - operation: PuzzleAgentOperationResponse { - operation_id: session.session_id.clone(), - operation_type: operation_type.to_string(), - status: "completed".to_string(), - phase_label: phase_label.to_string(), - phase_detail: phase_detail.to_string(), - progress: 100, - error: None, - }, - session: map_puzzle_agent_session_response(session), - }, - )) -} - -pub async fn get_puzzle_works( - State(state): State, - Extension(request_context): Extension, - Extension(authenticated): Extension, -) -> Result, Response> { - let items = state - .spacetime_client() - .list_puzzle_works(authenticated.claims().user_id().to_string()) - .await - .map_err(|error| { - puzzle_error_response( - &request_context, - PUZZLE_WORKS_PROVIDER, - map_puzzle_client_error(error), - ) - })?; - - Ok(json_success_body( - Some(&request_context), - PuzzleWorksResponse { - items: items - .into_iter() - .map(|item| map_puzzle_work_summary_response(&state, item)) - .collect(), - }, - )) -} - -pub async fn get_puzzle_work_detail( - State(state): State, - AxumPath(profile_id): AxumPath, - Extension(request_context): Extension, - Extension(_authenticated): Extension, -) -> Result, Response> { - ensure_non_empty( - &request_context, - PUZZLE_WORKS_PROVIDER, - &profile_id, - "profileId", - )?; - - let item = state - .spacetime_client() - .get_puzzle_work_detail(profile_id) - .await - .map_err(|error| { - puzzle_error_response( - &request_context, - PUZZLE_WORKS_PROVIDER, - map_puzzle_client_error(error), - ) - })?; - - Ok(json_success_body( - Some(&request_context), - PuzzleWorkDetailResponse { - item: map_puzzle_work_profile_response(&state, item), - }, - )) -} - -pub async fn put_puzzle_work( - State(state): State, - AxumPath(profile_id): AxumPath, - Extension(request_context): Extension, - Extension(authenticated): Extension, - payload: Result, JsonRejection>, -) -> Result, Response> { - let Json(payload) = 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": error.body_text(), - })), - ) - })?; - ensure_non_empty( - &request_context, - PUZZLE_WORKS_PROVIDER, - &profile_id, - "profileId", - )?; - - let item = state - .spacetime_client() - .update_puzzle_work(PuzzleWorkUpsertRecordInput { - profile_id, - owner_user_id: authenticated.claims().user_id().to_string(), - work_title: payload.work_title, - work_description: payload.work_description, - level_name: payload.level_name, - summary: payload.summary, - theme_tags: payload.theme_tags, - cover_image_src: payload.cover_image_src, - cover_asset_id: payload.cover_asset_id, - levels_json: Some(serialize_puzzle_levels_response( - &request_context, - &payload.levels, - )?), - updated_at_micros: current_utc_micros(), - }) - .await - .map_err(|error| { - puzzle_error_response( - &request_context, - PUZZLE_WORKS_PROVIDER, - map_puzzle_client_error(error), - ) - })?; - - Ok(json_success_body( - Some(&request_context), - PuzzleWorkMutationResponse { - item: map_puzzle_work_profile_response(&state, item), - }, - )) -} - -pub async fn delete_puzzle_work( - State(state): State, - AxumPath(profile_id): AxumPath, - Extension(request_context): Extension, - Extension(authenticated): Extension, -) -> Result, Response> { - ensure_non_empty( - &request_context, - PUZZLE_WORKS_PROVIDER, - &profile_id, - "profileId", - )?; - - let items = state - .spacetime_client() - .delete_puzzle_work(profile_id, authenticated.claims().user_id().to_string()) - .await - .map_err(|error| { - puzzle_error_response( - &request_context, - PUZZLE_WORKS_PROVIDER, - map_puzzle_client_error(error), - ) - })?; - - Ok(json_success_body( - Some(&request_context), - PuzzleWorksResponse { - items: items - .into_iter() - .map(|item| map_puzzle_work_summary_response(&state, item)) - .collect(), - }, - )) -} - -pub async fn claim_puzzle_work_point_incentive( - State(state): State, - AxumPath(profile_id): AxumPath, - Extension(request_context): Extension, - Extension(authenticated): Extension, -) -> Result, Response> { - ensure_non_empty( - &request_context, - PUZZLE_WORKS_PROVIDER, - &profile_id, - "profileId", - )?; - - let item = state - .spacetime_client() - .claim_puzzle_work_point_incentive(PuzzleWorkPointIncentiveClaimRecordInput { - profile_id, - owner_user_id: authenticated.claims().user_id().to_string(), - claimed_at_micros: current_utc_micros(), - }) - .await - .map_err(|error| { - puzzle_error_response( - &request_context, - PUZZLE_WORKS_PROVIDER, - map_puzzle_client_error(error), - ) - })?; - - Ok(json_success_body( - Some(&request_context), - PuzzleWorkMutationResponse { - item: map_puzzle_work_profile_response(&state, item), - }, - )) -} - -pub async fn list_puzzle_gallery( - State(state): State, - Extension(request_context): Extension, -) -> Result { - if let Some(response) = state.puzzle_gallery_cache().read_fresh_response().await { - crate::telemetry::record_puzzle_gallery_cache_hit(); - return Ok(puzzle_gallery_cached_json(&request_context, response)); - } - crate::telemetry::record_puzzle_gallery_cache_miss(); - let _rebuild_guard = state.puzzle_gallery_cache().acquire_rebuild_guard().await; - if let Some(response) = state.puzzle_gallery_cache().read_fresh_response().await { - crate::telemetry::record_puzzle_gallery_cache_hit(); - return Ok(puzzle_gallery_cached_json(&request_context, response)); - } - - let rebuild_started_at = std::time::Instant::now(); - let items = state - .spacetime_client() - .list_puzzle_gallery() - .await - .map_err(|error| { - puzzle_error_response( - &request_context, - PUZZLE_GALLERY_PROVIDER, - map_puzzle_client_error(error), - ) - })?; - - let response = build_puzzle_gallery_window_response( - items - .into_iter() - .map(|item| map_puzzle_gallery_card_response(&state, item)) - .collect(), - ); - let cached_response = state - .puzzle_gallery_cache() - .store_response(response) - .await - .map_err(|error| { - puzzle_error_response( - &request_context, - PUZZLE_GALLERY_PROVIDER, - AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ - "provider": PUZZLE_GALLERY_PROVIDER, - "message": format!("拼图广场缓存序列化失败:{error}"), - })), - ) - })?; - crate::telemetry::record_puzzle_gallery_cache_rebuild( - rebuild_started_at.elapsed(), - cached_response.data_json_len(), - ); - - Ok(puzzle_gallery_cached_json(&request_context, cached_response)) -} - -pub async fn get_puzzle_gallery_detail( - State(state): State, - AxumPath(profile_id): AxumPath, - Extension(request_context): Extension, -) -> Result, Response> { - ensure_non_empty( - &request_context, - PUZZLE_GALLERY_PROVIDER, - &profile_id, - "profileId", - )?; - - let item = state - .spacetime_client() - .get_puzzle_gallery_detail(profile_id) - .await - .map_err(|error| { - puzzle_error_response( - &request_context, - PUZZLE_GALLERY_PROVIDER, - map_puzzle_client_error(error), - ) - })?; - - Ok(json_success_body( - Some(&request_context), - PuzzleGalleryDetailResponse { - item: map_puzzle_work_profile_response(&state, item), - }, - )) -} - -pub async fn record_puzzle_gallery_like( - State(state): State, - AxumPath(profile_id): AxumPath, - Extension(request_context): Extension, - Extension(authenticated): Extension, -) -> Result, Response> { - ensure_non_empty( - &request_context, - PUZZLE_GALLERY_PROVIDER, - &profile_id, - "profileId", - )?; - - let item = state - .spacetime_client() - .record_puzzle_work_like(PuzzleWorkLikeReportRecordInput { - profile_id, - user_id: authenticated.claims().user_id().to_string(), - liked_at_micros: current_utc_micros(), - }) - .await - .map_err(|error| { - puzzle_error_response( - &request_context, - PUZZLE_GALLERY_PROVIDER, - map_puzzle_client_error(error), - ) - })?; - - Ok(json_success_body( - Some(&request_context), - PuzzleGalleryDetailResponse { - item: map_puzzle_work_profile_response(&state, item), - }, - )) -} - -pub async fn remix_puzzle_gallery_work( - State(state): State, - AxumPath(profile_id): AxumPath, - Extension(request_context): Extension, - Extension(authenticated): Extension, -) -> Result, Response> { - ensure_non_empty( - &request_context, - PUZZLE_GALLERY_PROVIDER, - &profile_id, - "profileId", - )?; - - let owner_user_id = authenticated.claims().user_id().to_string(); - let session = state - .spacetime_client() - .remix_puzzle_work(PuzzleWorkRemixRecordInput { - source_profile_id: profile_id, - target_owner_user_id: owner_user_id, - target_session_id: build_prefixed_uuid_id("puzzle-session-"), - target_profile_id: build_prefixed_uuid_id("puzzle-profile-"), - target_work_id: build_prefixed_uuid_id("puzzle-work-"), - author_display_name: resolve_author_display_name(&state, &authenticated), - welcome_message_id: build_prefixed_uuid_id("puzzle-message-"), - remixed_at_micros: current_utc_micros(), - }) - .await - .map_err(|error| { - puzzle_error_response( - &request_context, - PUZZLE_GALLERY_PROVIDER, - map_puzzle_client_error(error), - ) - })?; - - Ok(json_success_body( - Some(&request_context), - PuzzleAgentSessionResponse { - session: map_puzzle_agent_session_response(session), - }, - )) -} - -pub async fn start_puzzle_run( - State(state): State, - Extension(request_context): Extension, - Extension(authenticated): Extension, - payload: Result, JsonRejection>, -) -> Result, Response> { - let Json(payload) = payload.map_err(|error| { - puzzle_error_response( - &request_context, - PUZZLE_RUNTIME_PROVIDER, - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": PUZZLE_RUNTIME_PROVIDER, - "message": error.body_text(), - })), - ) - })?; - ensure_non_empty( - &request_context, - PUZZLE_RUNTIME_PROVIDER, - &payload.profile_id, - "profileId", - )?; - - let run = state - .spacetime_client() - .start_puzzle_run(PuzzleRunStartRecordInput { - run_id: build_prefixed_uuid_id("puzzle-run-"), - owner_user_id: authenticated.claims().user_id().to_string(), - profile_id: payload.profile_id.clone(), - level_id: payload.level_id.clone(), - started_at_micros: current_utc_micros(), - }) - .await - .map_err(|error| { - puzzle_error_response( - &request_context, - PUZZLE_RUNTIME_PROVIDER, - map_puzzle_client_error(error), - ) - })?; - - record_work_play_start_after_success( - &state, - &request_context, - WorkPlayTrackingDraft::new( - "puzzle", - payload.profile_id.clone(), - &authenticated, - "/api/runtime/puzzle/...", - ) - .profile_id(payload.profile_id.clone()) - .extra(json!({ - "levelId": payload.level_id, - "runId": run.run_id, - })), - ) - .await; - - Ok(json_success_body( - Some(&request_context), - PuzzleRunResponse { - run: map_puzzle_run_response(enrich_puzzle_run_author_name(&state, run).await), - }, - )) -} - -pub async fn get_puzzle_run( - State(state): State, - AxumPath(run_id): AxumPath, - Extension(request_context): Extension, - Extension(authenticated): Extension, -) -> Result, Response> { - ensure_non_empty(&request_context, PUZZLE_RUNTIME_PROVIDER, &run_id, "runId")?; - - let run = state - .spacetime_client() - .get_puzzle_run(run_id, authenticated.claims().user_id().to_string()) - .await - .map_err(|error| { - puzzle_error_response( - &request_context, - PUZZLE_RUNTIME_PROVIDER, - map_puzzle_client_error(error), - ) - })?; - - Ok(json_success_body( - Some(&request_context), - PuzzleRunResponse { - run: map_puzzle_run_response(enrich_puzzle_run_author_name(&state, run).await), - }, - )) -} - -pub async fn swap_puzzle_pieces( - State(state): State, - AxumPath(run_id): AxumPath, - Extension(request_context): Extension, - Extension(authenticated): Extension, - payload: Result, JsonRejection>, -) -> Result, Response> { - let Json(payload) = payload.map_err(|error| { - puzzle_error_response( - &request_context, - PUZZLE_RUNTIME_PROVIDER, - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": PUZZLE_RUNTIME_PROVIDER, - "message": error.body_text(), - })), - ) - })?; - ensure_non_empty(&request_context, PUZZLE_RUNTIME_PROVIDER, &run_id, "runId")?; - ensure_non_empty( - &request_context, - PUZZLE_RUNTIME_PROVIDER, - &payload.first_piece_id, - "firstPieceId", - )?; - ensure_non_empty( - &request_context, - PUZZLE_RUNTIME_PROVIDER, - &payload.second_piece_id, - "secondPieceId", - )?; - - let run = state - .spacetime_client() - .swap_puzzle_pieces(PuzzleRunSwapRecordInput { - run_id, - owner_user_id: authenticated.claims().user_id().to_string(), - first_piece_id: payload.first_piece_id, - second_piece_id: payload.second_piece_id, - swapped_at_micros: current_utc_micros(), - }) - .await - .map_err(|error| { - puzzle_error_response( - &request_context, - PUZZLE_RUNTIME_PROVIDER, - map_puzzle_client_error(error), - ) - })?; - - Ok(json_success_body( - Some(&request_context), - PuzzleRunResponse { - run: map_puzzle_run_response(enrich_puzzle_run_author_name(&state, run).await), - }, - )) -} - -pub async fn drag_puzzle_piece_or_group( - State(state): State, - AxumPath(run_id): AxumPath, - Extension(request_context): Extension, - Extension(authenticated): Extension, - payload: Result, JsonRejection>, -) -> Result, Response> { - let Json(payload) = payload.map_err(|error| { - puzzle_error_response( - &request_context, - PUZZLE_RUNTIME_PROVIDER, - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": PUZZLE_RUNTIME_PROVIDER, - "message": error.body_text(), - })), - ) - })?; - ensure_non_empty(&request_context, PUZZLE_RUNTIME_PROVIDER, &run_id, "runId")?; - ensure_non_empty( - &request_context, - PUZZLE_RUNTIME_PROVIDER, - &payload.piece_id, - "pieceId", - )?; - - let run = state - .spacetime_client() - .drag_puzzle_piece_or_group(PuzzleRunDragRecordInput { - run_id, - owner_user_id: authenticated.claims().user_id().to_string(), - piece_id: payload.piece_id, - target_row: payload.target_row, - target_col: payload.target_col, - dragged_at_micros: current_utc_micros(), - }) - .await - .map_err(|error| { - puzzle_error_response( - &request_context, - PUZZLE_RUNTIME_PROVIDER, - map_puzzle_client_error(error), - ) - })?; - - Ok(json_success_body( - Some(&request_context), - PuzzleRunResponse { - run: map_puzzle_run_response(enrich_puzzle_run_author_name(&state, run).await), - }, - )) -} - -pub async fn advance_puzzle_next_level( - State(state): State, - AxumPath(run_id): AxumPath, - Extension(request_context): Extension, - Extension(authenticated): Extension, - payload: Result, JsonRejection>, -) -> Result, Response> { - ensure_non_empty(&request_context, PUZZLE_RUNTIME_PROVIDER, &run_id, "runId")?; - let payload = match payload { - Ok(Json(payload)) => payload, - Err(error) if error.status() == StatusCode::UNSUPPORTED_MEDIA_TYPE => { - AdvancePuzzleNextLevelRequest { - target_profile_id: None, - } - } - Err(error) => { - return Err(puzzle_error_response( - &request_context, - PUZZLE_RUNTIME_PROVIDER, - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": PUZZLE_RUNTIME_PROVIDER, - "message": error.body_text(), - })), - )); - } - }; - - let run = state - .spacetime_client() - .advance_puzzle_next_level(spacetime_client::PuzzleRunNextLevelRecordInput { - run_id, - owner_user_id: authenticated.claims().user_id().to_string(), - target_profile_id: payload.target_profile_id, - advanced_at_micros: current_utc_micros(), - }) - .await - .map_err(|error| { - puzzle_error_response( - &request_context, - PUZZLE_RUNTIME_PROVIDER, - map_puzzle_client_error(error), - ) - })?; - - Ok(json_success_body( - Some(&request_context), - PuzzleRunResponse { - run: map_puzzle_run_response(enrich_puzzle_run_author_name(&state, run).await), - }, - )) -} - -pub async fn update_puzzle_run_pause( - State(state): State, - AxumPath(run_id): AxumPath, - Extension(request_context): Extension, - Extension(authenticated): Extension, - payload: Result, JsonRejection>, -) -> Result, Response> { - let Json(payload) = payload.map_err(|error| { - puzzle_error_response( - &request_context, - PUZZLE_RUNTIME_PROVIDER, - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": PUZZLE_RUNTIME_PROVIDER, - "message": error.body_text(), - })), - ) - })?; - ensure_non_empty(&request_context, PUZZLE_RUNTIME_PROVIDER, &run_id, "runId")?; - - let run = state - .spacetime_client() - .update_puzzle_run_pause(PuzzleRunPauseRecordInput { - run_id, - owner_user_id: authenticated.claims().user_id().to_string(), - paused: payload.paused, - updated_at_micros: current_utc_micros(), - }) - .await - .map_err(|error| { - puzzle_error_response( - &request_context, - PUZZLE_RUNTIME_PROVIDER, - map_puzzle_client_error(error), - ) - })?; - - Ok(json_success_body( - Some(&request_context), - PuzzleRunResponse { - run: map_puzzle_run_response(enrich_puzzle_run_author_name(&state, run).await), - }, - )) -} - -pub async fn use_puzzle_runtime_prop( - State(state): State, - AxumPath(run_id): AxumPath, - Extension(request_context): Extension, - Extension(authenticated): Extension, - payload: Result, JsonRejection>, -) -> Result, Response> { - let Json(payload) = payload.map_err(|error| { - puzzle_error_response( - &request_context, - PUZZLE_RUNTIME_PROVIDER, - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": PUZZLE_RUNTIME_PROVIDER, - "message": error.body_text(), - })), - ) - })?; - ensure_non_empty(&request_context, PUZZLE_RUNTIME_PROVIDER, &run_id, "runId")?; - ensure_non_empty( - &request_context, - PUZZLE_RUNTIME_PROVIDER, - &payload.prop_kind, - "propKind", - )?; - - let owner_user_id = authenticated.claims().user_id().to_string(); - let prop_kind = payload.prop_kind.trim().to_string(); - let billing_asset_kind = match prop_kind.as_str() { - "hint" => "puzzle_prop_hint", - "reference" => "puzzle_prop_preview", - "freezeTime" | "freeze_time" => "puzzle_prop_freeze_time", - "extendTime" | "extend_time" => "puzzle_prop_extend_time", - _ => { - return Err(puzzle_bad_request( - &request_context, - PUZZLE_RUNTIME_PROVIDER, - "unknown puzzle prop kind", - )); - } - }; - let should_sync_freeze_boundary = matches!(prop_kind.as_str(), "freezeTime" | "freeze_time"); - let billing_asset_id = format!("{}:{}:{}", run_id, prop_kind, current_utc_micros()); - let reducer_owner_user_id = owner_user_id.clone(); - let reducer_run_id = run_id.clone(); - let fallback_run_id = run_id.clone(); - let fallback_owner_user_id = owner_user_id.clone(); - let run_result = execute_billable_asset_operation( - &state, - &owner_user_id, - billing_asset_kind, - billing_asset_id.as_str(), - async { - state - .spacetime_client() - .use_puzzle_runtime_prop(PuzzleRunPropRecordInput { - run_id: reducer_run_id, - owner_user_id: reducer_owner_user_id, - prop_kind, - used_at_micros: current_utc_micros(), - spent_points: crate::asset_billing::ASSET_OPERATION_POINTS_COST, - }) - .await - .map_err(map_puzzle_client_error) - }, - ) - .await; - - let run = match run_result { - Ok(run) => run, - Err(error) if should_sync_puzzle_freeze_boundary(&error, should_sync_freeze_boundary) => { - // 中文注释:冻结确认窗打开时前端会暂停视觉计时,但正式 run 仍可能在服务端边界帧先结算失败。 - // 这类情况已由扣费包装器退款,此处只同步失败态快照,避免玩家看到“操作不合法”。 - state - .spacetime_client() - .get_puzzle_run(fallback_run_id, fallback_owner_user_id) - .await - .map_err(map_puzzle_client_error) - .map_err(|error| { - puzzle_error_response(&request_context, PUZZLE_RUNTIME_PROVIDER, error) - })? - } - Err(error) => { - return Err(puzzle_error_response( - &request_context, - PUZZLE_RUNTIME_PROVIDER, - error, - )); - } - }; - - Ok(json_success_body( - Some(&request_context), - PuzzleRunResponse { - run: map_puzzle_run_response(enrich_puzzle_run_author_name(&state, run).await), - }, - )) -} - -pub async fn submit_puzzle_leaderboard( - State(state): State, - AxumPath(run_id): AxumPath, - Extension(request_context): Extension, - Extension(authenticated): Extension, - payload: Result, JsonRejection>, -) -> Result, Response> { - let Json(payload) = payload.map_err(|error| { - puzzle_error_response( - &request_context, - PUZZLE_RUNTIME_PROVIDER, - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": PUZZLE_RUNTIME_PROVIDER, - "message": error.body_text(), - })), - ) - })?; - - ensure_non_empty(&request_context, PUZZLE_RUNTIME_PROVIDER, &run_id, "runId")?; - - let run = state - .spacetime_client() - .submit_puzzle_leaderboard_entry(PuzzleLeaderboardSubmitRecordInput { - run_id, - owner_user_id: authenticated.claims().user_id().to_string(), - profile_id: payload.profile_id, - grid_size: payload.grid_size, - elapsed_ms: payload.elapsed_ms.max(1_000), - nickname: payload.nickname.trim().to_string(), - submitted_at_micros: current_utc_micros(), - }) - .await - .map_err(|error| { - puzzle_error_response( - &request_context, - PUZZLE_RUNTIME_PROVIDER, - map_puzzle_client_error(error), - ) - })?; - - Ok(json_success_body( - Some(&request_context), - PuzzleRunResponse { - run: map_puzzle_run_response(enrich_puzzle_run_author_name(&state, run).await), - }, - )) -} ->>>>>>> Stashed changes mod mappers; use self::mappers::*; -<<<<<<< Updated upstream mod draft; use self::draft::*; -======= -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()), - }) -} - -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, - }) -} - -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), - )), - } -} - -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) -} - -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": "拼图草稿缺少可编辑关卡", - })) - }) -} - -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()) -} - -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)), - } -} - -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), - }) -} - -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), - } -} - -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(), - } -} - -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}"), - })), - ) - }) -} - -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}")) -} - -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)] -struct PuzzleLevelNaming { - level_name: String, - work_description: Option, - work_tags: Vec, - 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, - } - } -} - -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) -} - -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 - } - } -} - -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) - )) -} - -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()) -} - -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)] -fn parse_puzzle_first_level_name_from_text(text: &str) -> Option { - parse_puzzle_level_naming_from_text(text).map(|naming| naming.level_name) -} - -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) -} - -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) -} - -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) -} - -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) -} - -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 -} - -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 - } -} - -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 - } -} - -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) - }) -} - -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)) -} - -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() -} - -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() -} - -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 -} - -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(); - } -} - -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)) -} - -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() -} - -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、按钮、按钮文字、数字、文字、水印、拼图碎片、完整拼图图像、教程浮层或角色手指。中央区域保持干净通透,方便运行态后续叠加默认拼图槽和正式拼图图块。" - ) -} - -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); -} - -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 -} - -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)) -} - -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, - })), - ) -} - -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()) -} - -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)), - } -} - -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 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 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 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( - 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)), - } -} - -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 -} - -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 -} - -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 -} - -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 -} - -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); -} - -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()), - }); - } -} - -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 -} - -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 -} ->>>>>>> Stashed changes mod tags; diff --git a/server-rs/crates/api-server/src/puzzle/draft.rs b/server-rs/crates/api-server/src/puzzle/draft.rs index 9be9db15..4a445782 100644 --- a/server-rs/crates/api-server/src/puzzle/draft.rs +++ b/server-rs/crates/api-server/src/puzzle/draft.rs @@ -1154,20 +1154,17 @@ pub(crate) async fn compile_puzzle_draft_with_initial_cover( &target_level.picture_description, &draft.summary, ); - let image_level_name = if target_level.level_name.trim().is_empty() { - build_fallback_puzzle_first_level_name(&target_level.picture_description) - } else { - target_level.level_name.clone() - }; - // 中文注释:首图 prompt 只依赖画面描述,关卡名分支可以和生图分支并行;OSS 临时路径使用已有名或确定性兜底名。 - let level_name_future = - generate_puzzle_first_level_name(state, &target_level.picture_description); - // 点击生成草稿时一次性完成首图生成与正式图选定,前端只展示进度,不再承担业务编排。 + 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, - &image_level_name, + &target_level.level_name, &image_prompt, reference_image_src, true, @@ -1175,33 +1172,26 @@ pub(crate) async fn compile_puzzle_draft_with_initial_cover( 1, target_level.candidates.len(), ); - let (generated_naming, candidates_result) = tokio::join!(level_name_future, candidates_future); - 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; - let candidates = candidates_result?; - 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": "拼图候选图生成结果为空", - })) - })?; - if let Some(refined_naming) = generate_puzzle_first_level_name_from_image( + let ui_background_future = generate_puzzle_initial_ui_background_required( state, - target_level.picture_description.as_str(), - &candidates[0].downloaded_image, - ) - .await + 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.ui_background_prompt.is_some() { - target_level.ui_background_prompt = refined_naming.ui_background_prompt; - } if refined_naming.work_description.is_some() { generated_metadata.work_description = refined_naming.work_description; } @@ -1214,15 +1204,22 @@ pub(crate) async fn compile_puzzle_draft_with_initial_cover( 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) = generate_puzzle_initial_ui_background_required( - state, - owner_user_id.as_str(), - compiled_session.session_id.as_str(), - &draft, - &target_level, - ) - .await?; + let (ui_prompt, ui_background) = ui_background_result?; attach_puzzle_level_ui_background( &mut updated_levels, target_level.level_id.as_str(), @@ -1443,11 +1440,6 @@ pub(crate) async fn compile_puzzle_draft_with_uploaded_cover( &target_level.picture_description, &draft.summary, ); - let image_level_name = if target_level.level_name.trim().is_empty() { - build_fallback_puzzle_first_level_name(&target_level.picture_description) - } else { - target_level.level_name.clone() - }; // 中文注释:关闭 AI 重绘时首关图不请求 VectorEngine;上传图直接成为首关正式图候选。 let candidate_id = format!( "{}-candidate-{}", @@ -1466,21 +1458,8 @@ pub(crate) async fn compile_puzzle_draft_with_uploaded_cover( target_level.picture_description.as_str(), &uploaded_downloaded_image, ); - let persist_upload_future = persist_puzzle_generated_asset( - state, - owner_user_id.as_str(), - &compiled_session.session_id, - image_level_name.as_str(), - candidate_id.as_str(), - "uploaded-direct", - uploaded_downloaded_image.clone(), - current_utc_micros(), - ); - let (mut generated_naming, refined_naming, persisted_upload_result) = tokio::join!( - level_name_future, - image_level_name_future, - persist_upload_future - ); + 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() { @@ -1499,18 +1478,30 @@ pub(crate) async fn compile_puzzle_draft_with_uploaded_cover( 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 persisted_upload = persisted_upload_result?; let mut updated_levels = build_puzzle_levels_with_primary_update(&draft, &target_level, reference_image_src); - // 中文注释:直用上传图时同样只补 UI 背景;音频生成入口临时关闭。 - let (ui_prompt, ui_background) = generate_puzzle_initial_ui_background_required( + 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, - ) - .await?; + ); + // 中文注释:直用上传图时并行完成上传图持久化与 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(), diff --git a/server-rs/crates/api-server/src/puzzle/handlers.rs b/server-rs/crates/api-server/src/puzzle/handlers.rs index 44b84c74..1aabf16a 100644 --- a/server-rs/crates/api-server/src/puzzle/handlers.rs +++ b/server-rs/crates/api-server/src/puzzle/handlers.rs @@ -664,9 +664,9 @@ pub async fn execute_puzzle_agent_action( "compile_puzzle_draft", "首关拼图草稿", if ai_redraw { - "已编译首关草稿、生成首关画面并写入正式草稿。" + "已编译首关草稿、并行生成首关画面和 UI 背景并写入正式草稿。" } else { - "已编译首关草稿,并直接应用上传图片为第一关图片。" + "已编译首关草稿,并直接应用上传图片、生成 UI 背景为第一关图片。" }, session, ) diff --git a/server-rs/crates/api-server/src/puzzle/mappers.rs b/server-rs/crates/api-server/src/puzzle/mappers.rs index e988809c..2b08f7ea 100644 --- a/server-rs/crates/api-server/src/puzzle/mappers.rs +++ b/server-rs/crates/api-server/src/puzzle/mappers.rs @@ -338,7 +338,7 @@ pub(super) fn map_puzzle_work_summary_response( .saturating_sub(item.point_incentive_claimed_points), publish_ready: item.publish_ready, generation_status, - levels: Vec::new(), + levels: item.levels.iter().map(|x|map_puzzle_draft_level_response(x.clone())).collect(), } } @@ -603,4 +603,4 @@ pub(super) fn build_puzzle_welcome_text(seed_text: &str) -> String { } "拼图创作信息已准备好。".to_string() -} +} \ No newline at end of file