use std::{ collections::BTreeMap, convert::Infallible, time::{SystemTime, UNIX_EPOCH}, }; use axum::{ Json, body::to_bytes, extract::{Extension, Path, State, rejection::JsonRejection}, http::{HeaderName, StatusCode, header}, response::{ IntoResponse, Response, sse::{Event, Sse}, }, }; use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; use module_assets::{ AssetObjectAccessPolicy, AssetObjectFieldError, build_asset_entity_binding_input, build_asset_object_upsert_input, generate_asset_binding_id, generate_asset_object_id, }; use module_square_hole::{ SQUARE_HOLE_MESSAGE_ID_PREFIX, SQUARE_HOLE_PROFILE_ID_PREFIX, SQUARE_HOLE_RUN_ID_PREFIX, SQUARE_HOLE_SESSION_ID_PREFIX, default_background_prompt, normalize_hole_options, normalize_shape_options, }; use platform_oss::{LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use shared_contracts::{ square_hole_agent::{ CreateSquareHoleSessionRequest, ExecuteSquareHoleActionRequest, SendSquareHoleMessageRequest, SquareHoleActionResponse, SquareHoleAgentMessageResponse, SquareHoleAnchorItemResponse, SquareHoleAnchorPackResponse, SquareHoleCreatorConfigResponse, SquareHoleHoleOptionResponse, SquareHoleResultDraftResponse, SquareHoleSessionResponse, SquareHoleSessionSnapshotResponse, SquareHoleShapeOptionResponse, }, square_hole_runtime::{ DropSquareHoleShapeRequest, SquareHoleDropFeedbackResponse, SquareHoleDropResponse, SquareHoleHoleSnapshotResponse, SquareHoleRunResponse, SquareHoleRunSnapshotResponse, SquareHoleShapeSnapshotResponse, StartSquareHoleRunRequest, StopSquareHoleRunRequest, }, square_hole_works::{ PutSquareHoleWorkRequest, RegenerateSquareHoleWorkImageRequest, SquareHoleHoleOptionResponse as SquareHoleWorkHoleOptionResponse, SquareHoleShapeOptionResponse as SquareHoleWorkShapeOptionResponse, SquareHoleWorkDetailResponse, SquareHoleWorkMutationResponse, SquareHoleWorkProfileResponse, SquareHoleWorkSummaryResponse, SquareHoleWorksResponse, }, }; use shared_kernel::build_prefixed_uuid_id; use spacetime_client::{ SpacetimeClientError, SquareHoleAgentMessageRecord, SquareHoleAgentMessageSubmitRecordInput, SquareHoleAgentSessionCreateRecordInput, SquareHoleAgentSessionRecord, SquareHoleAnchorItemRecord, SquareHoleAnchorPackRecord, SquareHoleCompileDraftRecordInput, SquareHoleCreatorConfigRecord, SquareHoleDropFeedbackRecord, SquareHoleHoleOptionRecord, SquareHoleHoleSnapshotRecord, SquareHoleResultDraftRecord, SquareHoleRunDropRecordInput, SquareHoleRunRecord, SquareHoleRunRestartRecordInput, SquareHoleRunStartRecordInput, SquareHoleRunStopRecordInput, SquareHoleRunTimeUpRecordInput, SquareHoleShapeOptionRecord, SquareHoleShapeSnapshotRecord, SquareHoleWorkProfileRecord, SquareHoleWorkUpdateRecordInput, }; use crate::{ ai_generation_drafts::{AiGenerationDraftContext, AiGenerationDraftWriter}, api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError, openai_image_generation::{ DownloadedOpenAiImage, build_openai_image_http_client, create_openai_image_generation, require_openai_image_settings, }, platform_errors::map_oss_error, request_context::RequestContext, square_hole_agent_turn::{ SquareHoleAgentTurnRequest, build_finalize_record_input, run_square_hole_agent_turn, }, state::AppState, work_play_tracking::{WorkPlayTrackingDraft, record_work_play_start_after_success}, }; const SQUARE_HOLE_AGENT_PROVIDER: &str = "square-hole-agent"; const SQUARE_HOLE_WORKS_PROVIDER: &str = "square-hole-works"; const SQUARE_HOLE_RUNTIME_PROVIDER: &str = "square-hole-runtime"; const SQUARE_HOLE_DEFAULT_THEME: &str = "纸箱"; const SQUARE_HOLE_DEFAULT_TWIST_RULE: &str = "方洞万能"; const SQUARE_HOLE_DEFAULT_SHAPE_COUNT: u32 = 12; const SQUARE_HOLE_DEFAULT_DIFFICULTY: u32 = 4; const SQUARE_HOLE_QUESTION_THEME: &str = "你想做什么题材"; const SQUARE_HOLE_ENTITY_KIND: &str = "square_hole_work"; const SQUARE_HOLE_COVER_IMAGE_KIND: &str = "square_hole_cover_image"; const SQUARE_HOLE_BACKGROUND_IMAGE_KIND: &str = "square_hole_background_image"; const SQUARE_HOLE_SHAPE_IMAGE_KIND: &str = "square_hole_shape_image"; const SQUARE_HOLE_HOLE_IMAGE_KIND: &str = "square_hole_hole_image"; #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct SquareHoleConfigJson { theme_text: String, twist_rule: String, shape_count: u32, difficulty: u32, #[serde(default)] shape_options: Vec, #[serde(default)] hole_options: Vec, #[serde(default)] background_prompt: String, #[serde(default, deserialize_with = "deserialize_optional_string_as_default")] cover_image_src: String, #[serde(default, deserialize_with = "deserialize_optional_string_as_default")] background_image_src: String, } #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct SquareHoleConfigShapeOptionJson { option_id: String, shape_kind: String, label: String, #[serde(default)] target_hole_id: String, image_prompt: String, #[serde(default, deserialize_with = "deserialize_optional_string_as_default")] image_src: String, } #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct SquareHoleConfigHoleOptionJson { hole_id: String, hole_kind: String, label: String, #[serde(default)] image_prompt: String, #[serde(default, deserialize_with = "deserialize_optional_string_as_default")] image_src: String, } #[derive(Clone, Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub(crate) struct CompileSquareHoleDraftRequest { #[serde(default)] game_name: Option, #[serde(default)] summary: Option, #[serde(default)] tags: Option>, #[serde(default)] cover_image_src: Option, } pub async fn create_square_hole_agent_session( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = square_hole_json(payload, &request_context, SQUARE_HOLE_AGENT_PROVIDER)?; let config = build_config_from_create_request(&payload); let seed_text = build_seed_text(&payload, &config); let welcome_message_text = SQUARE_HOLE_QUESTION_THEME.to_string(); let session = state .spacetime_client() .create_square_hole_agent_session(SquareHoleAgentSessionCreateRecordInput { session_id: build_prefixed_uuid_id(SQUARE_HOLE_SESSION_ID_PREFIX), owner_user_id: authenticated.claims().user_id().to_string(), seed_text, welcome_message_id: build_prefixed_uuid_id(SQUARE_HOLE_MESSAGE_ID_PREFIX), welcome_message_text, config_json: serialize_square_hole_config(&config), created_at_micros: current_utc_micros(), }) .await .map_err(|error| { square_hole_error_response( &request_context, SQUARE_HOLE_AGENT_PROVIDER, map_square_hole_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), SquareHoleSessionResponse { session: map_square_hole_agent_session_response(session), }, )) } pub async fn get_square_hole_agent_session( State(state): State, Path(session_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, Response> { ensure_non_empty( &request_context, SQUARE_HOLE_AGENT_PROVIDER, &session_id, "sessionId", )?; let session = state .spacetime_client() .get_square_hole_agent_session(session_id, authenticated.claims().user_id().to_string()) .await .map_err(|error| { square_hole_error_response( &request_context, SQUARE_HOLE_AGENT_PROVIDER, map_square_hole_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), SquareHoleSessionResponse { session: map_square_hole_agent_session_response(session), }, )) } pub async fn submit_square_hole_agent_message( State(state): State, Path(session_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = square_hole_json(payload, &request_context, SQUARE_HOLE_AGENT_PROVIDER)?; let session = submit_and_finalize_square_hole_message( &state, &request_context, authenticated.claims().user_id(), session_id, payload, |_| {}, ) .await?; Ok(json_success_body( Some(&request_context), SquareHoleSessionResponse { session: map_square_hole_agent_session_response(session), }, )) } pub async fn stream_square_hole_agent_message( State(state): State, Path(session_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result { let Json(payload) = square_hole_json(payload, &request_context, SQUARE_HOLE_AGENT_PROVIDER)?; ensure_non_empty( &request_context, SQUARE_HOLE_AGENT_PROVIDER, &session_id, "sessionId", )?; let owner_user_id = authenticated.claims().user_id().to_string(); let request_context_for_stream = request_context.clone(); let state = state.clone(); let stream = async_stream::stream! { let mut draft_writer = AiGenerationDraftWriter::new(AiGenerationDraftContext::new( "square-hole", owner_user_id.as_str(), session_id.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 result = { let run_turn = submit_and_finalize_square_hole_message( &state, &request_context_for_stream, owner_user_id.as_str(), session_id.clone(), payload, 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::(square_hole_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::(square_hole_sse_json_event_or_error( "reply_delta", json!({ "text": text }), )); } let session = match result { Ok(session) => session, Err(response) => { let message = extract_square_hole_response_error_message(response).await; yield Ok::(square_hole_sse_json_event_or_error( "error", json!({ "message": message }), )); return; } }; let session_response = map_square_hole_agent_session_response(session); yield Ok::(square_hole_sse_json_event_or_error( "session", json!({ "session": session_response }), )); yield Ok::(square_hole_sse_json_event_or_error( "done", json!({ "ok": true }), )); }; Ok(Sse::new(stream).into_response()) } pub async fn execute_square_hole_agent_action( State(state): State, Path(session_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = square_hole_json(payload, &request_context, SQUARE_HOLE_AGENT_PROVIDER)?; ensure_non_empty( &request_context, SQUARE_HOLE_AGENT_PROVIDER, &session_id, "sessionId", )?; let session = match payload.action.trim() { "square_hole_compile_draft" => { compile_square_hole_draft_for_session( &state, &request_context, &authenticated, session_id, payload.game_name, payload.summary, payload.tags, payload.cover_image_src, ) .await? } "square_hole_generate_visual_assets" => { generate_square_hole_visual_assets_for_session( &state, &request_context, &authenticated, session_id, payload.regenerate_visual_assets.unwrap_or(false), payload.visual_asset_slot, payload.visual_asset_option_id, ) .await? } _ => { return Err(square_hole_bad_request( &request_context, SQUARE_HOLE_AGENT_PROVIDER, "unknown square hole action", )); } }; Ok(json_success_body( Some(&request_context), SquareHoleActionResponse { session: map_square_hole_agent_session_response(session), }, )) } pub async fn compile_square_hole_agent_draft( State(state): State, Path(session_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let payload = payload .map(|Json(payload)| payload) .unwrap_or(CompileSquareHoleDraftRequest { game_name: None, summary: None, tags: None, cover_image_src: None, }); ensure_non_empty( &request_context, SQUARE_HOLE_AGENT_PROVIDER, &session_id, "sessionId", )?; let session = compile_square_hole_draft_for_session( &state, &request_context, &authenticated, session_id, payload.game_name, payload.summary, payload.tags, payload.cover_image_src, ) .await?; Ok(json_success_body( Some(&request_context), SquareHoleActionResponse { session: map_square_hole_agent_session_response(session), }, )) } pub async fn get_square_hole_works( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, Response> { let items = state .spacetime_client() .list_square_hole_works(authenticated.claims().user_id().to_string()) .await .map_err(|error| { square_hole_error_response( &request_context, SQUARE_HOLE_WORKS_PROVIDER, map_square_hole_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), SquareHoleWorksResponse { items: items .into_iter() .map(map_square_hole_work_summary_response) .collect(), }, )) } pub async fn list_square_hole_gallery( State(state): State, Extension(request_context): Extension, ) -> Result, Response> { let items = state .spacetime_client() .list_square_hole_gallery() .await .map_err(|error| { square_hole_error_response( &request_context, SQUARE_HOLE_WORKS_PROVIDER, map_square_hole_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), SquareHoleWorksResponse { items: items .into_iter() .map(map_square_hole_work_summary_response) .collect(), }, )) } pub async fn get_square_hole_work_detail( State(state): State, Path(profile_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, Response> { ensure_non_empty( &request_context, SQUARE_HOLE_WORKS_PROVIDER, &profile_id, "profileId", )?; let item = state .spacetime_client() .get_square_hole_work_detail(profile_id, authenticated.claims().user_id().to_string()) .await .map_err(|error| { square_hole_error_response( &request_context, SQUARE_HOLE_WORKS_PROVIDER, map_square_hole_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), SquareHoleWorkDetailResponse { item: map_square_hole_work_profile_response(item), }, )) } pub async fn put_square_hole_work( State(state): State, Path(profile_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = square_hole_json(payload, &request_context, SQUARE_HOLE_WORKS_PROVIDER)?; ensure_non_empty( &request_context, SQUARE_HOLE_WORKS_PROVIDER, &profile_id, "profileId", )?; let existing = state .spacetime_client() .get_square_hole_work_detail( profile_id.clone(), authenticated.claims().user_id().to_string(), ) .await .map_err(|error| { square_hole_error_response( &request_context, SQUARE_HOLE_WORKS_PROVIDER, map_square_hole_client_error(error), ) })?; let theme_text = payload .theme_text .clone() .filter(|value| !value.trim().is_empty()) .unwrap_or(existing.theme_text); let hole_options = payload .hole_options .clone() .map(square_hole_work_hole_options_to_records) .unwrap_or_else(|| existing.hole_options.clone()); let hole_options_json = serialize_square_hole_hole_option_records(&hole_options); let shape_options = payload .shape_options .clone() .map(|options| square_hole_work_shape_options_to_records(options, hole_options.as_slice())) .unwrap_or_else(|| existing.shape_options.clone()); let shape_options_json = serialize_square_hole_shape_option_records(&shape_options); let item = state .spacetime_client() .update_square_hole_work(SquareHoleWorkUpdateRecordInput { profile_id, owner_user_id: authenticated.claims().user_id().to_string(), game_name: payload.game_name, theme_text, twist_rule: payload.twist_rule, summary_text: payload.summary, tags_json: serde_json::to_string(&normalize_tags(payload.tags)).unwrap_or_default(), cover_image_src: payload.cover_image_src.unwrap_or_default(), background_prompt: payload .background_prompt .filter(|value| !value.trim().is_empty()) .unwrap_or(existing.background_prompt), background_image_src: payload .background_image_src .unwrap_or(existing.background_image_src.unwrap_or_default()), shape_options_json, hole_options_json, shape_count: payload.shape_count, difficulty: payload.difficulty, updated_at_micros: current_utc_micros(), }) .await .map_err(|error| { square_hole_error_response( &request_context, SQUARE_HOLE_WORKS_PROVIDER, map_square_hole_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), SquareHoleWorkMutationResponse { item: map_square_hole_work_profile_response(item), }, )) } pub async fn publish_square_hole_work( State(state): State, Path(profile_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, Response> { ensure_non_empty( &request_context, SQUARE_HOLE_WORKS_PROVIDER, &profile_id, "profileId", )?; let item = state .spacetime_client() .publish_square_hole_work( profile_id, authenticated.claims().user_id().to_string(), current_utc_micros(), ) .await .map_err(|error| { square_hole_error_response( &request_context, SQUARE_HOLE_WORKS_PROVIDER, map_square_hole_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), SquareHoleWorkMutationResponse { item: map_square_hole_work_profile_response(item), }, )) } pub async fn regenerate_square_hole_work_image( State(state): State, Path(profile_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = square_hole_json(payload, &request_context, SQUARE_HOLE_WORKS_PROVIDER)?; ensure_non_empty( &request_context, SQUARE_HOLE_WORKS_PROVIDER, &profile_id, "profileId", )?; let owner_user_id = authenticated.claims().user_id().to_string(); let item = regenerate_square_hole_visual_asset_for_work( &state, &request_context, owner_user_id, profile_id, payload.visual_asset_slot, payload.visual_asset_option_id, ) .await?; Ok(json_success_body( Some(&request_context), SquareHoleWorkMutationResponse { item: map_square_hole_work_profile_response(item), }, )) } pub async fn delete_square_hole_work( State(state): State, Path(profile_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, Response> { ensure_non_empty( &request_context, SQUARE_HOLE_WORKS_PROVIDER, &profile_id, "profileId", )?; let items = state .spacetime_client() .delete_square_hole_work(profile_id, authenticated.claims().user_id().to_string()) .await .map_err(|error| { square_hole_error_response( &request_context, SQUARE_HOLE_WORKS_PROVIDER, map_square_hole_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), SquareHoleWorksResponse { items: items .into_iter() .map(map_square_hole_work_summary_response) .collect(), }, )) } pub async fn start_square_hole_run( State(state): State, Path(profile_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let maybe_payload = payload.ok().map(|Json(payload)| payload); let profile_id = maybe_payload .map(|payload| payload.profile_id) .filter(|value| !value.trim().is_empty()) .unwrap_or(profile_id); ensure_non_empty( &request_context, SQUARE_HOLE_RUNTIME_PROVIDER, &profile_id, "profileId", )?; let run = state .spacetime_client() .start_square_hole_run(SquareHoleRunStartRecordInput { run_id: build_prefixed_uuid_id(SQUARE_HOLE_RUN_ID_PREFIX), owner_user_id: authenticated.claims().user_id().to_string(), profile_id: profile_id.clone(), started_at_ms: current_utc_ms(), }) .await .map_err(|error| { square_hole_error_response( &request_context, SQUARE_HOLE_RUNTIME_PROVIDER, map_square_hole_client_error(error), ) })?; record_work_play_start_after_success( &state, &request_context, WorkPlayTrackingDraft::new( "square-hole", profile_id.clone(), &authenticated, "/api/runtime/square-hole/...", ) .profile_id(profile_id.clone()) .extra(json!({ "runId": run.run_id, })), ) .await; Ok(json_success_body( Some(&request_context), SquareHoleRunResponse { run: map_square_hole_run_response(run), }, )) } pub async fn get_square_hole_run( State(state): State, Path(run_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, Response> { ensure_non_empty( &request_context, SQUARE_HOLE_RUNTIME_PROVIDER, &run_id, "runId", )?; let run = state .spacetime_client() .get_square_hole_run(run_id, authenticated.claims().user_id().to_string()) .await .map_err(|error| { square_hole_error_response( &request_context, SQUARE_HOLE_RUNTIME_PROVIDER, map_square_hole_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), SquareHoleRunResponse { run: map_square_hole_run_response(run), }, )) } pub async fn drop_square_hole_shape( State(state): State, Path(run_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = square_hole_json(payload, &request_context, SQUARE_HOLE_RUNTIME_PROVIDER)?; ensure_non_empty( &request_context, SQUARE_HOLE_RUNTIME_PROVIDER, &run_id, "runId", )?; ensure_non_empty( &request_context, SQUARE_HOLE_RUNTIME_PROVIDER, &payload.hole_id, "holeId", )?; ensure_non_empty( &request_context, SQUARE_HOLE_RUNTIME_PROVIDER, &payload.client_event_id, "clientEventId", )?; let confirmation = state .spacetime_client() .drop_square_hole_shape(SquareHoleRunDropRecordInput { run_id: payload.run_id.unwrap_or(run_id), owner_user_id: authenticated.claims().user_id().to_string(), hole_id: payload.hole_id, client_snapshot_version: payload.client_snapshot_version, client_event_id: payload.client_event_id, dropped_at_ms: payload.dropped_at_ms.min(i64::MAX as u64) as i64, }) .await .map_err(|error| { square_hole_error_response( &request_context, SQUARE_HOLE_RUNTIME_PROVIDER, map_square_hole_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), SquareHoleDropResponse { feedback: map_square_hole_feedback_response(confirmation.feedback), run: map_square_hole_run_response(confirmation.run), }, )) } pub async fn stop_square_hole_run( State(state): State, Path(run_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let _ = payload.ok(); ensure_non_empty( &request_context, SQUARE_HOLE_RUNTIME_PROVIDER, &run_id, "runId", )?; let run = state .spacetime_client() .stop_square_hole_run(SquareHoleRunStopRecordInput { run_id, owner_user_id: authenticated.claims().user_id().to_string(), stopped_at_ms: current_utc_ms(), }) .await .map_err(|error| { square_hole_error_response( &request_context, SQUARE_HOLE_RUNTIME_PROVIDER, map_square_hole_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), SquareHoleRunResponse { run: map_square_hole_run_response(run), }, )) } pub async fn restart_square_hole_run( State(state): State, Path(run_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, Response> { ensure_non_empty( &request_context, SQUARE_HOLE_RUNTIME_PROVIDER, &run_id, "runId", )?; let run = state .spacetime_client() .restart_square_hole_run(SquareHoleRunRestartRecordInput { source_run_id: run_id, next_run_id: build_prefixed_uuid_id(SQUARE_HOLE_RUN_ID_PREFIX), owner_user_id: authenticated.claims().user_id().to_string(), restarted_at_ms: current_utc_ms(), }) .await .map_err(|error| { square_hole_error_response( &request_context, SQUARE_HOLE_RUNTIME_PROVIDER, map_square_hole_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), SquareHoleRunResponse { run: map_square_hole_run_response(run), }, )) } pub async fn finish_square_hole_time_up( State(state): State, Path(run_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, Response> { ensure_non_empty( &request_context, SQUARE_HOLE_RUNTIME_PROVIDER, &run_id, "runId", )?; let run = state .spacetime_client() .finish_square_hole_time_up(SquareHoleRunTimeUpRecordInput { run_id, owner_user_id: authenticated.claims().user_id().to_string(), finished_at_ms: current_utc_ms(), }) .await .map_err(|error| { square_hole_error_response( &request_context, SQUARE_HOLE_RUNTIME_PROVIDER, map_square_hole_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), SquareHoleRunResponse { run: map_square_hole_run_response(run), }, )) } async fn submit_and_finalize_square_hole_message( state: &AppState, request_context: &RequestContext, owner_user_id: &str, session_id: String, payload: SendSquareHoleMessageRequest, on_reply_update: impl FnMut(&str), ) -> Result { ensure_non_empty( request_context, SQUARE_HOLE_AGENT_PROVIDER, &session_id, "sessionId", )?; ensure_non_empty( request_context, SQUARE_HOLE_AGENT_PROVIDER, &payload.client_message_id, "clientMessageId", )?; ensure_non_empty( request_context, SQUARE_HOLE_AGENT_PROVIDER, &payload.text, "text", )?; let submitted = state .spacetime_client() .submit_square_hole_agent_message(SquareHoleAgentMessageSubmitRecordInput { session_id: session_id.clone(), owner_user_id: owner_user_id.to_string(), user_message_id: payload.client_message_id.clone(), user_message_text: payload.text.clone(), submitted_at_micros: current_utc_micros(), }) .await .map_err(|error| { square_hole_error_response( request_context, SQUARE_HOLE_AGENT_PROVIDER, map_square_hole_client_error(error), ) })?; let turn_result = run_square_hole_agent_turn( SquareHoleAgentTurnRequest { llm_client: state.llm_client(), session: &submitted, quick_fill_requested: payload.quick_fill_requested.unwrap_or(false), enable_web_search: state.config.creation_agent_llm_web_search_enabled, }, on_reply_update, ) .await .map_err(|error| { square_hole_error_response( request_context, SQUARE_HOLE_AGENT_PROVIDER, AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": SQUARE_HOLE_AGENT_PROVIDER, "message": error.to_string(), })), ) })?; let finalize_input = build_finalize_record_input( session_id, owner_user_id.to_string(), build_prefixed_uuid_id(SQUARE_HOLE_MESSAGE_ID_PREFIX), turn_result, current_utc_micros(), ); state .spacetime_client() .finalize_square_hole_agent_message(finalize_input) .await .map_err(|error| { square_hole_error_response( request_context, SQUARE_HOLE_AGENT_PROVIDER, map_square_hole_client_error(error), ) }) } async fn compile_square_hole_draft_for_session( state: &AppState, request_context: &RequestContext, authenticated: &AuthenticatedAccessToken, session_id: String, game_name: Option, summary: Option, tags: Option>, cover_image_src: Option, ) -> Result { let owner_user_id = authenticated.claims().user_id().to_string(); let session = state .spacetime_client() .get_square_hole_agent_session(session_id.clone(), owner_user_id.clone()) .await .map_err(|error| { square_hole_error_response( request_context, SQUARE_HOLE_AGENT_PROVIDER, map_square_hole_client_error(error), ) })?; if session.current_turn < 2 || session.progress_percent < 100 { return Err(square_hole_bad_request( request_context, SQUARE_HOLE_AGENT_PROVIDER, "square hole 创作配置尚未确认完成", )); } let config = resolve_config_or_default(Some(&session.config)); let tags_json = tags .as_ref() .map(|tags| serde_json::to_string(&normalize_tags(tags.clone())).unwrap_or_default()); state .spacetime_client() .compile_square_hole_draft(SquareHoleCompileDraftRecordInput { session_id, owner_user_id, profile_id: build_prefixed_uuid_id(SQUARE_HOLE_PROFILE_ID_PREFIX), author_display_name: resolve_author_display_name(state, authenticated), game_name: game_name.or_else(|| Some(format!("{}方洞挑战", config.theme_text))), summary_text: summary, tags_json, cover_image_src, compiled_at_micros: current_utc_micros(), }) .await .map_err(|error| { square_hole_error_response( request_context, SQUARE_HOLE_AGENT_PROVIDER, map_square_hole_client_error(error), ) }) } async fn generate_square_hole_visual_assets_for_session( state: &AppState, request_context: &RequestContext, authenticated: &AuthenticatedAccessToken, session_id: String, regenerate_visual_assets: bool, visual_asset_slot: Option, visual_asset_option_id: Option, ) -> Result { let owner_user_id = authenticated.claims().user_id().to_string(); let session = state .spacetime_client() .get_square_hole_agent_session(session_id.clone(), owner_user_id.clone()) .await .map_err(|error| { square_hole_error_response( request_context, SQUARE_HOLE_AGENT_PROVIDER, map_square_hole_client_error(error), ) })?; let profile_id = session .draft .as_ref() .map(|draft| draft.profile_id.clone()) .ok_or_else(|| { square_hole_bad_request( request_context, SQUARE_HOLE_AGENT_PROVIDER, "square hole 草稿尚未编译,不能生成图片资产", ) })?; let mut work = state .spacetime_client() .get_square_hole_work_detail(profile_id.clone(), owner_user_id.clone()) .await .map_err(|error| { square_hole_error_response( request_context, SQUARE_HOLE_AGENT_PROVIDER, map_square_hole_client_error(error), ) })?; let requested_slot = normalize_square_hole_visual_asset_slot( visual_asset_slot.as_deref(), visual_asset_option_id.as_deref(), ); let cover_image_src = match work.cover_image_src.clone() { Some(value) if !should_generate_square_hole_cover_image( requested_slot.as_ref(), regenerate_visual_assets, value.as_str(), ) => { Some(value) } _ => Some( generate_square_hole_image_data_url( state, &owner_user_id, &session_id, profile_id.as_str(), "cover", SQUARE_HOLE_COVER_IMAGE_KIND, build_square_hole_cover_prompt(&work).as_str(), "16:9", "生成方洞挑战封面图失败", ) .await .map_err(|error| { square_hole_error_response(request_context, SQUARE_HOLE_AGENT_PROVIDER, error) })?, ), }; let background_image_src = match work.background_image_src.clone() { Some(value) if !should_generate_square_hole_background_image( requested_slot.as_ref(), regenerate_visual_assets, value.as_str(), ) => { Some(value) } _ => Some( generate_square_hole_image_data_url( state, &owner_user_id, &session_id, profile_id.as_str(), "background", SQUARE_HOLE_BACKGROUND_IMAGE_KIND, build_square_hole_background_prompt(&work).as_str(), "16:9", "生成方洞挑战背景图失败", ) .await .map_err(|error| { square_hole_error_response(request_context, SQUARE_HOLE_AGENT_PROVIDER, error) })?, ), }; let mut shape_options = work.shape_options.clone(); let prompt_work = work.clone(); for option in shape_options.iter_mut() { if !should_generate_square_hole_shape_image( requested_slot.as_ref(), regenerate_visual_assets, option, ) { continue; } option.image_src = Some( generate_square_hole_image_data_url( state, &owner_user_id, &session_id, profile_id.as_str(), option.option_id.as_str(), SQUARE_HOLE_SHAPE_IMAGE_KIND, build_square_hole_shape_prompt(&prompt_work, option).as_str(), "1:1", "生成方洞挑战形状贴图失败", ) .await .map_err(|error| { square_hole_error_response(request_context, SQUARE_HOLE_AGENT_PROVIDER, error) })?, ); } let mut hole_options = work.hole_options.clone(); for option in hole_options.iter_mut() { if !should_generate_square_hole_hole_image( requested_slot.as_ref(), regenerate_visual_assets, option, ) { continue; } option.image_src = Some( generate_square_hole_image_data_url( state, &owner_user_id, &session_id, profile_id.as_str(), option.hole_id.as_str(), SQUARE_HOLE_HOLE_IMAGE_KIND, build_square_hole_hole_prompt(&prompt_work, option).as_str(), "1:1", "生成方洞挑战洞口贴图失败", ) .await .map_err(|error| { square_hole_error_response(request_context, SQUARE_HOLE_AGENT_PROVIDER, error) })?, ); } work = state .spacetime_client() .update_square_hole_work(SquareHoleWorkUpdateRecordInput { profile_id, owner_user_id: owner_user_id.clone(), game_name: work.game_name.clone(), theme_text: work.theme_text.clone(), twist_rule: work.twist_rule.clone(), summary_text: work.summary.clone(), tags_json: serde_json::to_string(&normalize_tags(work.tags.clone())) .unwrap_or_default(), cover_image_src: cover_image_src.clone().unwrap_or_default(), background_prompt: work.background_prompt.clone(), background_image_src: background_image_src.clone().unwrap_or_default(), shape_options_json: serialize_square_hole_shape_option_records(&shape_options), hole_options_json: serialize_square_hole_hole_option_records(&hole_options), shape_count: work.shape_count, difficulty: work.difficulty, updated_at_micros: current_utc_micros(), }) .await .map_err(|error| { square_hole_error_response( request_context, SQUARE_HOLE_AGENT_PROVIDER, map_square_hole_client_error(error), ) })?; let mut next_session = state .spacetime_client() .get_square_hole_agent_session(session_id, owner_user_id) .await .map_err(|error| { square_hole_error_response( request_context, SQUARE_HOLE_AGENT_PROVIDER, map_square_hole_client_error(error), ) })?; if let Some(draft) = next_session.draft.as_mut() { draft.cover_image_src = work.cover_image_src.clone(); draft.background_image_src = work.background_image_src.clone(); draft.background_prompt = work.background_prompt.clone(); draft.shape_options = work.shape_options.clone(); draft.hole_options = work.hole_options.clone(); } Ok(next_session) } async fn regenerate_square_hole_visual_asset_for_work( state: &AppState, request_context: &RequestContext, owner_user_id: String, profile_id: String, visual_asset_slot: String, visual_asset_option_id: Option, ) -> Result { let mut work = state .spacetime_client() .get_square_hole_work_detail(profile_id.clone(), owner_user_id.clone()) .await .map_err(|error| { square_hole_error_response( request_context, SQUARE_HOLE_WORKS_PROVIDER, map_square_hole_client_error(error), ) })?; let requested_slot = normalize_square_hole_visual_asset_slot( Some(visual_asset_slot.as_str()), visual_asset_option_id.as_deref(), ) .ok_or_else(|| { square_hole_bad_request( request_context, SQUARE_HOLE_WORKS_PROVIDER, "图片槽位不存在", ) })?; let synthetic_session_id = work .source_session_id .clone() .filter(|value| !value.trim().is_empty()) .unwrap_or_else(|| profile_id.clone()); let prompt_work = work.clone(); match &requested_slot { SquareHoleVisualAssetSlotRequest::Cover => { work.cover_image_src = Some( generate_square_hole_image_data_url( state, owner_user_id.as_str(), synthetic_session_id.as_str(), profile_id.as_str(), "cover", SQUARE_HOLE_COVER_IMAGE_KIND, build_square_hole_cover_prompt(&prompt_work).as_str(), "16:9", "生成方洞挑战封面图失败", ) .await .map_err(|error| { square_hole_error_response(request_context, SQUARE_HOLE_WORKS_PROVIDER, error) })?, ); } SquareHoleVisualAssetSlotRequest::Background => { work.background_image_src = Some( generate_square_hole_image_data_url( state, owner_user_id.as_str(), synthetic_session_id.as_str(), profile_id.as_str(), "background", SQUARE_HOLE_BACKGROUND_IMAGE_KIND, build_square_hole_background_prompt(&prompt_work).as_str(), "16:9", "生成方洞挑战背景图失败", ) .await .map_err(|error| { square_hole_error_response(request_context, SQUARE_HOLE_WORKS_PROVIDER, error) })?, ); } SquareHoleVisualAssetSlotRequest::Shape(option_id) => { let Some(option) = work .shape_options .iter_mut() .find(|option| option.option_id == *option_id) else { return Err(square_hole_bad_request( request_context, SQUARE_HOLE_WORKS_PROVIDER, "形状图片槽位不存在", )); }; option.image_src = Some( generate_square_hole_image_data_url( state, owner_user_id.as_str(), synthetic_session_id.as_str(), profile_id.as_str(), option.option_id.as_str(), SQUARE_HOLE_SHAPE_IMAGE_KIND, build_square_hole_shape_prompt(&prompt_work, option).as_str(), "1:1", "生成方洞挑战形状贴图失败", ) .await .map_err(|error| { square_hole_error_response(request_context, SQUARE_HOLE_WORKS_PROVIDER, error) })?, ); } SquareHoleVisualAssetSlotRequest::Hole(hole_id) => { let Some(option) = work .hole_options .iter_mut() .find(|option| option.hole_id == *hole_id) else { return Err(square_hole_bad_request( request_context, SQUARE_HOLE_WORKS_PROVIDER, "洞口图片槽位不存在", )); }; option.image_src = Some( generate_square_hole_image_data_url( state, owner_user_id.as_str(), synthetic_session_id.as_str(), profile_id.as_str(), option.hole_id.as_str(), SQUARE_HOLE_HOLE_IMAGE_KIND, build_square_hole_hole_prompt(&prompt_work, option).as_str(), "1:1", "生成方洞挑战洞口贴图失败", ) .await .map_err(|error| { square_hole_error_response(request_context, SQUARE_HOLE_WORKS_PROVIDER, error) })?, ); } } state .spacetime_client() .update_square_hole_work(SquareHoleWorkUpdateRecordInput { profile_id, owner_user_id, game_name: work.game_name.clone(), theme_text: work.theme_text.clone(), twist_rule: work.twist_rule.clone(), summary_text: work.summary.clone(), tags_json: serde_json::to_string(&normalize_tags(work.tags.clone())) .unwrap_or_default(), cover_image_src: work.cover_image_src.clone().unwrap_or_default(), background_prompt: work.background_prompt.clone(), background_image_src: work.background_image_src.clone().unwrap_or_default(), shape_options_json: serialize_square_hole_shape_option_records(&work.shape_options), hole_options_json: serialize_square_hole_hole_option_records(&work.hole_options), shape_count: work.shape_count, difficulty: work.difficulty, updated_at_micros: current_utc_micros(), }) .await .map_err(|error| { square_hole_error_response( request_context, SQUARE_HOLE_WORKS_PROVIDER, map_square_hole_client_error(error), ) }) } async fn generate_square_hole_image_data_url( state: &AppState, owner_user_id: &str, session_id: &str, profile_id: &str, slot: &str, asset_kind: &str, prompt: &str, size: &str, failure_context: &str, ) -> Result { let settings = require_openai_image_settings(state)?; let http_client = build_openai_image_http_client(&settings)?; let generated = create_openai_image_generation( &http_client, &settings, prompt, Some(build_square_hole_negative_prompt().as_str()), size, 1, &[], failure_context, ) .await?; let image = generated.images.into_iter().next().ok_or_else(|| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "vector-engine", "message": format!("{failure_context}:上游未返回图片"), })) })?; let fallback_data_url = format_square_hole_data_url(&image); match persist_square_hole_generated_asset( state, owner_user_id, session_id, profile_id, slot, asset_kind, generated.task_id.as_str(), image, current_utc_micros(), ) .await { Ok(image_src) => Ok(image_src), Err(error) => { tracing::warn!( provider = "square-hole-assets", owner_user_id, session_id, profile_id, slot, asset_kind, message = %error.body_text(), "方洞图片已生成但资产持久化失败,降级回写 Data URL" ); Ok(fallback_data_url) } } } fn format_square_hole_data_url(image: &DownloadedOpenAiImage) -> String { format!( "data:{};base64,{}", image.mime_type, BASE64_STANDARD.encode(&image.bytes) ) } #[allow(clippy::too_many_arguments)] async fn persist_square_hole_generated_asset( state: &AppState, owner_user_id: &str, session_id: &str, profile_id: &str, slot: &str, asset_kind: &str, task_id: &str, image: DownloadedOpenAiImage, generated_at_micros: i64, ) -> Result { let oss_client = state.oss_client().ok_or_else(|| { AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ "provider": "aliyun-oss", "reason": "OSS 未完成环境变量配置", })) })?; let http_client = reqwest::Client::new(); let storage_slot = sanitize_square_hole_asset_segment(slot, "slot"); let put_result = oss_client .put_object( &http_client, OssPutObjectRequest { prefix: LegacyAssetPrefix::SquareHoleAssets, path_segments: vec![ sanitize_square_hole_asset_segment(session_id, "session"), sanitize_square_hole_asset_segment(profile_id, "profile"), sanitize_square_hole_asset_segment(asset_kind, "asset"), storage_slot.clone(), format!("asset-{generated_at_micros}"), ], file_name: format!("image.{}", image.extension), content_type: Some(image.mime_type.clone()), access: OssObjectAccess::Private, metadata: build_square_hole_asset_metadata( asset_kind, owner_user_id, profile_id, slot, ), body: image.bytes, }, ) .await .map_err(map_square_hole_asset_oss_error)?; let head = oss_client .head_object( &http_client, OssHeadObjectRequest { object_key: put_result.object_key.clone(), }, ) .await .map_err(map_square_hole_asset_oss_error)?; match state .spacetime_client() .confirm_asset_object( build_asset_object_upsert_input( generate_asset_object_id(generated_at_micros), head.bucket, head.object_key, AssetObjectAccessPolicy::Private, head.content_type.or(Some(image.mime_type)), head.content_length, head.etag, asset_kind.to_string(), Some(task_id.to_string()), Some(owner_user_id.to_string()), Some(profile_id.to_string()), Some(profile_id.to_string()), generated_at_micros, ) .map_err(map_square_hole_asset_field_error)?, ) .await { Ok(asset_object) => { if let Err(error) = state .spacetime_client() .bind_asset_object_to_entity( build_asset_entity_binding_input( generate_asset_binding_id(generated_at_micros), asset_object.asset_object_id, SQUARE_HOLE_ENTITY_KIND.to_string(), profile_id.to_string(), slot.to_string(), asset_kind.to_string(), Some(owner_user_id.to_string()), Some(profile_id.to_string()), generated_at_micros, ) .map_err(map_square_hole_asset_field_error)?, ) .await { tracing::warn!( provider = "spacetimedb", owner_user_id, session_id, profile_id, slot, asset_kind, error = %error, "方洞图片资产绑定失败,历史素材索引可能缺少绑定记录" ); } } Err(error) => { tracing::warn!( provider = "spacetimedb", owner_user_id, session_id, profile_id, slot, asset_kind, error = %error, "方洞图片资产对象确认失败,历史素材索引可能缺少本次记录" ); } } Ok(put_result.legacy_public_path) } fn build_square_hole_asset_metadata( asset_kind: &str, owner_user_id: &str, profile_id: &str, slot: &str, ) -> BTreeMap { BTreeMap::from([ ("asset_kind".to_string(), asset_kind.to_string()), ("owner_user_id".to_string(), owner_user_id.to_string()), ("profile_id".to_string(), profile_id.to_string()), ( "entity_kind".to_string(), SQUARE_HOLE_ENTITY_KIND.to_string(), ), ("entity_id".to_string(), profile_id.to_string()), ("slot".to_string(), slot.to_string()), ]) } fn map_square_hole_asset_oss_error(error: platform_oss::OssError) -> AppError { map_oss_error(error, "aliyun-oss") } fn map_square_hole_asset_field_error(error: AssetObjectFieldError) -> AppError { AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ "provider": "square-hole-assets", "message": error.to_string(), })) } fn sanitize_square_hole_asset_segment(value: &str, fallback: &str) -> String { let sanitized = value .trim() .chars() .map(|ch| { if ch.is_ascii_alphanumeric() || ('\u{4e00}'..='\u{9fff}').contains(&ch) { ch } else { '-' } }) .collect::() .trim_matches('-') .to_string(); if sanitized.is_empty() { fallback.to_string() } else { sanitized } } fn build_square_hole_cover_prompt(work: &SquareHoleWorkProfileRecord) -> String { format!( "移动端休闲游戏封面图。主题:{}。玩法反差:{}。画面主体是贴着主题图案的几何形状正在靠近不同洞口,视觉清晰、色彩明快、偏游戏资产质感。不要文字、不要 UI、不要水印。", clean_prompt_text(&work.theme_text, "奇怪形状"), clean_prompt_text(&work.twist_rule, "反直觉分拣") ) } fn build_square_hole_background_prompt(work: &SquareHoleWorkProfileRecord) -> String { let custom_prompt = work.background_prompt.trim(); if !custom_prompt.is_empty() { return format!( "移动端休闲游戏运行背景。{}。画面中央预留清晰操作空间,边缘可有主题装饰,低噪声,不要文字、不要 UI、不要水印。", custom_prompt ); } format!( "移动端休闲游戏运行背景。主题:{}。柔和纵深、玩具盒或舞台感,中央预留清晰操作空间,不要文字、不要 UI、不要水印。", clean_prompt_text(&work.theme_text, "奇怪形状") ) } fn build_square_hole_shape_prompt( work: &SquareHoleWorkProfileRecord, option: &SquareHoleShapeOptionRecord, ) -> String { let image_prompt = option.image_prompt.trim(); let option_prompt = if image_prompt.is_empty() { format!("{} 主题的 {}", work.theme_text, option.label) } else { image_prompt.to_string() }; format!( "单个游戏道具贴图,透明或干净浅色背景。几何形状:{}。主题贴图:{}。要求主体居中、边缘清晰、适合贴在可拖拽形状上,不要文字、不要 UI、不要水印。", clean_prompt_text(&option.label, "形状"), clean_prompt_text(&option_prompt, "主题图案") ) } fn build_square_hole_hole_prompt( work: &SquareHoleWorkProfileRecord, option: &SquareHoleHoleOptionRecord, ) -> String { let image_prompt = option.image_prompt.trim(); let option_prompt = if image_prompt.is_empty() { format!("{} 主题的 {}", work.theme_text, option.label) } else { image_prompt.to_string() }; format!( "单个游戏洞口贴图,透明或干净浅色背景。洞口名称:{}。主题贴图:{}。要求主体居中、边缘清晰、适合放在可接收拖拽形状的洞口平面上,不要文字、不要 UI、不要水印。", clean_prompt_text(&option.label, "洞口"), clean_prompt_text(&option_prompt, "主题洞口") ) } fn build_square_hole_negative_prompt() -> String { "文字、水印、复杂 UI、真实人物、恐怖血腥、低清晰度、过度模糊、主体被裁切、多个主体".to_string() } fn map_square_hole_agent_session_response( session: SquareHoleAgentSessionRecord, ) -> SquareHoleSessionSnapshotResponse { SquareHoleSessionSnapshotResponse { session_id: session.session_id, current_turn: session.current_turn, progress_percent: session.progress_percent, stage: session.stage.clone(), anchor_pack: map_square_hole_anchor_pack_response_for_turn( session.anchor_pack, session.current_turn, session.stage.as_str(), ), config: map_square_hole_config_response(session.config), draft: session.draft.map(map_square_hole_draft_response), messages: session .messages .into_iter() .map(map_square_hole_message_response) .collect(), last_assistant_reply: session.last_assistant_reply, published_profile_id: session.published_profile_id, updated_at: session.updated_at, } } fn map_square_hole_anchor_pack_response_for_turn( anchor: SquareHoleAnchorPackRecord, current_turn: u32, stage: &str, ) -> SquareHoleAnchorPackResponse { let is_ready = matches!( stage, "ReadyToCompile" | "ready_to_compile" | "DraftCompiled" | "draft_compiled" | "draft_ready" | "Published" | "published" ); let collected_count = if is_ready { 4 } else { current_turn.min(4) }; SquareHoleAnchorPackResponse { theme: map_square_hole_anchor_item_response_for_collected( anchor.theme, collected_count >= 1, ), twist_rule: map_square_hole_anchor_item_response_for_collected( anchor.twist_rule, collected_count >= 2, ), shape_count: map_square_hole_anchor_item_response_for_collected( anchor.shape_count, collected_count >= 3, ), difficulty: map_square_hole_anchor_item_response_for_collected( anchor.difficulty, collected_count >= 4, ), } } fn map_square_hole_anchor_item_response( anchor: SquareHoleAnchorItemRecord, ) -> SquareHoleAnchorItemResponse { SquareHoleAnchorItemResponse { key: anchor.key, label: anchor.label, value: anchor.value, status: anchor.status, } } fn map_square_hole_anchor_item_response_for_collected( anchor: SquareHoleAnchorItemRecord, collected: bool, ) -> SquareHoleAnchorItemResponse { if collected { return map_square_hole_anchor_item_response(anchor); } SquareHoleAnchorItemResponse { key: anchor.key, label: anchor.label, value: String::new(), status: "missing".to_string(), } } fn map_square_hole_config_response( config: SquareHoleCreatorConfigRecord, ) -> SquareHoleCreatorConfigResponse { SquareHoleCreatorConfigResponse { theme_text: config.theme_text, twist_rule: config.twist_rule, shape_count: config.shape_count, difficulty: config.difficulty, shape_options: config .shape_options .into_iter() .map(map_square_hole_shape_option_response) .collect(), hole_options: config .hole_options .into_iter() .map(map_square_hole_hole_option_response) .collect(), background_prompt: config.background_prompt, cover_image_src: config.cover_image_src, background_image_src: config.background_image_src, } } fn map_square_hole_draft_response( draft: SquareHoleResultDraftRecord, ) -> SquareHoleResultDraftResponse { SquareHoleResultDraftResponse { profile_id: draft.profile_id, game_name: draft.game_name, theme_text: draft.theme_text, twist_rule: draft.twist_rule, summary: draft.summary, tags: draft.tags, cover_image_src: draft.cover_image_src, background_prompt: draft.background_prompt, background_image_src: draft.background_image_src, shape_options: draft .shape_options .into_iter() .map(map_square_hole_shape_option_response) .collect(), hole_options: draft .hole_options .into_iter() .map(map_square_hole_hole_option_response) .collect(), shape_count: draft.shape_count, difficulty: draft.difficulty, publish_ready: draft.publish_ready, blockers: draft.blockers, } } fn map_square_hole_message_response( message: SquareHoleAgentMessageRecord, ) -> SquareHoleAgentMessageResponse { SquareHoleAgentMessageResponse { id: message.id, role: message.role, kind: message.kind, text: message.text, created_at: message.created_at, } } fn map_square_hole_work_summary_response( item: SquareHoleWorkProfileRecord, ) -> SquareHoleWorkSummaryResponse { SquareHoleWorkSummaryResponse { work_id: item.work_id, profile_id: item.profile_id, owner_user_id: item.owner_user_id, source_session_id: item.source_session_id, game_name: item.game_name, theme_text: item.theme_text, twist_rule: item.twist_rule, summary: item.summary, tags: item.tags, cover_image_src: item.cover_image_src, background_prompt: item.background_prompt, background_image_src: item.background_image_src, shape_options: item .shape_options .into_iter() .map(map_square_hole_work_shape_option_response) .collect(), hole_options: item .hole_options .into_iter() .map(map_square_hole_work_hole_option_response) .collect(), shape_count: item.shape_count, difficulty: item.difficulty, publication_status: item.publication_status, play_count: item.play_count, updated_at: item.updated_at, published_at: item.published_at, publish_ready: item.publish_ready, } } fn map_square_hole_work_profile_response( item: SquareHoleWorkProfileRecord, ) -> SquareHoleWorkProfileResponse { SquareHoleWorkProfileResponse { summary: map_square_hole_work_summary_response(item), } } fn map_square_hole_run_response(run: SquareHoleRunRecord) -> SquareHoleRunSnapshotResponse { SquareHoleRunSnapshotResponse { run_id: run.run_id, profile_id: run.profile_id, owner_user_id: run.owner_user_id, status: normalize_square_hole_run_status(run.status.as_str()).to_string(), snapshot_version: run.snapshot_version, started_at_ms: run.started_at_ms, duration_limit_ms: run.duration_limit_ms, remaining_ms: run.remaining_ms, total_shape_count: run.total_shape_count, completed_shape_count: run.completed_shape_count, combo: run.combo, best_combo: run.best_combo, score: run.score, rule_label: run.rule_label, background_image_src: run.background_image_src, current_shape: run.current_shape.map(map_square_hole_shape_response), holes: run .holes .into_iter() .map(map_square_hole_hole_response) .collect(), last_feedback: run.last_feedback.map(map_square_hole_feedback_response), } } fn map_square_hole_shape_response( item: SquareHoleShapeSnapshotRecord, ) -> SquareHoleShapeSnapshotResponse { SquareHoleShapeSnapshotResponse { shape_id: item.shape_id, shape_kind: item.shape_kind, label: item.label, target_hole_id: item.target_hole_id, color: item.color, image_src: item.image_src, } } fn map_square_hole_hole_response( slot: SquareHoleHoleSnapshotRecord, ) -> SquareHoleHoleSnapshotResponse { SquareHoleHoleSnapshotResponse { hole_id: slot.hole_id, hole_kind: slot.hole_kind, label: slot.label, x: slot.x, y: slot.y, image_src: slot.image_src, } } fn map_square_hole_shape_option_response( item: SquareHoleShapeOptionRecord, ) -> SquareHoleShapeOptionResponse { SquareHoleShapeOptionResponse { option_id: item.option_id, shape_kind: item.shape_kind, label: item.label, target_hole_id: item.target_hole_id, image_prompt: item.image_prompt, image_src: item.image_src, } } fn map_square_hole_hole_option_response( item: SquareHoleHoleOptionRecord, ) -> SquareHoleHoleOptionResponse { SquareHoleHoleOptionResponse { hole_id: item.hole_id, hole_kind: item.hole_kind, label: item.label, image_prompt: item.image_prompt, image_src: item.image_src, } } fn map_square_hole_work_shape_option_response( item: SquareHoleShapeOptionRecord, ) -> SquareHoleWorkShapeOptionResponse { SquareHoleWorkShapeOptionResponse { option_id: item.option_id, shape_kind: item.shape_kind, label: item.label, target_hole_id: item.target_hole_id, image_prompt: item.image_prompt, image_src: item.image_src, } } fn map_square_hole_work_hole_option_response( item: SquareHoleHoleOptionRecord, ) -> SquareHoleWorkHoleOptionResponse { SquareHoleWorkHoleOptionResponse { hole_id: item.hole_id, hole_kind: item.hole_kind, label: item.label, image_prompt: item.image_prompt, image_src: item.image_src, } } fn map_square_hole_feedback_response( feedback: SquareHoleDropFeedbackRecord, ) -> SquareHoleDropFeedbackResponse { SquareHoleDropFeedbackResponse { accepted: feedback.accepted, reject_reason: feedback.reject_reason, message: feedback.message, } } fn build_config_from_create_request( payload: &CreateSquareHoleSessionRequest, ) -> SquareHoleConfigJson { let theme_text = payload .theme_text .as_deref() .or(payload.seed_text.as_deref()) .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or(SQUARE_HOLE_DEFAULT_THEME); let hole_options = normalize_hole_options(Vec::new(), theme_text); SquareHoleConfigJson { theme_text: theme_text.to_string(), twist_rule: payload .twist_rule .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or(SQUARE_HOLE_DEFAULT_TWIST_RULE) .to_string(), shape_count: payload .shape_count .unwrap_or(SQUARE_HOLE_DEFAULT_SHAPE_COUNT) .max(1), difficulty: payload .difficulty .unwrap_or(SQUARE_HOLE_DEFAULT_DIFFICULTY) .clamp(1, 10), shape_options: square_hole_shape_records_to_config_json(normalize_shape_options( Vec::new(), theme_text, hole_options.as_slice(), )), hole_options: square_hole_hole_records_to_config_json(hole_options), background_prompt: default_background_prompt(theme_text), cover_image_src: String::new(), background_image_src: String::new(), } } fn resolve_config_or_default( config: Option<&SquareHoleCreatorConfigRecord>, ) -> SquareHoleConfigJson { config .map(|config| SquareHoleConfigJson { theme_text: config.theme_text.clone(), twist_rule: config.twist_rule.clone(), shape_count: config.shape_count.max(1), difficulty: config.difficulty.clamp(1, 10), shape_options: square_hole_shape_records_to_config_json(config.shape_options.clone()), hole_options: square_hole_hole_records_to_config_json(config.hole_options.clone()), background_prompt: config.background_prompt.clone(), cover_image_src: config.cover_image_src.clone().unwrap_or_default(), background_image_src: config.background_image_src.clone().unwrap_or_default(), }) .unwrap_or_else(|| SquareHoleConfigJson { theme_text: SQUARE_HOLE_DEFAULT_THEME.to_string(), twist_rule: SQUARE_HOLE_DEFAULT_TWIST_RULE.to_string(), shape_count: SQUARE_HOLE_DEFAULT_SHAPE_COUNT, difficulty: SQUARE_HOLE_DEFAULT_DIFFICULTY, shape_options: { let hole_options = normalize_hole_options(Vec::new(), SQUARE_HOLE_DEFAULT_THEME); square_hole_shape_records_to_config_json(normalize_shape_options( Vec::new(), SQUARE_HOLE_DEFAULT_THEME, hole_options.as_slice(), )) }, hole_options: square_hole_hole_records_to_config_json(normalize_hole_options( Vec::new(), SQUARE_HOLE_DEFAULT_THEME, )), background_prompt: default_background_prompt(SQUARE_HOLE_DEFAULT_THEME), cover_image_src: String::new(), background_image_src: String::new(), }) } fn serialize_square_hole_config(config: &SquareHoleConfigJson) -> Option { serde_json::to_string(config).ok() } fn deserialize_optional_string_as_default<'de, D>(deserializer: D) -> Result where D: serde::Deserializer<'de>, { Ok(Option::::deserialize(deserializer)?.unwrap_or_default()) } fn build_seed_text( payload: &CreateSquareHoleSessionRequest, config: &SquareHoleConfigJson, ) -> String { payload .seed_text .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string) .unwrap_or_else(|| { format!( "{}题材,{},{} 个形状,难度{}", config.theme_text, config.twist_rule, config.shape_count, config.difficulty ) }) } fn normalize_tags(tags: Vec) -> Vec { let mut result = Vec::new(); for tag in tags { let trimmed = tag.trim(); if !trimmed.is_empty() && !result.iter().any(|value| value == trimmed) { result.push(trimmed.to_string()); } if result.len() >= 6 { break; } } result } fn square_hole_shape_records_to_config_json( options: Vec>, ) -> Vec { options.into_iter().map(Into::into).collect() } fn square_hole_hole_records_to_config_json( options: Vec>, ) -> Vec { options.into_iter().map(Into::into).collect() } fn square_hole_work_shape_options_to_records( options: Vec, hole_options: &[SquareHoleHoleOptionRecord], ) -> Vec { let fallback_hole_id = hole_options .first() .map(|option| option.hole_id.clone()) .unwrap_or_else(|| "hole-1".to_string()); options .into_iter() .map(|option| SquareHoleShapeOptionRecord { option_id: option.option_id, shape_kind: option.shape_kind, label: option.label, target_hole_id: hole_options .iter() .find(|hole| hole.hole_id == option.target_hole_id) .map(|hole| hole.hole_id.clone()) .unwrap_or_else(|| fallback_hole_id.clone()), image_prompt: option.image_prompt, image_src: option.image_src.filter(|value| !value.trim().is_empty()), }) .collect() } fn square_hole_work_hole_options_to_records( options: Vec, ) -> Vec { options .into_iter() .map(|option| SquareHoleHoleOptionRecord { hole_id: option.hole_id, hole_kind: option.hole_kind, label: option.label, image_prompt: option.image_prompt, image_src: option.image_src.filter(|value| !value.trim().is_empty()), }) .collect() } fn serialize_square_hole_shape_option_records(options: &[SquareHoleShapeOptionRecord]) -> String { let json_options: Vec = options.iter().cloned().map(Into::into).collect(); serde_json::to_string(&json_options).unwrap_or_default() } fn serialize_square_hole_hole_option_records(options: &[SquareHoleHoleOptionRecord]) -> String { let json_options: Vec = options.iter().cloned().map(Into::into).collect(); serde_json::to_string(&json_options).unwrap_or_default() } fn clean_prompt_text(value: &str, fallback: &str) -> String { let cleaned = value .trim() .split_whitespace() .collect::>() .join(" "); if cleaned.is_empty() { fallback.to_string() } else { cleaned.chars().take(180).collect() } } #[derive(Clone, Debug, PartialEq, Eq)] enum SquareHoleVisualAssetSlotRequest { Cover, Background, Shape(String), Hole(String), } fn normalize_square_hole_visual_asset_slot( slot: Option<&str>, option_id: Option<&str>, ) -> Option { match slot.map(str::trim).unwrap_or_default() { "cover" => Some(SquareHoleVisualAssetSlotRequest::Cover), "background" => Some(SquareHoleVisualAssetSlotRequest::Background), "shape" => option_id .map(str::trim) .filter(|value| !value.is_empty()) .map(|value| SquareHoleVisualAssetSlotRequest::Shape(value.to_string())), "hole" => option_id .map(str::trim) .filter(|value| !value.is_empty()) .map(|value| SquareHoleVisualAssetSlotRequest::Hole(value.to_string())), _ => None, } } fn should_generate_square_hole_cover_image( requested_slot: Option<&SquareHoleVisualAssetSlotRequest>, regenerate_visual_assets: bool, current_image_src: &str, ) -> bool { matches!( requested_slot, Some(SquareHoleVisualAssetSlotRequest::Cover) ) || (requested_slot.is_none() && (regenerate_visual_assets || current_image_src.trim().is_empty())) } fn should_generate_square_hole_background_image( requested_slot: Option<&SquareHoleVisualAssetSlotRequest>, regenerate_visual_assets: bool, current_image_src: &str, ) -> bool { matches!( requested_slot, Some(SquareHoleVisualAssetSlotRequest::Background) ) || (requested_slot.is_none() && (regenerate_visual_assets || current_image_src.trim().is_empty())) } fn should_generate_square_hole_shape_image( requested_slot: Option<&SquareHoleVisualAssetSlotRequest>, regenerate_visual_assets: bool, option: &SquareHoleShapeOptionRecord, ) -> bool { match requested_slot { Some(SquareHoleVisualAssetSlotRequest::Shape(option_id)) => option.option_id == *option_id, Some(_) => false, None => { regenerate_visual_assets || option .image_src .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .is_none() } } } fn should_generate_square_hole_hole_image( requested_slot: Option<&SquareHoleVisualAssetSlotRequest>, regenerate_visual_assets: bool, option: &SquareHoleHoleOptionRecord, ) -> bool { match requested_slot { Some(SquareHoleVisualAssetSlotRequest::Hole(hole_id)) => option.hole_id == *hole_id, Some(_) => false, None => { regenerate_visual_assets || option .image_src .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .is_none() } } } impl From for SquareHoleConfigShapeOptionJson { fn from(option: module_square_hole::SquareHoleShapeOption) -> Self { Self { option_id: option.option_id, shape_kind: option.shape_kind, label: option.label, target_hole_id: option.target_hole_id, image_prompt: option.image_prompt, image_src: option.image_src.unwrap_or_default(), } } } impl From for SquareHoleConfigShapeOptionJson { fn from(option: SquareHoleShapeOptionRecord) -> Self { Self { option_id: option.option_id, shape_kind: option.shape_kind, label: option.label, target_hole_id: option.target_hole_id, image_prompt: option.image_prompt, image_src: option.image_src.unwrap_or_default(), } } } impl From for SquareHoleConfigHoleOptionJson { fn from(option: module_square_hole::SquareHoleHoleOption) -> Self { Self { hole_id: option.hole_id, hole_kind: option.hole_kind, label: option.label, image_prompt: option.image_prompt, image_src: option.image_src.unwrap_or_default(), } } } impl From for SquareHoleConfigHoleOptionJson { fn from(option: SquareHoleHoleOptionRecord) -> Self { Self { hole_id: option.hole_id, hole_kind: option.hole_kind, label: option.label, image_prompt: option.image_prompt, image_src: option.image_src.unwrap_or_default(), } } } fn resolve_author_display_name( state: &AppState, authenticated: &AuthenticatedAccessToken, ) -> String { state .auth_user_service() .get_user_by_id(authenticated.claims().user_id()) .ok() .flatten() .map(|user| user.display_name) .filter(|value| !value.trim().is_empty()) .unwrap_or_else(|| "玩家".to_string()) } fn normalize_square_hole_run_status(value: &str) -> &str { match value { "Running" | "running" => "running", "Won" | "won" => "won", "Failed" | "failed" => "failed", "Stopped" | "stopped" => "stopped", _ => value, } } fn ensure_non_empty( request_context: &RequestContext, provider: &str, value: &str, field_name: &str, ) -> Result<(), Response> { if value.trim().is_empty() { return Err(square_hole_bad_request( request_context, provider, format!("{field_name} is required").as_str(), )); } Ok(()) } fn square_hole_json( payload: Result, JsonRejection>, request_context: &RequestContext, provider: &str, ) -> Result, Response> { payload.map_err(|error| { square_hole_error_response( request_context, provider, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": provider, "message": error.body_text(), })), ) }) } async fn extract_square_hole_response_error_message(response: Response) -> String { let status = response.status(); let body = match to_bytes(response.into_body(), 64 * 1024).await { Ok(body) => body, Err(_) => return format!("方洞挑战生成失败:{status}"), }; let body_text = String::from_utf8_lossy(&body).trim().to_string(); if body_text.is_empty() { return format!("方洞挑战生成失败:{status}"); } if let Ok(body_json) = serde_json::from_str::(&body_text) && let Some(message) = find_square_hole_error_message(&body_json) { return message; } body_text } fn find_square_hole_error_message(value: &Value) -> Option { if let Some(message) = value .get("details") .and_then(|details| details.get("message")) .and_then(Value::as_str) .map(str::trim) .filter(|message| !message.is_empty()) { return Some(message.to_string()); } if let Some(message) = value .get("message") .and_then(Value::as_str) .map(str::trim) .filter(|message| !message.is_empty()) { return Some(message.to_string()); } match value { Value::Array(items) => items.iter().find_map(find_square_hole_error_message), Value::Object(object) => object.values().find_map(find_square_hole_error_message), _ => None, } } fn square_hole_bad_request( request_context: &RequestContext, provider: &str, message: &str, ) -> Response { square_hole_error_response( request_context, provider, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": provider, "message": message, })), ) } fn map_square_hole_client_error(error: SpacetimeClientError) -> AppError { let status = match &error { SpacetimeClientError::Runtime(_) => StatusCode::BAD_REQUEST, SpacetimeClientError::Procedure(message) if message.contains("不存在") || message.contains("not found") || message.contains("does not exist") => { StatusCode::NOT_FOUND } SpacetimeClientError::Procedure(message) if message.contains("发布需要") || message.contains("不能为空") || message.contains("必须") => { StatusCode::BAD_REQUEST } _ => StatusCode::BAD_GATEWAY, }; AppError::from_status(status).with_details(json!({ "provider": "spacetimedb", "message": error.to_string(), })) } fn square_hole_error_response( request_context: &RequestContext, provider: &str, error: AppError, ) -> Response { let mut response = error.into_response_with_context(Some(request_context)); response.headers_mut().insert( HeaderName::from_static("x-genarrative-provider"), header::HeaderValue::from_str(provider) .unwrap_or_else(|_| header::HeaderValue::from_static("square-hole")), ); response } fn square_hole_sse_json_event(event_name: &str, payload: Value) -> Result { Event::default() .event(event_name) .json_data(payload) .map_err(|error| { AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ "provider": "sse", "message": format!("SSE payload 序列化失败:{error}"), })) }) } fn square_hole_sse_json_event_or_error(event_name: &str, payload: Value) -> Event { match square_hole_sse_json_event(event_name, payload) { Ok(event) => event, Err(error) => Event::default().event("error").data(format!("{error:?}")), } } fn current_utc_micros() -> i64 { let duration = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default(); (duration.as_secs() as i64) * 1_000_000 + i64::from(duration.subsec_micros()) } fn current_utc_ms() -> i64 { current_utc_micros().saturating_div(1000) }