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}; 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::generated_image_assets::{ GeneratedImageAssetAdapter, GeneratedImageAssetDataUrl, adapter::GeneratedImageAssetAdapterMetadata, adapter::GeneratedImageAssetPersistInput, normalize_generated_image_asset_mime, }; 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), ) }) } mod visual_assets; use visual_assets::{ generate_square_hole_visual_assets_for_session, regenerate_square_hole_visual_asset_for_work, }; mod mappers; use mappers::*; 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) }