use std::{ env, fs, path::{Path, PathBuf}, time::{SystemTime, UNIX_EPOCH}, }; use axum::{ Json, extract::{Extension, Path as AxumPath, State, rejection::JsonRejection}, http::{HeaderName, StatusCode, header}, response::{IntoResponse, Response}, }; use serde_json::{Value, json}; use shared_contracts::{ puzzle_agent::{ CreatePuzzleAgentSessionRequest, ExecutePuzzleAgentActionRequest, PuzzleAgentActionResponse, PuzzleAgentMessageResponse, PuzzleAgentOperationResponse, PuzzleAgentSessionResponse, PuzzleAgentSessionSnapshotResponse, PuzzleAgentSuggestedActionResponse, PuzzleAnchorItemResponse, PuzzleAnchorPackResponse, PuzzleCreatorIntentResponse, PuzzleGeneratedImageCandidateResponse, PuzzleResultDraftResponse, PuzzleResultPreviewBlockerResponse, PuzzleResultPreviewEnvelopeResponse, PuzzleResultPreviewFindingResponse, SendPuzzleAgentMessageRequest, }, puzzle_gallery::{PuzzleGalleryDetailResponse, PuzzleGalleryResponse}, puzzle_runtime::{ DragPuzzlePieceRequest, PuzzleBoardSnapshotResponse, PuzzleCellPositionResponse, PuzzleMergedGroupStateResponse, PuzzlePieceStateResponse, PuzzleRunResponse, PuzzleRunSnapshotResponse, PuzzleRuntimeLevelSnapshotResponse, StartPuzzleRunRequest, SwapPuzzlePiecesRequest, }, puzzle_works::{ PuzzleWorkDetailResponse, PuzzleWorkMutationResponse, PuzzleWorkProfileResponse, PuzzleWorkSummaryResponse, PuzzleWorksResponse, PutPuzzleWorkRequest, }, }; use shared_kernel::build_prefixed_uuid_id; use spacetime_client::{ PuzzleAgentMessageRecord, PuzzleAgentMessageSubmitRecordInput, PuzzleAgentSessionCreateRecordInput, PuzzleAgentSessionRecord, PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord, PuzzleAnchorPackRecord, PuzzleCreatorIntentRecord, PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput, PuzzlePublishRecordInput, PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord, PuzzleRunDragRecordInput, PuzzleRunRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleSelectCoverImageRecordInput, PuzzleWorkProfileRecord, PuzzleWorkUpsertRecordInput, SpacetimeClientError, }; use crate::{ api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError, request_context::RequestContext, state::AppState, }; const PUZZLE_AGENT_API_BASE_PROVIDER: &str = "puzzle-agent"; const PUZZLE_WORKS_PROVIDER: &str = "puzzle-works"; const PUZZLE_GALLERY_PROVIDER: &str = "puzzle-gallery"; const PUZZLE_RUNTIME_PROVIDER: &str = "puzzle-runtime"; pub async fn create_puzzle_agent_session( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = payload.map_err(|error| { puzzle_error_response( &request_context, PUZZLE_AGENT_API_BASE_PROVIDER, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": PUZZLE_AGENT_API_BASE_PROVIDER, "message": error.body_text(), })), ) })?; let seed_text = payload.seed_text.unwrap_or_default().trim().to_string(); let session = state .spacetime_client() .create_puzzle_agent_session(PuzzleAgentSessionCreateRecordInput { session_id: build_prefixed_uuid_id("puzzle-session-"), owner_user_id: authenticated.claims().user_id().to_string(), seed_text: seed_text.clone(), welcome_message_id: build_prefixed_uuid_id("puzzle-message-"), welcome_message_text: build_puzzle_welcome_text(&seed_text), created_at_micros: current_utc_micros(), }) .await .map_err(|error| { puzzle_error_response( &request_context, PUZZLE_AGENT_API_BASE_PROVIDER, map_puzzle_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), PuzzleAgentSessionResponse { session: map_puzzle_agent_session_response(session), }, )) } pub async fn get_puzzle_agent_session( State(state): State, AxumPath(session_id): AxumPath, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, Response> { ensure_non_empty(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, &session_id, "sessionId")?; let session = state .spacetime_client() .get_puzzle_agent_session( session_id, authenticated.claims().user_id().to_string(), ) .await .map_err(|error| { puzzle_error_response( &request_context, PUZZLE_AGENT_API_BASE_PROVIDER, map_puzzle_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), PuzzleAgentSessionResponse { session: map_puzzle_agent_session_response(session), }, )) } pub async fn submit_puzzle_agent_message( State(state): State, AxumPath(session_id): AxumPath, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = payload.map_err(|error| { puzzle_error_response( &request_context, PUZZLE_AGENT_API_BASE_PROVIDER, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": PUZZLE_AGENT_API_BASE_PROVIDER, "message": error.body_text(), })), ) })?; ensure_non_empty(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, &session_id, "sessionId")?; let client_message_id = payload.client_message_id.trim().to_string(); let message_text = payload.text.trim().to_string(); if client_message_id.is_empty() || message_text.is_empty() { return Err(puzzle_bad_request( &request_context, PUZZLE_AGENT_API_BASE_PROVIDER, "clientMessageId and text are required", )); } let session = state .spacetime_client() .submit_puzzle_agent_message(PuzzleAgentMessageSubmitRecordInput { session_id, owner_user_id: authenticated.claims().user_id().to_string(), user_message_id: client_message_id, user_message_text: message_text, submitted_at_micros: current_utc_micros(), }) .await .map_err(|error| { puzzle_error_response( &request_context, PUZZLE_AGENT_API_BASE_PROVIDER, map_puzzle_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), PuzzleAgentSessionResponse { session: map_puzzle_agent_session_response(session), }, )) } pub async fn stream_puzzle_agent_message( State(state): State, AxumPath(session_id): AxumPath, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result { let Json(payload) = payload.map_err(|error| { puzzle_error_response( &request_context, PUZZLE_AGENT_API_BASE_PROVIDER, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": PUZZLE_AGENT_API_BASE_PROVIDER, "message": error.body_text(), })), ) })?; ensure_non_empty(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, &session_id, "sessionId")?; let session = state .spacetime_client() .submit_puzzle_agent_message(PuzzleAgentMessageSubmitRecordInput { session_id, owner_user_id: authenticated.claims().user_id().to_string(), user_message_id: payload.client_message_id.trim().to_string(), user_message_text: payload.text.trim().to_string(), submitted_at_micros: current_utc_micros(), }) .await .map_err(|error| { puzzle_error_response( &request_context, PUZZLE_AGENT_API_BASE_PROVIDER, map_puzzle_client_error(error), ) })?; let session_response = map_puzzle_agent_session_response(session); let reply_text = session_response .last_assistant_reply .clone() .unwrap_or_else(|| "拼图锚点已更新。".to_string()); let mut sse_body = String::new(); append_sse_event( &request_context, &mut sse_body, "reply_delta", &json!({ "text": reply_text }), )?; append_sse_event( &request_context, &mut sse_body, "session", &json!({ "session": session_response }), )?; append_sse_event(&request_context, &mut sse_body, "done", &json!({ "ok": true }))?; Ok(build_event_stream_response(sse_body)) } pub async fn execute_puzzle_agent_action( State(state): State, AxumPath(session_id): AxumPath, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = payload.map_err(|error| { puzzle_error_response( &request_context, PUZZLE_AGENT_API_BASE_PROVIDER, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": PUZZLE_AGENT_API_BASE_PROVIDER, "message": error.body_text(), })), ) })?; ensure_non_empty(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, &session_id, "sessionId")?; let owner_user_id = authenticated.claims().user_id().to_string(); let now = current_utc_micros(); let (operation_type, phase_label, phase_detail, session) = match payload.action.trim() { "compile_puzzle_draft" => { let session = state .spacetime_client() .compile_puzzle_agent_draft(session_id, owner_user_id, now) .await; ( "compile_puzzle_draft", "结果页草稿", "已根据当前锚点编译结果页草稿。", session, ) } "generate_puzzle_images" => { let session = state .spacetime_client() .get_puzzle_agent_session(session_id.clone(), owner_user_id.clone()) .await; let session = match session { Ok(session) => { let draft = session.draft.clone().ok_or_else(|| { SpacetimeClientError::Runtime("拼图结果页草稿尚未生成".to_string()) }); match draft { Ok(draft) => { let prompt = payload .prompt_text .clone() .filter(|value| !value.trim().is_empty()) .unwrap_or_else(|| draft.summary.clone()); let candidate_count = payload.candidate_count.unwrap_or(2).clamp(1, 2); let candidates = build_placeholder_puzzle_candidates( &session.session_id, &draft.level_name, &prompt, candidate_count, ) .map_err(SpacetimeClientError::Runtime); match candidates { Ok(candidates) => { let candidates_json = serde_json::to_string( &candidates .iter() .map(|candidate| { json!({ "candidateId": candidate.candidate_id, "imageSrc": candidate.image_src, "assetId": candidate.asset_id, "prompt": candidate.prompt, "actualPrompt": candidate.actual_prompt, "sourceType": candidate.source_type, "selected": candidate.selected, }) }) .collect::>(), ) .map_err(|error| { SpacetimeClientError::Runtime(format!( "拼图候选图序列化失败:{error}" )) }); match candidates_json { Ok(candidates_json) => { state .spacetime_client() .save_puzzle_generated_images( PuzzleGeneratedImagesSaveRecordInput { session_id: session.session_id, owner_user_id, candidates_json, saved_at_micros: now, }, ) .await } Err(error) => Err(error), } } Err(error) => Err(error), } } Err(error) => Err(error), } } Err(error) => Err(error), }; ( "generate_puzzle_images", "候选图生成", "已生成 2 张候选拼图图像。", session, ) } "select_puzzle_image" => { let candidate_id = payload .candidate_id .clone() .filter(|value| !value.trim().is_empty()) .ok_or_else(|| { puzzle_bad_request( &request_context, PUZZLE_AGENT_API_BASE_PROVIDER, "candidateId is required", ) })?; let session = state .spacetime_client() .select_puzzle_cover_image(PuzzleSelectCoverImageRecordInput { session_id: session_id.clone(), owner_user_id: owner_user_id.clone(), candidate_id, selected_at_micros: now, }) .await; ( "select_puzzle_image", "正式图确认", "已应用正式拼图图片。", session, ) } "publish_puzzle_work" => { let profile = state .spacetime_client() .publish_puzzle_work(PuzzlePublishRecordInput { session_id: session_id.clone(), owner_user_id: owner_user_id.clone(), work_id: build_prefixed_uuid_id("puzzle-work-"), profile_id: build_prefixed_uuid_id("puzzle-profile-"), author_display_name: resolve_author_display_name(&state, &authenticated), level_name: payload.level_name.clone(), summary: payload.summary.clone(), theme_tags: payload.theme_tags.clone(), published_at_micros: now, }) .await .map_err(|error| { puzzle_error_response( &request_context, PUZZLE_AGENT_API_BASE_PROVIDER, map_puzzle_client_error(error), ) })?; return Ok(json_success_body( Some(&request_context), PuzzleAgentActionResponse { operation: PuzzleAgentOperationResponse { operation_id: profile.profile_id.clone(), operation_type: "publish_puzzle_work".to_string(), status: "completed".to_string(), phase_label: "作品发布".to_string(), phase_detail: "拼图作品已发布到广场。".to_string(), progress: 100, error: None, }, }, )); } other => { return Err(puzzle_bad_request( &request_context, PUZZLE_AGENT_API_BASE_PROVIDER, format!("action `{other}` is not supported").as_str(), )); } }; let session = session.map_err(|error| { puzzle_error_response( &request_context, PUZZLE_AGENT_API_BASE_PROVIDER, map_puzzle_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), PuzzleAgentActionResponse { operation: PuzzleAgentOperationResponse { operation_id: session.session_id.clone(), operation_type: operation_type.to_string(), status: "completed".to_string(), phase_label: phase_label.to_string(), phase_detail: phase_detail.to_string(), progress: 100, error: None, }, }, )) } pub async fn get_puzzle_works( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, Response> { let items = state .spacetime_client() .list_puzzle_works(authenticated.claims().user_id().to_string()) .await .map_err(|error| { puzzle_error_response( &request_context, PUZZLE_WORKS_PROVIDER, map_puzzle_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), PuzzleWorksResponse { items: items .into_iter() .map(map_puzzle_work_summary_response) .collect(), }, )) } pub async fn get_puzzle_work_detail( State(state): State, AxumPath(profile_id): AxumPath, Extension(request_context): Extension, Extension(_authenticated): Extension, ) -> Result, Response> { ensure_non_empty(&request_context, PUZZLE_WORKS_PROVIDER, &profile_id, "profileId")?; let item = state .spacetime_client() .get_puzzle_work_detail(profile_id) .await .map_err(|error| { puzzle_error_response( &request_context, PUZZLE_WORKS_PROVIDER, map_puzzle_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), PuzzleWorkDetailResponse { item: map_puzzle_work_profile_response(item), }, )) } pub async fn put_puzzle_work( State(state): State, AxumPath(profile_id): AxumPath, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = payload.map_err(|error| { puzzle_error_response( &request_context, PUZZLE_WORKS_PROVIDER, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": PUZZLE_WORKS_PROVIDER, "message": error.body_text(), })), ) })?; ensure_non_empty(&request_context, PUZZLE_WORKS_PROVIDER, &profile_id, "profileId")?; let item = state .spacetime_client() .update_puzzle_work(PuzzleWorkUpsertRecordInput { profile_id, owner_user_id: authenticated.claims().user_id().to_string(), level_name: payload.level_name, summary: payload.summary, theme_tags: payload.theme_tags, cover_image_src: payload.cover_image_src, cover_asset_id: payload.cover_asset_id, updated_at_micros: current_utc_micros(), }) .await .map_err(|error| { puzzle_error_response( &request_context, PUZZLE_WORKS_PROVIDER, map_puzzle_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), PuzzleWorkMutationResponse { item: map_puzzle_work_profile_response(item), }, )) } pub async fn list_puzzle_gallery( State(state): State, Extension(request_context): Extension, ) -> Result, Response> { let items = state .spacetime_client() .list_puzzle_gallery() .await .map_err(|error| { puzzle_error_response( &request_context, PUZZLE_GALLERY_PROVIDER, map_puzzle_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), PuzzleGalleryResponse { items: items .into_iter() .map(map_puzzle_work_summary_response) .collect(), }, )) } pub async fn get_puzzle_gallery_detail( State(state): State, AxumPath(profile_id): AxumPath, Extension(request_context): Extension, ) -> Result, Response> { ensure_non_empty(&request_context, PUZZLE_GALLERY_PROVIDER, &profile_id, "profileId")?; let item = state .spacetime_client() .get_puzzle_gallery_detail(profile_id) .await .map_err(|error| { puzzle_error_response( &request_context, PUZZLE_GALLERY_PROVIDER, map_puzzle_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), PuzzleGalleryDetailResponse { item: map_puzzle_work_summary_response(item), }, )) } pub async fn start_puzzle_run( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = payload.map_err(|error| { puzzle_error_response( &request_context, PUZZLE_RUNTIME_PROVIDER, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": PUZZLE_RUNTIME_PROVIDER, "message": error.body_text(), })), ) })?; ensure_non_empty(&request_context, PUZZLE_RUNTIME_PROVIDER, &payload.profile_id, "profileId")?; let run = state .spacetime_client() .start_puzzle_run(PuzzleRunStartRecordInput { run_id: build_prefixed_uuid_id("puzzle-run-"), owner_user_id: authenticated.claims().user_id().to_string(), profile_id: payload.profile_id, started_at_micros: current_utc_micros(), }) .await .map_err(|error| { puzzle_error_response( &request_context, PUZZLE_RUNTIME_PROVIDER, map_puzzle_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), PuzzleRunResponse { run: map_puzzle_run_response(run), }, )) } pub async fn get_puzzle_run( State(state): State, AxumPath(run_id): AxumPath, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, Response> { ensure_non_empty(&request_context, PUZZLE_RUNTIME_PROVIDER, &run_id, "runId")?; let run = state .spacetime_client() .get_puzzle_run(run_id, authenticated.claims().user_id().to_string()) .await .map_err(|error| { puzzle_error_response( &request_context, PUZZLE_RUNTIME_PROVIDER, map_puzzle_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), PuzzleRunResponse { run: map_puzzle_run_response(run), }, )) } pub async fn swap_puzzle_pieces( State(state): State, AxumPath(run_id): AxumPath, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = payload.map_err(|error| { puzzle_error_response( &request_context, PUZZLE_RUNTIME_PROVIDER, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": PUZZLE_RUNTIME_PROVIDER, "message": error.body_text(), })), ) })?; ensure_non_empty(&request_context, PUZZLE_RUNTIME_PROVIDER, &run_id, "runId")?; ensure_non_empty( &request_context, PUZZLE_RUNTIME_PROVIDER, &payload.first_piece_id, "firstPieceId", )?; ensure_non_empty( &request_context, PUZZLE_RUNTIME_PROVIDER, &payload.second_piece_id, "secondPieceId", )?; let run = state .spacetime_client() .swap_puzzle_pieces(PuzzleRunSwapRecordInput { run_id, owner_user_id: authenticated.claims().user_id().to_string(), first_piece_id: payload.first_piece_id, second_piece_id: payload.second_piece_id, swapped_at_micros: current_utc_micros(), }) .await .map_err(|error| { puzzle_error_response( &request_context, PUZZLE_RUNTIME_PROVIDER, map_puzzle_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), PuzzleRunResponse { run: map_puzzle_run_response(run), }, )) } pub async fn drag_puzzle_piece_or_group( State(state): State, AxumPath(run_id): AxumPath, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = payload.map_err(|error| { puzzle_error_response( &request_context, PUZZLE_RUNTIME_PROVIDER, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": PUZZLE_RUNTIME_PROVIDER, "message": error.body_text(), })), ) })?; ensure_non_empty(&request_context, PUZZLE_RUNTIME_PROVIDER, &run_id, "runId")?; ensure_non_empty(&request_context, PUZZLE_RUNTIME_PROVIDER, &payload.piece_id, "pieceId")?; let run = state .spacetime_client() .drag_puzzle_piece_or_group(PuzzleRunDragRecordInput { run_id, owner_user_id: authenticated.claims().user_id().to_string(), piece_id: payload.piece_id, target_row: payload.target_row, target_col: payload.target_col, dragged_at_micros: current_utc_micros(), }) .await .map_err(|error| { puzzle_error_response( &request_context, PUZZLE_RUNTIME_PROVIDER, map_puzzle_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), PuzzleRunResponse { run: map_puzzle_run_response(run), }, )) } pub async fn advance_puzzle_next_level( State(state): State, AxumPath(run_id): AxumPath, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, Response> { ensure_non_empty(&request_context, PUZZLE_RUNTIME_PROVIDER, &run_id, "runId")?; let run = state .spacetime_client() .advance_puzzle_next_level(spacetime_client::PuzzleRunNextLevelRecordInput { run_id, owner_user_id: authenticated.claims().user_id().to_string(), advanced_at_micros: current_utc_micros(), }) .await .map_err(|error| { puzzle_error_response( &request_context, PUZZLE_RUNTIME_PROVIDER, map_puzzle_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), PuzzleRunResponse { run: map_puzzle_run_response(run), }, )) } fn map_puzzle_agent_session_response( session: PuzzleAgentSessionRecord, ) -> PuzzleAgentSessionSnapshotResponse { PuzzleAgentSessionSnapshotResponse { session_id: session.session_id, current_turn: session.current_turn, progress_percent: session.progress_percent, stage: session.stage, anchor_pack: map_puzzle_anchor_pack_response(session.anchor_pack), draft: session.draft.map(map_puzzle_result_draft_response), messages: session .messages .into_iter() .map(map_puzzle_agent_message_response) .collect(), last_assistant_reply: session.last_assistant_reply, published_profile_id: session.published_profile_id, suggested_actions: session .suggested_actions .into_iter() .map(map_puzzle_suggested_action_response) .collect(), result_preview: session.result_preview.map(map_puzzle_result_preview_response), updated_at: session.updated_at, } } fn map_puzzle_anchor_pack_response(anchor_pack: PuzzleAnchorPackRecord) -> PuzzleAnchorPackResponse { PuzzleAnchorPackResponse { theme_promise: map_puzzle_anchor_item_response(anchor_pack.theme_promise), visual_subject: map_puzzle_anchor_item_response(anchor_pack.visual_subject), visual_mood: map_puzzle_anchor_item_response(anchor_pack.visual_mood), composition_hooks: map_puzzle_anchor_item_response(anchor_pack.composition_hooks), tags_and_forbidden: map_puzzle_anchor_item_response(anchor_pack.tags_and_forbidden), } } fn map_puzzle_anchor_item_response(anchor: PuzzleAnchorItemRecord) -> PuzzleAnchorItemResponse { PuzzleAnchorItemResponse { key: anchor.key, label: anchor.label, value: anchor.value, status: anchor.status, } } fn map_puzzle_result_draft_response(draft: PuzzleResultDraftRecord) -> PuzzleResultDraftResponse { PuzzleResultDraftResponse { level_name: draft.level_name, summary: draft.summary, theme_tags: draft.theme_tags, forbidden_directives: draft.forbidden_directives, creator_intent: draft.creator_intent.map(map_puzzle_creator_intent_response), anchor_pack: map_puzzle_anchor_pack_response(draft.anchor_pack), candidates: draft .candidates .into_iter() .map(map_puzzle_generated_image_candidate_response) .collect(), selected_candidate_id: draft.selected_candidate_id, cover_image_src: draft.cover_image_src, cover_asset_id: draft.cover_asset_id, generation_status: draft.generation_status, } } fn map_puzzle_creator_intent_response( intent: PuzzleCreatorIntentRecord, ) -> PuzzleCreatorIntentResponse { PuzzleCreatorIntentResponse { source_mode: intent.source_mode, raw_messages_summary: intent.raw_messages_summary, theme_promise: intent.theme_promise, visual_subject: intent.visual_subject, visual_mood: intent.visual_mood, composition_hooks: intent.composition_hooks, theme_tags: intent.theme_tags, forbidden_directives: intent.forbidden_directives, } } fn map_puzzle_generated_image_candidate_response( candidate: PuzzleGeneratedImageCandidateRecord, ) -> PuzzleGeneratedImageCandidateResponse { PuzzleGeneratedImageCandidateResponse { candidate_id: candidate.candidate_id, image_src: candidate.image_src, asset_id: candidate.asset_id, prompt: candidate.prompt, actual_prompt: candidate.actual_prompt, source_type: candidate.source_type, selected: candidate.selected, } } fn map_puzzle_agent_message_response( message: PuzzleAgentMessageRecord, ) -> PuzzleAgentMessageResponse { PuzzleAgentMessageResponse { id: message.message_id, role: message.role, kind: message.kind, text: message.text, created_at: message.created_at, } } fn map_puzzle_suggested_action_response( action: PuzzleAgentSuggestedActionRecord, ) -> PuzzleAgentSuggestedActionResponse { PuzzleAgentSuggestedActionResponse { id: action.action_id, action_type: action.action_type, label: action.label, } } fn map_puzzle_result_preview_response( preview: PuzzleResultPreviewRecord, ) -> PuzzleResultPreviewEnvelopeResponse { PuzzleResultPreviewEnvelopeResponse { draft: map_puzzle_result_draft_response(preview.draft), blockers: preview .blockers .into_iter() .map(map_puzzle_result_preview_blocker_response) .collect(), quality_findings: preview .quality_findings .into_iter() .map(map_puzzle_result_preview_finding_response) .collect(), publish_ready: preview.publish_ready, } } fn map_puzzle_result_preview_blocker_response( blocker: PuzzleResultPreviewBlockerRecord, ) -> PuzzleResultPreviewBlockerResponse { PuzzleResultPreviewBlockerResponse { id: blocker.blocker_id, code: blocker.code, message: blocker.message, } } fn map_puzzle_result_preview_finding_response( finding: PuzzleResultPreviewFindingRecord, ) -> PuzzleResultPreviewFindingResponse { PuzzleResultPreviewFindingResponse { id: finding.finding_id, severity: finding.severity, code: finding.code, message: finding.message, } } fn map_puzzle_work_summary_response(item: PuzzleWorkProfileRecord) -> PuzzleWorkSummaryResponse { PuzzleWorkSummaryResponse { work_id: item.work_id, profile_id: item.profile_id, owner_user_id: item.owner_user_id, source_session_id: item.source_session_id, author_display_name: item.author_display_name, level_name: item.level_name, summary: item.summary, theme_tags: item.theme_tags, cover_image_src: item.cover_image_src, cover_asset_id: item.cover_asset_id, publication_status: item.publication_status, updated_at: item.updated_at, published_at: item.published_at, play_count: item.play_count, publish_ready: item.publish_ready, } } fn map_puzzle_work_profile_response(item: PuzzleWorkProfileRecord) -> PuzzleWorkProfileResponse { PuzzleWorkProfileResponse { summary: map_puzzle_work_summary_response(item.clone()), anchor_pack: map_puzzle_anchor_pack_response(item.anchor_pack), } } fn map_puzzle_run_response(run: PuzzleRunRecord) -> PuzzleRunSnapshotResponse { PuzzleRunSnapshotResponse { run_id: run.run_id, entry_profile_id: run.entry_profile_id, cleared_level_count: run.cleared_level_count, current_level_index: run.current_level_index, current_grid_size: run.current_grid_size, played_profile_ids: run.played_profile_ids, previous_level_tags: run.previous_level_tags, current_level: run.current_level.map(map_puzzle_runtime_level_response), recommended_next_profile_id: run.recommended_next_profile_id, } } fn map_puzzle_runtime_level_response( level: spacetime_client::PuzzleRuntimeLevelRecord, ) -> PuzzleRuntimeLevelSnapshotResponse { PuzzleRuntimeLevelSnapshotResponse { run_id: level.run_id, level_index: level.level_index, grid_size: level.grid_size, profile_id: level.profile_id, level_name: level.level_name, author_display_name: level.author_display_name, theme_tags: level.theme_tags, cover_image_src: level.cover_image_src, board: map_puzzle_board_response(level.board), status: level.status, } } fn map_puzzle_board_response( board: spacetime_client::PuzzleBoardRecord, ) -> PuzzleBoardSnapshotResponse { PuzzleBoardSnapshotResponse { rows: board.rows, cols: board.cols, pieces: board .pieces .into_iter() .map(|piece| PuzzlePieceStateResponse { piece_id: piece.piece_id, correct_row: piece.correct_row, correct_col: piece.correct_col, current_row: piece.current_row, current_col: piece.current_col, merged_group_id: piece.merged_group_id, }) .collect(), merged_groups: board .merged_groups .into_iter() .map(|group| PuzzleMergedGroupStateResponse { group_id: group.group_id, piece_ids: group.piece_ids, occupied_cells: group .occupied_cells .into_iter() .map(|cell| PuzzleCellPositionResponse { row: cell.row, col: cell.col, }) .collect(), }) .collect(), selected_piece_id: board.selected_piece_id, all_tiles_resolved: board.all_tiles_resolved, } } 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 build_puzzle_welcome_text(seed_text: &str) -> String { if seed_text.trim().is_empty() { return "先告诉我你想做一张什么样的拼图图像,我会帮你收束成可发布的题材锚点。" .to_string(); } "我先接住你的画面灵感,再一起把它收束成正式拼图关卡。".to_string() } fn ensure_non_empty( request_context: &RequestContext, provider: &str, value: &str, field_name: &str, ) -> Result<(), Response> { if value.trim().is_empty() { return Err(puzzle_error_response( request_context, provider, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": provider, "message": format!("{field_name} is required"), })), )); } Ok(()) } fn puzzle_bad_request( request_context: &RequestContext, provider: &str, message: &str, ) -> Response { puzzle_error_response( request_context, provider, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": provider, "message": message, })), ) } fn map_puzzle_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 } _ => StatusCode::BAD_GATEWAY, }; AppError::from_status(status).with_details(json!({ "provider": "spacetimedb", "message": error.to_string(), })) } fn puzzle_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("puzzle")), ); response } fn append_sse_event( request_context: &RequestContext, body: &mut String, event_name: &str, payload: &Value, ) -> Result<(), Response> { let payload = serde_json::to_string(payload).map_err(|error| { puzzle_error_response( request_context, PUZZLE_AGENT_API_BASE_PROVIDER, AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ "provider": PUZZLE_AGENT_API_BASE_PROVIDER, "message": format!("SSE payload 序列化失败:{error}"), })), ) })?; body.push_str("event: "); body.push_str(event_name); body.push('\n'); body.push_str("data: "); body.push_str(&payload); body.push_str("\n\n"); Ok(()) } fn build_event_stream_response(body: String) -> Response { ( [ (header::CONTENT_TYPE, "text/event-stream; charset=utf-8"), (header::CACHE_CONTROL, "no-cache, no-transform"), (header::CONNECTION, "keep-alive"), ], body, ) .into_response() } fn build_placeholder_puzzle_candidates( session_id: &str, level_name: &str, prompt: &str, candidate_count: u32, ) -> Result, String> { let count = candidate_count.clamp(1, 2); let mut items = Vec::with_capacity(count as usize); for index in 0..count { let asset = save_placeholder_puzzle_asset( session_id, level_name, &format!("candidate-{}", index + 1), "cover", "1536*1536", Some(prompt), ) .map_err(|error| error.message().to_string())?; items.push(PuzzleGeneratedImageCandidateResponse { candidate_id: format!("{session_id}-candidate-{}", index + 1), image_src: asset.image_src, asset_id: asset.asset_id, prompt: prompt.to_string(), actual_prompt: Some(prompt.to_string()), source_type: "generated".to_string(), selected: index == 0, }); } Ok(items .into_iter() .map(|candidate| PuzzleGeneratedImageCandidateRecord { candidate_id: candidate.candidate_id, image_src: candidate.image_src, asset_id: candidate.asset_id, prompt: candidate.prompt, actual_prompt: candidate.actual_prompt, source_type: candidate.source_type, selected: candidate.selected, }) .collect()) } struct GeneratedPuzzleAssetResponse { image_src: String, asset_id: String, } fn save_placeholder_puzzle_asset( session_segment_seed: &str, work_segment_seed: &str, leaf_segment_seed: &str, file_stem: &str, size: &str, prompt: Option<&str>, ) -> Result { let asset_id = format!("{file_stem}-{}", current_utc_millis()); let relative_dir = PathBuf::from("generated-puzzle-covers") .join(sanitize_path_segment(session_segment_seed, "session")) .join(sanitize_path_segment(work_segment_seed, "puzzle")) .join(sanitize_path_segment(leaf_segment_seed, "candidate")) .join(&asset_id); let output_dir = resolve_public_output_dir(&relative_dir)?; fs::create_dir_all(&output_dir).map_err(io_error)?; let file_name = format!("{file_stem}.svg"); let svg = build_puzzle_placeholder_svg(size, prompt.unwrap_or(file_stem)); fs::write(output_dir.join(&file_name), svg).map_err(io_error)?; Ok(GeneratedPuzzleAssetResponse { image_src: format!("/{}/{}", relative_dir.to_string_lossy().replace('\\', "/"), file_name), asset_id, }) } fn build_puzzle_placeholder_svg(size: &str, label: &str) -> String { let (width, height) = parse_size(size); format!( r##" {title} Puzzle placeholder "##, width = width, height = height, cx1 = width / 4, cy1 = height / 3, r1 = (width.min(height) / 5).max(42), cx2 = width * 3 / 4, cy2 = height / 4, r2 = (width.min(height) / 7).max(30), frame_x = width / 9, frame_y = height / 9, frame_w = width * 7 / 9, frame_h = height * 7 / 9, frame_r = (width.min(height) / 20).max(18), font_main = (width.min(height) / 14).max(22), font_sub = (width.min(height) / 30).max(12), title = escape_svg_text(label), ) } fn parse_size(size: &str) -> (u32, u32) { let mut parts = size.split('*'); let width = parts .next() .and_then(|value| value.trim().parse::().ok()) .filter(|value| *value > 0) .unwrap_or(1536); let height = parts .next() .and_then(|value| value.trim().parse::().ok()) .filter(|value| *value > 0) .unwrap_or(1536); (width, height) } fn escape_svg_text(value: &str) -> String { value .replace('&', "&") .replace('<', "<") .replace('>', ">") } fn sanitize_path_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 resolve_public_output_dir(relative_dir: &Path) -> Result { let workspace_root = Path::new(env!("CARGO_MANIFEST_DIR")) .ancestors() .nth(3) .ok_or_else(|| { AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) .with_message("无法定位仓库根目录") })?; Ok(workspace_root.join("public").join(relative_dir)) } fn io_error(error: std::io::Error) -> AppError { AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error.to_string()) } 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_millis() -> u128 { SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_millis() }