use std::collections::BTreeMap; use axum::{ Json, extract::{Extension, Path, State, rejection::JsonRejection}, http::StatusCode, response::{ IntoResponse, Response, sse::{Event, Sse}, }, }; use module_custom_world::{ CustomWorldThemeMode, canonicalize_custom_world_profile_before_save, empty_agent_anchor_content_json, empty_agent_asset_coverage_json, empty_agent_creator_intent_readiness_json, empty_json_array, empty_json_object, }; use serde_json::{Map, Value, json}; use shared_contracts::runtime::{ CreateCustomWorldAgentSessionRequest, CustomWorldAgentCardDetailResponse, CustomWorldAgentCheckpointResponse, CustomWorldAgentMessageResponse, CustomWorldAgentOperationResponse, CustomWorldAgentSessionResponse, CustomWorldAgentSessionSnapshotResponse, CustomWorldCreationResultViewResponse, CustomWorldDraftCardDetailResponse, CustomWorldDraftCardDetailSectionResponse, CustomWorldDraftCardSummaryResponse, CustomWorldGalleryCardResponse, CustomWorldGalleryDetailResponse, CustomWorldGalleryResponse, CustomWorldLibraryEntryResponse, CustomWorldLibraryMutationResponse, CustomWorldLibraryResponse, CustomWorldProfileUpsertRequest, CustomWorldPublishGateResponse, CustomWorldResultPreviewBlockerResponse, CustomWorldSupportedActionResponse, CustomWorldWorkSummaryResponse, CustomWorldWorksResponse, ExecuteCustomWorldAgentActionRequest, GenerateCustomWorldProfileRequest, SendCustomWorldAgentMessageRequest, }; use shared_kernel::build_prefixed_uuid_id; use spacetime_client::{ CustomWorldAgentActionExecuteRecordInput, CustomWorldAgentCheckpointRecord, CustomWorldAgentMessageFinalizeRecordInput, CustomWorldAgentMessageRecord, CustomWorldAgentMessageSubmitRecordInput, CustomWorldAgentOperationProgressRecordInput, CustomWorldAgentOperationRecord, CustomWorldAgentSessionCreateRecordInput, CustomWorldAgentSessionRecord, CustomWorldDraftCardDetailRecord, CustomWorldDraftCardDetailSectionRecord, CustomWorldDraftCardRecord, CustomWorldGalleryEntryRecord, CustomWorldLibraryEntryRecord, CustomWorldProfileLikeReportRecordInput, CustomWorldProfilePlayReportRecordInput, CustomWorldProfileRemixRecordInput, CustomWorldProfileUpsertRecordInput, CustomWorldPublishGateRecord, CustomWorldResultPreviewBlockerRecord, CustomWorldSupportedActionRecord, CustomWorldWorkSummaryRecord, SpacetimeClientError, }; use std::{collections::BTreeSet, convert::Infallible, sync::Arc, time::Instant}; use time::{OffsetDateTime, format_description::well_known::Rfc3339}; use tokio::sync::Semaphore; use tokio::task::JoinSet; use tracing::info; use crate::{ ai_generation_drafts::{ AiGenerationDraftContext, AiGenerationDraftSink, AiGenerationDraftWriter, }, api_response::json_success_body, asset_billing::execute_billable_asset_operation, auth::AuthenticatedAccessToken, character_visual_assets::generate_character_primary_visual_for_profile, custom_world_agent_entities::generate_custom_world_agent_entities, custom_world_agent_turn::{ CustomWorldAgentTurnRequest, build_failed_finalize_record_input, build_finalize_record_input, run_custom_world_agent_turn, }, custom_world_ai::generate_custom_world_scene_image_for_profile, custom_world_foundation_draft::{ DraftFoundationPayloadError, build_draft_foundation_action_payload_json, generate_custom_world_foundation_draft, stable_ascii_slug, }, http_error::AppError, prompt::scene_background::{ SceneActBackgroundPromptParams, build_scene_act_background_image_prompt, }, request_context::RequestContext, state::AppState, work_author::resolve_work_author_by_user_id, }; const DRAFT_ASSET_GENERATION_MAX_ATTEMPTS: u32 = 3; const DRAFT_FOUNDATION_PROGRESS_FRAMEWORK_START: u32 = 12; const DRAFT_FOUNDATION_PROGRESS_FRAMEWORK_DONE: u32 = 97; const DRAFT_FOUNDATION_PROGRESS_ASSET_START: u32 = 97; const DRAFT_FOUNDATION_PROGRESS_CARD_START: u32 = 98; const DRAFT_FOUNDATION_PROGRESS_WRITEBACK_START: u32 = 99; const DRAFT_ROLE_ASSET_TEXT_FIELDS: [&str; 3] = [ "visualDescription", "actionDescription", "sceneVisualDescription", ]; fn timestamp_micros_to_rfc3339(value: i64) -> String { match OffsetDateTime::from_unix_timestamp_nanos(i128::from(value) * 1_000) { Ok(timestamp) => timestamp .format(&Rfc3339) .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string()), Err(_) => "1970-01-01T00:00:00Z".to_string(), } } fn fallback_draft_foundation_failure_progress(phase_label: &str) -> u32 { if phase_label.contains("写入") { return DRAFT_FOUNDATION_PROGRESS_WRITEBACK_START; } if phase_label.contains("草稿卡") { return DRAFT_FOUNDATION_PROGRESS_CARD_START; } if phase_label.contains("素材") || phase_label.contains("角色主形象") || phase_label.contains("幕背景图") { return DRAFT_FOUNDATION_PROGRESS_ASSET_START; } if phase_label.contains("底稿") { return DRAFT_FOUNDATION_PROGRESS_FRAMEWORK_DONE; } DRAFT_FOUNDATION_PROGRESS_FRAMEWORK_START } fn reusable_draft_profile_for_asset_generation( session: &CustomWorldAgentSessionRecord, ) -> Option { let object = session .draft_profile .as_object() .filter(|object| !object.is_empty())?; let profile = Value::Object(object.clone()); match missing_role_asset_text_report(&profile) { Some(report) => { // 中文注释:旧失败会话可能保存了缺少角色形象文本的半成品底稿; // 这种底稿不能直接续跑到生图阶段,必须回到文本底稿链路重新生成。 tracing::warn!( session_id = %session.session_id, missing_report = %report, "已保存 RPG 底稿缺少角色形象设定文本,跳过复用并重新生成底稿" ); None } None => Some(profile), } } pub async fn generate_custom_world_profile( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = payload.map_err(|error| { custom_world_error_response( &request_context, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "custom-world-profile", "message": error.body_text(), })), ) })?; let setting_text = payload.setting_text.trim().to_string(); if setting_text.is_empty() { return Err(custom_world_error_response( &request_context, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "custom-world-profile", "message": "settingText is required", })), )); } let llm_client = state.llm_client().ok_or_else(|| { custom_world_error_response( &request_context, AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ "provider": "custom-world-profile", "message": "服务端尚未配置可用的 LLM API Key", })), ) })?; let generation_mode = normalize_profile_generation_mode(payload.generation_mode.as_deref()); let creator_intent = payload.creator_intent.unwrap_or(Value::Null); let session = build_profile_generation_session( setting_text.clone(), creator_intent.clone(), authenticated.claims().user_id().to_string(), ); // 中文注释:profile 生成需要外部 LLM,必须留在 Axum/api-server;SpacetimeDB reducer 只接收确定结果。 let result = generate_custom_world_foundation_draft(llm_client, &session, |_| {}) .await .map_err(|message| { custom_world_error_response( &request_context, AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "custom-world-profile", "message": message, })), ) })?; let mut profile = serde_json::from_str::(&result.draft_profile_json).map_err(|error| { custom_world_error_response( &request_context, AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "custom-world-profile", "message": format!("profile JSON 解析失败:{error}"), })), ) })?; finalize_generated_custom_world_profile( &mut profile, setting_text.as_str(), generation_mode, creator_intent, ); Ok(json_success_body(Some(&request_context), profile)) } fn normalize_profile_generation_mode(value: Option<&str>) -> &'static str { if value.is_some_and(|item| item.trim().eq_ignore_ascii_case("fast")) { "fast" } else { "full" } } fn build_profile_generation_session( setting_text: String, creator_intent: Value, owner_user_id: String, ) -> CustomWorldAgentSessionRecord { CustomWorldAgentSessionRecord { session_id: build_prefixed_uuid_id("profile-generation-session-"), seed_text: setting_text, current_turn: 1, anchor_content: build_profile_generation_anchor_content(&creator_intent), progress_percent: 100, last_assistant_reply: None, stage: "foundation_review".to_string(), focus_card_id: None, creator_intent, creator_intent_readiness: json!({ "isReady": true }), anchor_pack: json!({}), lock_state: json!({}), draft_profile: Value::Null, messages: Vec::new(), draft_cards: Vec::new(), pending_clarifications: Vec::new(), suggested_actions: Vec::new(), recommended_replies: Vec::new(), quality_findings: Vec::new(), asset_coverage: json!({}), checkpoints: Vec::new(), supported_actions: Vec::new(), publish_gate: None, result_preview: None, updated_at: format!("profile-generation:{owner_user_id}"), } } fn build_profile_generation_anchor_content(creator_intent: &Value) -> Value { let world_hook = read_value_path_text(creator_intent, &["worldHook"]) .or_else(|| read_value_path_text(creator_intent, &["rawSettingText"])); let player_premise = read_value_path_text(creator_intent, &["playerPremise"]); let opening_situation = read_value_path_text(creator_intent, &["openingSituation"]); let core_conflicts = read_value_string_array(creator_intent, "coreConflicts"); let iconic_elements = read_value_string_array(creator_intent, "iconicElements"); json!({ "worldPromise": { "hook": world_hook.unwrap_or_default(), "differentiator": iconic_elements.first().cloned().unwrap_or_default(), "desiredExperience": read_value_string_array(creator_intent, "toneDirectives").join("、"), }, "playerEntryPoint": { "openingIdentity": player_premise.unwrap_or_default(), "openingProblem": opening_situation.unwrap_or_default(), "entryMotivation": core_conflicts.first().cloned().unwrap_or_default(), }, "coreConflict": { "conflicts": core_conflicts, }, "iconicElements": iconic_elements, }) } fn finalize_generated_custom_world_profile( profile: &mut Value, setting_text: &str, generation_mode: &str, creator_intent: Value, ) { if !profile.is_object() { *profile = json!({}); } let Some(object) = profile.as_object_mut() else { return; }; insert_profile_text_if_missing( object, "id", format!("custom-world-{}", stable_ascii_slug(setting_text)).as_str(), ); insert_profile_text_if_missing(object, "settingText", setting_text); insert_profile_text_if_missing(object, "templateWorldType", "WUXIA"); if !object .get("compatibilityTemplateWorldType") .and_then(Value::as_str) .map(str::trim) .is_some_and(|value| !value.is_empty()) { let template_world_type = object .get("templateWorldType") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or("WUXIA") .to_string(); object.insert( "compatibilityTemplateWorldType".to_string(), Value::String(template_world_type), ); } if !object.get("items").is_some_and(Value::is_array) { object.insert("items".to_string(), Value::Array(Vec::new())); } object.insert( "generationMode".to_string(), Value::String(generation_mode.to_string()), ); object.insert( "generationStatus".to_string(), Value::String( if generation_mode == "fast" { "key_only" } else { "complete" } .to_string(), ), ); if !matches!(creator_intent, Value::Null) { object.insert("creatorIntent".to_string(), creator_intent); } } fn insert_profile_text_if_missing(object: &mut Map, key: &str, fallback: &str) { if object .get(key) .and_then(Value::as_str) .map(str::trim) .is_some_and(|value| !value.is_empty()) { return; } object.insert(key.to_string(), Value::String(fallback.to_string())); } fn read_value_path_text(value: &Value, path: &[&str]) -> Option { let mut current = value; for segment in path { current = current.get(*segment)?; } current .as_str() .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) } fn read_value_string_array(value: &Value, key: &str) -> Vec { value .get(key) .and_then(Value::as_array) .map(|items| { items .iter() .filter_map(|item| item.as_str().map(str::trim)) .filter(|item| !item.is_empty()) .map(ToOwned::to_owned) .collect::>() }) .unwrap_or_default() } fn missing_role_asset_text_report(draft_profile: &Value) -> Option { let profile_object = draft_profile.as_object()?; let mut missing_items = Vec::new(); for key in ["playableNpcs", "storyNpcs"] { if let Some(roles) = profile_object.get(key).and_then(Value::as_array) { for (index, role) in roles.iter().enumerate() { let name = json_text_from_value(role, "name") .unwrap_or_else(|| format!("{}-{}", key, index + 1)); let missing_fields = DRAFT_ROLE_ASSET_TEXT_FIELDS .into_iter() .filter(|field| json_text_from_value(role, field).is_none()) .collect::>(); if !missing_fields.is_empty() { missing_items.push(format!("角色「{name}」缺少 {}", missing_fields.join("/"))); } } } } if missing_items.is_empty() { None } else { Some(missing_items.join(";")) } } pub async fn get_custom_world_library( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, Response> { let owner_user_id = authenticated.claims().user_id().to_string(); let entries = state .spacetime_client() .list_custom_world_works(owner_user_id.clone()) .await .map_err(|error| { custom_world_error_response(&request_context, map_custom_world_client_error(error)) })?; Ok(json_success_body( Some(&request_context), CustomWorldLibraryResponse { entries: entries .into_iter() .filter_map(|item| { map_custom_world_library_entry_response_from_work_summary( &state, item, &owner_user_id, ) }) .collect(), }, )) } pub async fn get_custom_world_library_detail( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, Path(profile_id): Path, ) -> Result, Response> { if profile_id.trim().is_empty() { return Err(custom_world_error_response( &request_context, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "custom-world-library", "message": "profileId is required", })), )); } let detail = state .spacetime_client() .get_custom_world_library_detail(authenticated.claims().user_id().to_string(), profile_id) .await .map_err(|error| { custom_world_error_response(&request_context, map_custom_world_client_error(error)) })?; Ok(json_success_body( Some(&request_context), CustomWorldGalleryDetailResponse { entry: map_custom_world_library_entry_response(&state, detail.entry), }, )) } pub async fn put_custom_world_library_profile( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, Path(profile_id): Path, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = payload.map_err(|error| { custom_world_error_response( &request_context, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "custom-world-library", "message": error.body_text(), })), ) })?; let owner_user_id = authenticated.claims().user_id().to_string(); if profile_id.trim().is_empty() { return Err(custom_world_error_response( &request_context, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "custom-world-library", "message": "profileId is required", })), )); } let (profile, metadata) = canonicalize_custom_world_library_profile_payload(payload.profile) .map_err(|error| { custom_world_error_response( &request_context, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "custom-world-library", "message": error, })), ) })?; let author_display_name = resolve_author_display_name(&state, &authenticated); let author_public_user_code = resolve_author_public_user_code(&state, &authenticated, &request_context)?; let mutation = state .spacetime_client() .upsert_custom_world_profile(CustomWorldProfileUpsertRecordInput { profile_id: profile_id.clone(), owner_user_id: owner_user_id.clone(), public_work_code: None, author_public_user_code: Some(author_public_user_code), source_agent_session_id: payload.source_agent_session_id.clone(), world_name: metadata.world_name, subtitle: metadata.subtitle, summary_text: metadata.summary_text, theme_mode: metadata.theme_mode, cover_image_src: metadata.cover_image_src, profile_payload_json: serde_json::to_string(&profile).map_err(|error| { custom_world_error_response( &request_context, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "custom-world-library", "message": format!("profile JSON 序列化失败:{error}"), })), ) })?, playable_npc_count: metadata.playable_npc_count, landmark_count: metadata.landmark_count, author_display_name, updated_at_micros: current_utc_micros(), }) .await .map_err(|error| { custom_world_error_response(&request_context, map_custom_world_client_error(error)) })?; Ok(json_success_body( Some(&request_context), CustomWorldLibraryMutationResponse { entry: map_custom_world_library_entry_response(&state, mutation.entry.clone()), entries: vec![map_custom_world_library_entry_response( &state, mutation.entry, )], }, )) } pub async fn delete_custom_world_library_profile( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, Path(profile_id): Path, ) -> Result, Response> { let owner_user_id = authenticated.claims().user_id().to_string(); if profile_id.trim().is_empty() { return Err(custom_world_error_response( &request_context, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "custom-world-library", "message": "profileId is required", })), )); } let entries = state .spacetime_client() .delete_custom_world_profile(profile_id, owner_user_id, current_utc_micros()) .await .map_err(|error| { custom_world_error_response(&request_context, map_custom_world_client_error(error)) })?; Ok(json_success_body( Some(&request_context), CustomWorldLibraryResponse { entries: entries .into_iter() .map(|entry| map_custom_world_library_entry_response(&state, entry)) .collect(), }, )) } pub async fn publish_custom_world_library_profile( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, Path(profile_id): Path, ) -> Result, Response> { let owner_user_id = authenticated.claims().user_id().to_string(); if profile_id.trim().is_empty() { return Err(custom_world_error_response( &request_context, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "custom-world-library", "message": "profileId is required", })), )); } let author_public_user_code = resolve_author_public_user_code(&state, &authenticated, &request_context)?; let author_display_name = resolve_author_display_name(&state, &authenticated); let mutation = execute_billable_asset_operation( &state, &owner_user_id, "custom_world_publish", &profile_id, async { state .spacetime_client() .publish_custom_world_profile( profile_id.clone(), owner_user_id.clone(), None, author_public_user_code, author_display_name, current_utc_micros(), ) .await .map_err(map_custom_world_client_error) }, ) .await .map_err(|error| custom_world_error_response(&request_context, error))?; Ok(json_success_body( Some(&request_context), CustomWorldLibraryMutationResponse { entry: map_custom_world_library_entry_response(&state, mutation.entry.clone()), entries: vec![map_custom_world_library_entry_response( &state, mutation.entry, )], }, )) } pub async fn unpublish_custom_world_library_profile( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, Path(profile_id): Path, ) -> Result, Response> { let owner_user_id = authenticated.claims().user_id().to_string(); if profile_id.trim().is_empty() { return Err(custom_world_error_response( &request_context, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "custom-world-library", "message": "profileId is required", })), )); } let mutation = state .spacetime_client() .unpublish_custom_world_profile( profile_id, owner_user_id, resolve_author_display_name(&state, &authenticated), current_utc_micros(), ) .await .map_err(|error| { custom_world_error_response(&request_context, map_custom_world_client_error(error)) })?; Ok(json_success_body( Some(&request_context), CustomWorldLibraryMutationResponse { entry: map_custom_world_library_entry_response(&state, mutation.entry.clone()), entries: vec![map_custom_world_library_entry_response( &state, mutation.entry, )], }, )) } pub async fn list_custom_world_gallery( State(state): State, Extension(request_context): Extension, ) -> Result, Response> { let entries = state .spacetime_client() .list_custom_world_gallery_entries() .await .map_err(|error| { custom_world_error_response(&request_context, map_custom_world_client_error(error)) })?; Ok(json_success_body( Some(&request_context), CustomWorldGalleryResponse { entries: entries .into_iter() .map(|entry| map_custom_world_gallery_card_response(&state, entry)) .collect(), }, )) } pub async fn get_custom_world_gallery_detail( State(state): State, Path((owner_user_id, profile_id)): Path<(String, String)>, Extension(request_context): Extension, ) -> Result, Response> { if owner_user_id.trim().is_empty() || profile_id.trim().is_empty() { return Err(custom_world_error_response( &request_context, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "custom-world-gallery", "message": "ownerUserId and profileId are required", })), )); } let detail = state .spacetime_client() .get_custom_world_gallery_detail(owner_user_id, profile_id) .await .map_err(|error| { custom_world_error_response(&request_context, map_custom_world_client_error(error)) })?; Ok(json_success_body( Some(&request_context), CustomWorldGalleryDetailResponse { entry: map_custom_world_library_entry_response(&state, detail.entry), }, )) } pub async fn get_custom_world_gallery_detail_by_code( State(state): State, Path(code): Path, Extension(request_context): Extension, ) -> Result, Response> { if code.trim().is_empty() { return Err(custom_world_error_response( &request_context, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "custom-world-gallery", "message": "code is required", })), )); } let detail = state .spacetime_client() .get_custom_world_gallery_detail_by_code(code) .await .map_err(|error| { custom_world_error_response(&request_context, map_custom_world_client_error(error)) })?; Ok(json_success_body( Some(&request_context), CustomWorldGalleryDetailResponse { entry: map_custom_world_library_entry_response(&state, detail.entry), }, )) } pub async fn remix_custom_world_gallery_profile( State(state): State, Path((owner_user_id, profile_id)): Path<(String, String)>, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, Response> { if owner_user_id.trim().is_empty() || profile_id.trim().is_empty() { return Err(custom_world_error_response( &request_context, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "custom-world-gallery", "message": "ownerUserId and profileId are required", })), )); } let mutation = state .spacetime_client() .remix_custom_world_profile(CustomWorldProfileRemixRecordInput { source_owner_user_id: owner_user_id, source_profile_id: profile_id, target_owner_user_id: authenticated.claims().user_id().to_string(), target_profile_id: build_prefixed_uuid_id("custom-world-profile-"), author_display_name: resolve_author_display_name(&state, &authenticated), remixed_at_micros: current_utc_micros(), }) .await .map_err(|error| { custom_world_error_response(&request_context, map_custom_world_client_error(error)) })?; Ok(json_success_body( Some(&request_context), CustomWorldLibraryMutationResponse { entry: map_custom_world_library_entry_response(&state, mutation.entry.clone()), entries: vec![map_custom_world_library_entry_response( &state, mutation.entry, )], }, )) } pub async fn record_custom_world_gallery_play( State(state): State, Path((owner_user_id, profile_id)): Path<(String, String)>, Extension(request_context): Extension, Extension(_authenticated): Extension, ) -> Result, Response> { if owner_user_id.trim().is_empty() || profile_id.trim().is_empty() { return Err(custom_world_error_response( &request_context, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "custom-world-gallery", "message": "ownerUserId and profileId are required", })), )); } let mutation = state .spacetime_client() .record_custom_world_profile_play(CustomWorldProfilePlayReportRecordInput { owner_user_id, profile_id, played_at_micros: current_utc_micros(), }) .await .map_err(|error| { custom_world_error_response(&request_context, map_custom_world_client_error(error)) })?; Ok(json_success_body( Some(&request_context), CustomWorldGalleryDetailResponse { entry: map_custom_world_library_entry_response(&state, mutation.entry), }, )) } pub async fn record_custom_world_gallery_like( State(state): State, Path((owner_user_id, profile_id)): Path<(String, String)>, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, Response> { if owner_user_id.trim().is_empty() || profile_id.trim().is_empty() { return Err(custom_world_error_response( &request_context, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "custom-world-gallery", "message": "ownerUserId and profileId are required", })), )); } let mutation = state .spacetime_client() .record_custom_world_profile_like(CustomWorldProfileLikeReportRecordInput { owner_user_id, profile_id, user_id: authenticated.claims().user_id().to_string(), liked_at_micros: current_utc_micros(), }) .await .map_err(|error| { custom_world_error_response(&request_context, map_custom_world_client_error(error)) })?; Ok(json_success_body( Some(&request_context), CustomWorldGalleryDetailResponse { entry: map_custom_world_library_entry_response(&state, mutation.entry), }, )) } pub async fn create_custom_world_agent_session( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = payload.map_err(|error| { custom_world_error_response( &request_context, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "custom-world-agent", "message": error.body_text(), })), ) })?; let seed_text = payload.seed_text.unwrap_or_default().trim().to_string(); let welcome_message_text = build_custom_world_agent_welcome_text(&seed_text); let session = state .spacetime_client() .create_custom_world_agent_session(CustomWorldAgentSessionCreateRecordInput { session_id: build_prefixed_uuid_id("custom-world-agent-session-"), owner_user_id: authenticated.claims().user_id().to_string(), seed_text, welcome_message_id: build_prefixed_uuid_id("message-"), welcome_message_text, anchor_content_json: empty_agent_anchor_content_json(), creator_intent_json: Some(empty_json_object()), creator_intent_readiness_json: empty_agent_creator_intent_readiness_json(), anchor_pack_json: Some(empty_json_object()), lock_state_json: Some(empty_json_object()), draft_profile_json: Some(empty_json_object()), pending_clarifications_json: empty_json_array(), suggested_actions_json: empty_json_array(), recommended_replies_json: empty_json_array(), quality_findings_json: empty_json_array(), asset_coverage_json: empty_agent_asset_coverage_json(), checkpoints_json: empty_json_array(), created_at_micros: current_utc_micros(), }) .await .map_err(|error| { custom_world_error_response(&request_context, map_custom_world_client_error(error)) })?; Ok(json_success_body( Some(&request_context), CustomWorldAgentSessionResponse { session: map_custom_world_agent_session_response(session), }, )) } pub async fn get_custom_world_agent_session( State(state): State, Path(session_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, Response> { if session_id.trim().is_empty() { return Err(custom_world_error_response( &request_context, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "custom-world-agent", "message": "sessionId is required", })), )); } let session = state .spacetime_client() .get_custom_world_agent_session(session_id, authenticated.claims().user_id().to_string()) .await .map_err(|error| { custom_world_error_response(&request_context, map_custom_world_client_error(error)) })?; log_custom_world_publish_gate_diagnostics("get_session", &session); Ok(json_success_body( Some(&request_context), map_custom_world_agent_session_response(session), )) } pub async fn get_custom_world_agent_result_view( State(state): State, Path(session_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, Response> { ensure_non_empty(&request_context, &session_id, "sessionId")?; let session = state .spacetime_client() .get_custom_world_agent_session(session_id, authenticated.claims().user_id().to_string()) .await .map_err(|error| { custom_world_error_response(&request_context, map_custom_world_client_error(error)) })?; log_custom_world_publish_gate_diagnostics("get_result_view", &session); let result_view = build_custom_world_creation_result_view_response(session); Ok(json_success_body(Some(&request_context), result_view)) } pub async fn get_custom_world_works( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, Response> { let items = state .spacetime_client() .list_custom_world_works(authenticated.claims().user_id().to_string()) .await .map_err(|error| { custom_world_error_response(&request_context, map_custom_world_client_error(error)) })?; Ok(json_success_body( Some(&request_context), CustomWorldWorksResponse { items: items .into_iter() .map(map_custom_world_work_summary_response) .collect(), }, )) } pub async fn delete_custom_world_agent_session( State(state): State, Path(session_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, Response> { ensure_non_empty(&request_context, &session_id, "sessionId")?; let items = state .spacetime_client() .delete_custom_world_agent_session(session_id, authenticated.claims().user_id().to_string()) .await .map_err(|error| { custom_world_error_response(&request_context, map_custom_world_client_error(error)) })?; Ok(json_success_body( Some(&request_context), CustomWorldWorksResponse { items: items .into_iter() .map(map_custom_world_work_summary_response) .collect(), }, )) } pub async fn get_custom_world_agent_card_detail( State(state): State, Path((session_id, card_id)): Path<(String, String)>, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, Response> { if session_id.trim().is_empty() || card_id.trim().is_empty() { return Err(custom_world_error_response( &request_context, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "custom-world-agent", "message": "sessionId and cardId are required", })), )); } let card = state .spacetime_client() .get_custom_world_agent_card_detail( session_id, authenticated.claims().user_id().to_string(), card_id, ) .await .map_err(|error| { custom_world_error_response(&request_context, map_custom_world_client_error(error)) })?; Ok(json_success_body( Some(&request_context), CustomWorldAgentCardDetailResponse { card: map_custom_world_draft_card_detail_response(card), }, )) } pub async fn submit_custom_world_agent_message( State(state): State, Path(session_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = payload.map_err(|error| { custom_world_error_response( &request_context, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "custom-world-agent", "message": error.body_text(), })), ) })?; if session_id.trim().is_empty() { return Err(custom_world_error_response( &request_context, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "custom-world-agent", "message": "sessionId is required", })), )); } 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(custom_world_error_response( &request_context, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "custom-world-agent", "message": "clientMessageId and text are required", })), )); } let owner_user_id = authenticated.claims().user_id().to_string(); let operation_id = build_prefixed_uuid_id("operation-"); let submitted_at_micros = current_utc_micros(); let operation = state .spacetime_client() .submit_custom_world_agent_message(CustomWorldAgentMessageSubmitRecordInput { session_id: session_id.clone(), owner_user_id: owner_user_id.clone(), user_message_id: client_message_id, user_message_text: message_text, operation_id: operation_id.clone(), submitted_at_micros, }) .await .map_err(|error| { custom_world_error_response(&request_context, map_custom_world_client_error(error)) })?; let session = state .spacetime_client() .get_custom_world_agent_session(session_id.clone(), owner_user_id.clone()) .await .map_err(|error| { custom_world_error_response(&request_context, map_custom_world_client_error(error)) })?; let mut draft_writer = AiGenerationDraftWriter::new(AiGenerationDraftContext::new( "custom_world", owner_user_id.as_str(), session_id.as_str(), operation_id.as_str(), "自定义世界模板生成草稿", )); if let Err(error) = draft_writer.ensure_started(state.spacetime_client()).await { tracing::warn!(error = %error, "自定义世界模板生成草稿任务启动失败,主生成流程继续执行"); } let draft_sink = AiGenerationDraftSink::new( AiGenerationDraftContext::new( "custom_world", owner_user_id.as_str(), session_id.as_str(), operation_id.as_str(), "自定义世界模板生成草稿", ), state.spacetime_client().clone(), ); let turn_result = run_custom_world_agent_turn( CustomWorldAgentTurnRequest { llm_client: state.llm_client(), session: &session, quick_fill_requested: payload.quick_fill_requested.unwrap_or(false), focus_card_id: payload.focus_card_id.clone(), enable_web_search: state.config.creation_agent_llm_web_search_enabled, }, move |text| { draft_sink.persist_visible_text_async(text); }, ) .await; if let Ok(result) = &turn_result { draft_writer .persist_visible_text( state.spacetime_client(), result.assistant_reply_text.as_str(), ) .await; } let finalize_input = match turn_result { Ok(turn_result) => build_finalize_record_input( session_id.clone(), owner_user_id.clone(), operation_id.clone(), format!("assistant-{}", operation.operation_id), turn_result, current_utc_micros(), ), Err(error) => build_failed_finalize_record_input( session_id.clone(), owner_user_id.clone(), operation_id.clone(), &session, error.to_string(), current_utc_micros(), ), }; let finalized_operation = state .spacetime_client() .finalize_custom_world_agent_message(finalize_input) .await .map_err(|error| { custom_world_error_response(&request_context, map_custom_world_client_error(error)) })?; if finalized_operation.status == "failed" { let message = finalized_operation .error_message .clone() .unwrap_or_else(|| "消息处理失败".to_string()); return Err(custom_world_error_response( &request_context, AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "custom-world-agent", "message": message, "operationId": finalized_operation.operation_id, })), )); } Ok(json_success_body( Some(&request_context), json!({ "operation": map_custom_world_agent_operation_response(finalized_operation), }), )) } pub async fn stream_custom_world_agent_message( State(state): State, Path(session_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result { let Json(payload) = payload.map_err(|error| { custom_world_error_response( &request_context, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "custom-world-agent", "message": error.body_text(), })), ) })?; if session_id.trim().is_empty() { return Err(custom_world_error_response( &request_context, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "custom-world-agent", "message": "sessionId is required", })), )); } 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(custom_world_error_response( &request_context, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "custom-world-agent", "message": "clientMessageId and text are required", })), )); } let owner_user_id = authenticated.claims().user_id().to_string(); let operation = state .spacetime_client() .submit_custom_world_agent_message(CustomWorldAgentMessageSubmitRecordInput { session_id: session_id.clone(), owner_user_id: owner_user_id.clone(), user_message_id: client_message_id, user_message_text: message_text, operation_id: build_prefixed_uuid_id("operation-"), submitted_at_micros: current_utc_micros(), }) .await .map_err(|error| { custom_world_error_response(&request_context, map_custom_world_client_error(error)) })?; let session = state .spacetime_client() .get_custom_world_agent_session(session_id.clone(), owner_user_id.clone()) .await .map_err(|error| { custom_world_error_response(&request_context, map_custom_world_client_error(error)) })?; let quick_fill_requested = payload.quick_fill_requested.unwrap_or(false); let focus_card_id = payload.focus_card_id.clone(); let state = state.clone(); let session_id_for_stream = session_id.clone(); let owner_user_id_for_stream = owner_user_id.clone(); let operation_id = operation.operation_id.clone(); let stream = async_stream::stream! { let mut draft_writer = AiGenerationDraftWriter::new(AiGenerationDraftContext::new( "custom_world", owner_user_id_for_stream.as_str(), session_id_for_stream.as_str(), operation_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::(); // Agent turn 仍负责完整 JSON 解析和最终写回;这里把 replyText 增量桥接成前端可见的 SSE 分片。 let turn_result = { let run_turn = run_custom_world_agent_turn( CustomWorldAgentTurnRequest { llm_client: state.llm_client(), session: &session, quick_fill_requested, focus_card_id, enable_web_search: state.config.creation_agent_llm_web_search_enabled, }, move |text| { let _ = reply_tx.send(text.to_string()); }, ); tokio::pin!(run_turn); loop { // 不等待最终 session 落库即可先推送回复进度;session/done 仍在 finalize 成功后发送。 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::(custom_world_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::(custom_world_sse_json_event_or_error( "reply_delta", json!({ "text": text }), )); } let finalize_input = match turn_result { Ok(turn_result) => build_finalize_record_input( session_id_for_stream.clone(), owner_user_id_for_stream.clone(), operation_id.clone(), format!("assistant-{operation_id}"), turn_result, current_utc_micros(), ), Err(error) => build_failed_finalize_record_input( session_id_for_stream.clone(), owner_user_id_for_stream.clone(), operation_id.clone(), &session, error.to_string(), current_utc_micros(), ), }; let finalize_result = state .spacetime_client() .finalize_custom_world_agent_message(finalize_input) .await; let _finalized_operation = match finalize_result { Ok(operation) => operation, Err(error) => { yield Ok::(custom_world_sse_json_event_or_error( "error", json!({ "message": error.to_string() }), )); return; } }; if _finalized_operation.status == "failed" { yield Ok::(custom_world_sse_json_event_or_error( "error", json!({ "message": _finalized_operation .error_message .clone() .unwrap_or_else(|| "消息处理失败".to_string()) }), )); return; } let final_session = match state .spacetime_client() .get_custom_world_agent_session( session_id_for_stream, owner_user_id_for_stream, ) .await { Ok(session) => session, Err(error) => { yield Ok::(custom_world_sse_json_event_or_error( "error", json!({ "message": error.to_string() }), )); return; } }; let session_response = map_custom_world_agent_session_response(final_session); yield Ok::(custom_world_sse_json_event_or_error( "session", json!({ "session": session_response }), )); yield Ok::(custom_world_sse_json_event_or_error( "done", json!({ "ok": true }), )); }; Ok(Sse::new(stream).into_response()) } pub async fn get_custom_world_agent_operation( State(state): State, Path((session_id, operation_id)): Path<(String, String)>, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, Response> { if session_id.trim().is_empty() || operation_id.trim().is_empty() { return Err(custom_world_error_response( &request_context, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "custom-world-agent", "message": "sessionId and operationId are required", })), )); } let operation = state .spacetime_client() .get_custom_world_agent_operation( session_id, authenticated.claims().user_id().to_string(), operation_id, ) .await .map_err(|error| { custom_world_error_response(&request_context, map_custom_world_client_error(error)) })?; Ok(json_success_body( Some(&request_context), map_custom_world_agent_operation_response(operation), )) } pub async fn execute_custom_world_agent_action( State(state): State, Path(session_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = payload.map_err(|error| { custom_world_error_response( &request_context, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "custom-world-agent", "message": error.body_text(), })), ) })?; if session_id.trim().is_empty() { return Err(custom_world_error_response( &request_context, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "custom-world-agent", "message": "sessionId is required", })), )); } let action = payload.action.trim().to_string(); if action.is_empty() { return Err(custom_world_error_response( &request_context, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "custom-world-agent", "message": "action is required", })), )); } let owner_user_id = authenticated.claims().user_id().to_string(); let submitted_at_micros = current_utc_micros(); let payload_json = if matches!( action.as_str(), "draft_foundation" | "generate_characters" | "generate_landmarks" ) { let session = state .spacetime_client() .get_custom_world_agent_session(session_id.clone(), owner_user_id.clone()) .await .map_err(|error| { custom_world_error_response(&request_context, map_custom_world_client_error(error)) })?; let llm_client = state.llm_client().ok_or_else(|| { custom_world_error_response( &request_context, AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ "provider": "custom-world-agent", "message": "服务端尚未配置可用的 LLM API Key", })), ) })?; if action == "draft_foundation" { if session.progress_percent < 100 { return Err(custom_world_error_response( &request_context, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "custom-world-agent", "message": "draft_foundation requires progressPercent >= 100", })), )); } let operation_id = build_prefixed_uuid_id("operation-"); let operation = state .spacetime_client() .upsert_custom_world_agent_operation_progress( CustomWorldAgentOperationProgressRecordInput { session_id: session_id.clone(), owner_user_id: owner_user_id.clone(), operation_id: operation_id.clone(), operation_type: "draft_foundation".to_string(), operation_status: "running".to_string(), phase_label: "整理世界骨架".to_string(), phase_detail: "正在校验已确认锚点,并准备第一版世界框架生成链路。" .to_string(), operation_progress: 12, error_message: None, updated_at_micros: submitted_at_micros, }, ) .await .map_err(|error| { custom_world_error_response( &request_context, map_custom_world_client_error(error), ) })?; spawn_custom_world_draft_foundation_job( state.clone(), session, owner_user_id, operation_id, payload, ); return Ok(json_success_body( Some(&request_context), json!({ "operation": map_custom_world_agent_operation_response(operation), }), )); } else { let generation_result = generate_custom_world_agent_entities(llm_client, &session, &payload) .await .map_err(|message| { custom_world_error_response( &request_context, AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "custom-world-agent", "message": message, })), ) })?; generation_result.payload_json } } else if action == "sync_result_profile" { serialize_sync_result_profile_action_payload(&payload).map_err(|error| { custom_world_error_response( &request_context, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "custom-world-agent", "message": error, })), ) })? } else if action == "publish_world" { let mut publish_payload = serde_json::to_value(&payload).map_err(|error| { custom_world_error_response( &request_context, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "custom-world-agent", "message": format!("action payload JSON 序列化失败:{error}"), })), ) })?; if let Some(object) = publish_payload.as_object_mut() { // 发布到广场时必须写入真实作者公开信息,避免 gallery 投影落成匿名兜底数据。 object.insert( "authorPublicUserCode".to_string(), Value::String(resolve_author_public_user_code( &state, &authenticated, &request_context, )?), ); object.insert( "authorDisplayName".to_string(), Value::String(resolve_author_display_name(&state, &authenticated)), ); } serde_json::to_string(&publish_payload).map_err(|error| { custom_world_error_response( &request_context, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "custom-world-agent", "message": format!("action payload JSON 序列化失败:{error}"), })), ) })? } else { serde_json::to_string(&payload).map_err(|error| { custom_world_error_response( &request_context, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "custom-world-agent", "message": format!("action payload JSON 序列化失败:{error}"), })), ) })? }; let should_bill_publish = action == "publish_world"; let operation_future = async { state .spacetime_client() .execute_custom_world_agent_action(CustomWorldAgentActionExecuteRecordInput { session_id: session_id.clone(), owner_user_id: owner_user_id.clone(), operation_id: build_prefixed_uuid_id("operation-"), action: action.clone(), payload_json: Some(payload_json), submitted_at_micros, }) .await .map_err(map_custom_world_client_error) }; let result = if should_bill_publish { execute_billable_asset_operation( &state, &owner_user_id, "custom_world_agent_publish", &session_id, operation_future, ) .await } else { operation_future.await }; let result = result.map_err(|error| custom_world_error_response(&request_context, error))?; if matches!( action.as_str(), "sync_result_profile" | "publish_world" | "draft_foundation" ) { if let Ok(session) = state .spacetime_client() .get_custom_world_agent_session(session_id.clone(), owner_user_id.clone()) .await { log_custom_world_publish_gate_diagnostics(action.as_str(), &session); } } Ok(json_success_body( Some(&request_context), json!({ "operation": map_custom_world_agent_operation_response(result.operation), }), )) } fn serialize_sync_result_profile_action_payload( payload: &ExecuteCustomWorldAgentActionRequest, ) -> Result { let mut payload_value = serde_json::to_value(payload) .map_err(|error| format!("action payload JSON 序列化失败:{error}"))?; if let Some(profile) = payload_value.get_mut("profile") { canonicalize_custom_world_profile_before_save(profile); } serde_json::to_string(&payload_value) .map_err(|error| format!("action payload JSON 序列化失败:{error}")) } fn canonicalize_custom_world_library_profile_payload( mut profile: Value, ) -> Result<(Value, CustomWorldProfileMetadata), String> { canonicalize_custom_world_profile_before_save(&mut profile); let metadata = extract_custom_world_metadata(&profile)?; Ok((profile, metadata)) } fn spawn_custom_world_draft_foundation_job( state: AppState, session: CustomWorldAgentSessionRecord, owner_user_id: String, operation_id: String, payload: ExecuteCustomWorldAgentActionRequest, ) { tokio::spawn(async move { let Some(llm_client) = state.llm_client().cloned() else { let _ = upsert_custom_world_draft_foundation_progress( &state, &session.session_id, &owner_user_id, &operation_id, "failed", "底稿生成失败", "服务端尚未配置可用的 LLM API Key", DRAFT_FOUNDATION_PROGRESS_FRAMEWORK_START, Some("服务端尚未配置可用的 LLM API Key".to_string()), ) .await; return; }; let progress_state = state.clone(); let progress_session_id = session.session_id.clone(); let progress_owner_user_id = owner_user_id.clone(); let progress_operation_id = operation_id.clone(); let existing_draft_profile = reusable_draft_profile_for_asset_generation(&session); let draft_result = if let Some(profile) = existing_draft_profile { // 失败后“继续生成草稿”复用已经写入 session 的底稿, // 只继续执行素材补齐、草稿卡编译和结果页写回。 let _ = upsert_custom_world_draft_foundation_progress( &state, &session.session_id, &owner_user_id, &operation_id, "running", "继续生成草稿", "已读取上次保存的世界底稿,正在继续补齐素材与结果页。", DRAFT_FOUNDATION_PROGRESS_ASSET_START, None, ) .await; match serde_json::to_string(&profile) { Ok(draft_profile_json) => Ok( crate::custom_world_foundation_draft::CustomWorldFoundationDraftResult { draft_profile_json, }, ), Err(error) => Err(format!("已保存底稿序列化失败:{error}")), } } else { generate_custom_world_foundation_draft(&llm_client, &session, move |progress| { let progress_state = progress_state.clone(); let session_id = progress_session_id.clone(); let owner_user_id = progress_owner_user_id.clone(); let operation_id = progress_operation_id.clone(); tokio::spawn(async move { let _ = upsert_custom_world_draft_foundation_progress( &progress_state, &session_id, &owner_user_id, &operation_id, "running", progress.phase_label.as_str(), progress.phase_detail.as_str(), progress.progress, None, ) .await; }); }) .await }; let draft_result = match draft_result { Ok(result) => result, Err(message) => { let _ = upsert_custom_world_draft_foundation_progress( &state, &session.session_id, &owner_user_id, &operation_id, "failed", "底稿生成失败", message.clone().as_str(), fallback_draft_foundation_failure_progress("底稿生成失败"), Some(message.clone()), ) .await; return; } }; let mut draft_profile_json = draft_result.draft_profile_json; let mut draft_profile_value = match serde_json::from_str::(&draft_profile_json) { Ok(Value::Object(object)) => Value::Object(object), Ok(_) => { let message = "foundation draft JSON 必须是 object".to_string(); let _ = upsert_custom_world_draft_foundation_progress( &state, &session.session_id, &owner_user_id, &operation_id, "failed", "底稿素材生成失败", message.as_str(), fallback_draft_foundation_failure_progress("底稿素材生成失败"), Some(message.clone()), ) .await; return; } Err(error) => { let message = format!("foundation draft JSON 非法:{error}"); let _ = upsert_custom_world_draft_foundation_progress( &state, &session.session_id, &owner_user_id, &operation_id, "failed", "底稿素材生成失败", message.as_str(), fallback_draft_foundation_failure_progress("底稿素材生成失败"), Some(message.clone()), ) .await; return; } }; let image_generation_limiter = Arc::new(Semaphore::new( state.config.draft_asset_generation_max_concurrent_requests, )); let role_visual_profile_input = draft_profile_value.clone(); let act_background_profile_input = draft_profile_value.clone(); // 角色主形象与幕背景图互不依赖,必须并行发起;上游生图请求统一限流,避免同批草稿瞬时打满供应商接口。 let (role_visual_result, act_background_result) = tokio::join!( async { let mut profile = role_visual_profile_input; generate_draft_foundation_role_visuals( &state, &session, &owner_user_id, &operation_id, &mut profile, image_generation_limiter.clone(), ) .await .map(|_| profile) }, async { let mut profile = act_background_profile_input; generate_draft_foundation_act_backgrounds( &state, &session, &owner_user_id, &operation_id, &mut profile, image_generation_limiter.clone(), ) .await .map(|_| profile) } ); let mut draft_profile_with_assets = draft_profile_value.clone(); let mut asset_generation_errors = Vec::new(); match role_visual_result { Ok(profile) => draft_profile_with_assets = profile, Err(message) => asset_generation_errors.push(("生成角色主形象失败", message)), } match act_background_result { Ok(profile) => { merge_generated_act_backgrounds(&mut draft_profile_with_assets, &profile) } Err(message) => asset_generation_errors.push(("生成幕背景图失败", message)), } draft_profile_value = draft_profile_with_assets; if !asset_generation_errors.is_empty() { let message = asset_generation_errors .iter() .map(|(_, message)| message.as_str()) .collect::>() .join(";"); let phase_label = asset_generation_errors .first() .map(|(label, _)| *label) .unwrap_or("素材生成失败"); persist_partial_draft_foundation_after_asset_failure( &state, &session, &owner_user_id, &operation_id, &draft_profile_value, phase_label, message.as_str(), ) .await; return; } draft_profile_json = match serde_json::to_string(&draft_profile_value) { Ok(value) => value, Err(error) => { let message = format!("带素材的 foundation draft JSON 序列化失败:{error}"); let _ = upsert_custom_world_draft_foundation_progress( &state, &session.session_id, &owner_user_id, &operation_id, "failed", "底稿素材写回失败", message.as_str(), fallback_draft_foundation_failure_progress("底稿素材写回失败"), Some(message.clone()), ) .await; return; } }; let _ = upsert_custom_world_draft_foundation_progress( &state, &session.session_id, &owner_user_id, &operation_id, "running", "编译草稿卡", "正在把世界底稿整理成可浏览的卡片摘要和详情结构。", 98, None, ) .await; let payload_json = match build_draft_foundation_action_payload_json(&payload, &draft_profile_json) { Ok(value) => value, Err(error) => { let message = match error { DraftFoundationPayloadError::SerializePayload(message) => message, DraftFoundationPayloadError::InvalidPayloadShape => { "action payload 必须是 object".to_string() } DraftFoundationPayloadError::InvalidGeneratedDraft(message) => message, }; let _ = upsert_custom_world_draft_foundation_progress( &state, &session.session_id, &owner_user_id, &operation_id, "failed", "底稿写入失败", message.clone().as_str(), fallback_draft_foundation_failure_progress("底稿写入失败"), Some(message), ) .await; return; } }; if let Err(error) = state .spacetime_client() .execute_custom_world_agent_action(CustomWorldAgentActionExecuteRecordInput { session_id: session.session_id.clone(), owner_user_id: owner_user_id.clone(), operation_id: operation_id.clone(), action: "draft_foundation".to_string(), payload_json: Some(payload_json), submitted_at_micros: current_utc_micros(), }) .await { let message = error.to_string(); let _ = upsert_custom_world_draft_foundation_progress( &state, &session.session_id, &owner_user_id, &operation_id, "failed", "底稿写入失败", message.clone().as_str(), fallback_draft_foundation_failure_progress("底稿写入失败"), Some(message), ) .await; } }); } async fn generate_draft_foundation_role_visuals( state: &AppState, session: &CustomWorldAgentSessionRecord, owner_user_id: &str, operation_id: &str, draft_profile: &mut Value, image_generation_limiter: Arc, ) -> Result<(), String> { let Some(profile_object) = draft_profile.as_object_mut() else { return Err("foundation draft JSON 必须是 object".to_string()); }; let mut role_refs = Vec::new(); for key in ["playableNpcs", "storyNpcs"] { if let Some(roles) = profile_object.get(key).and_then(Value::as_array) { for index in 0..roles.len() { role_refs.push((key.to_string(), index)); } } } let mut role_generation_refs = Vec::new(); for (key, index) in role_refs { let role = profile_object .get(key.as_str()) .and_then(Value::as_array) .and_then(|roles| roles.get(index)) .cloned() .unwrap_or(Value::Null); let name = json_text_from_value(&role, "name").unwrap_or_else(|| format!("角色{}", index + 1)); let role_id = json_text_from_value(&role, "id").unwrap_or_else(|| format!("{key}-{index}")); let visual_prompt = json_text_from_value(&role, "visualDescription").ok_or_else(|| { format!("角色「{name}」缺少 visualDescription,不能在角色形象设定文本生成前直接生图。") })?; role_generation_refs.push(RoleVisualGenerationRef { key, index, role_id, name, prompt: visual_prompt, }); } upsert_custom_world_draft_foundation_progress( state, &session.session_id, owner_user_id, operation_id, "running", "并行生成角色主形象", format!("正在同时生成 {} 张角色主形象。", role_generation_refs.len()).as_str(), 97, None, ) .await .map_err(|error| error.to_string())?; let mut generation_tasks = JoinSet::new(); for role_ref in role_generation_refs { let task_state = (*state).clone(); let task_owner_user_id = owner_user_id.to_string(); let task_limiter = image_generation_limiter.clone(); generation_tasks.spawn(async move { let mut last_error = None; for attempt in 1..=DRAFT_ASSET_GENERATION_MAX_ATTEMPTS { let generation_result = { let _permit = task_limiter .acquire() .await .map_err(|error| format!("图片生成并发控制失效:{error}"))?; generate_character_primary_visual_for_profile( &task_state, task_owner_user_id.as_str(), role_ref.role_id.as_str(), role_ref.prompt.as_str(), ) .await }; match generation_result { Ok(generated) => { return Ok::<_, String>((role_ref.key, role_ref.index, generated)); } Err(error) => { last_error = Some(error.body_text()); if attempt < DRAFT_ASSET_GENERATION_MAX_ATTEMPTS { tokio::time::sleep(std::time::Duration::from_millis( 300 * u64::from(attempt), )) .await; } } } } Err(format!( "角色「{}」主形象连续生成 {} 次失败:{}", role_ref.name, DRAFT_ASSET_GENERATION_MAX_ATTEMPTS, last_error.unwrap_or_else(|| "未知错误".to_string()) )) }); } let mut errors = Vec::new(); while let Some(result) = generation_tasks.join_next().await { let task_result = result.map_err(|error| error.to_string())?; let (key, index, generated) = match task_result { Ok(value) => value, Err(message) => { errors.push(message); continue; } }; if let Some(role_object) = profile_object .get_mut(key.as_str()) .and_then(Value::as_array_mut) .and_then(|roles| roles.get_mut(index)) .and_then(Value::as_object_mut) { role_object.insert("imageSrc".to_string(), Value::String(generated.image_src)); role_object.insert( "generatedVisualAssetId".to_string(), Value::String(generated.asset_id), ); } } if !errors.is_empty() { return Err(join_unique_error_messages(errors)); } Ok(()) } async fn generate_draft_foundation_act_backgrounds( state: &AppState, session: &CustomWorldAgentSessionRecord, owner_user_id: &str, operation_id: &str, draft_profile: &mut Value, image_generation_limiter: Arc, ) -> Result<(), String> { let world_name = json_text_from_value(draft_profile, "name").unwrap_or_else(|| "未命名世界".to_string()); let profile_id = json_text_from_value(draft_profile, "id"); let scene_image_profile_input = draft_profile.clone(); let act_refs = collect_scene_act_refs(draft_profile); validate_scene_act_background_prompts(&act_refs)?; tracing::info!( operation_id, session_id = %session.session_id, act_count = act_refs.len(), max_concurrent_requests = state.config.draft_asset_generation_max_concurrent_requests, "开始并行生成草稿幕背景图" ); upsert_custom_world_draft_foundation_progress( state, &session.session_id, owner_user_id, operation_id, "running", "并行生成幕背景图", format!("正在同时生成 {} 张幕背景图。", act_refs.len()).as_str(), 98, None, ) .await .map_err(|error| error.to_string())?; let mut generation_tasks = JoinSet::new(); for act_ref in act_refs { let task_state = (*state).clone(); let task_owner_user_id = owner_user_id.to_string(); let task_profile_id = profile_id.clone(); let task_world_name = world_name.clone(); let task_profile = scene_image_profile_input.clone(); let task_limiter = image_generation_limiter.clone(); let task_operation_id = operation_id.to_string(); let task_session_id = session.session_id.clone(); generation_tasks.spawn(async move { let mut last_error = None; for attempt in 1..=DRAFT_ASSET_GENERATION_MAX_ATTEMPTS { let attempt_started_at = Instant::now(); tracing::info!( operation_id = %task_operation_id, session_id = %task_session_id, chapter_index = act_ref.chapter_index, act_index = act_ref.act_index, scene_id = %act_ref.scene_id, scene_name = %act_ref.scene_name, attempt, "开始生成单幕背景图" ); let generation_result = { let _permit = task_limiter.acquire().await.map_err(|error| { ( act_ref.chapter_index, act_ref.act_index, format!("图片生成并发控制失效:{error}"), ) })?; generate_custom_world_scene_image_for_profile( &task_state, task_owner_user_id.as_str(), &task_profile, task_profile_id.as_deref(), task_world_name.as_str(), act_ref.scene_id.as_str(), act_ref.scene_name.as_str(), act_ref.scene_description.as_str(), act_ref.scene_image_prompt.as_str(), ) .await }; match generation_result { Ok(generated) => { tracing::info!( operation_id = %task_operation_id, session_id = %task_session_id, chapter_index = act_ref.chapter_index, act_index = act_ref.act_index, scene_id = %act_ref.scene_id, scene_name = %act_ref.scene_name, attempt, elapsed_ms = attempt_started_at.elapsed().as_millis(), "单幕背景图生成成功" ); return Ok::<_, (usize, usize, String)>(( act_ref.chapter_index, act_ref.act_index, generated, )); } Err(error) => { let error_message = error.body_text(); tracing::warn!( operation_id = %task_operation_id, session_id = %task_session_id, chapter_index = act_ref.chapter_index, act_index = act_ref.act_index, scene_id = %act_ref.scene_id, scene_name = %act_ref.scene_name, attempt, elapsed_ms = attempt_started_at.elapsed().as_millis(), error_message = %error_message, "单幕背景图生成失败" ); last_error = Some(error_message); if attempt < DRAFT_ASSET_GENERATION_MAX_ATTEMPTS { tokio::time::sleep(std::time::Duration::from_millis( 300 * u64::from(attempt), )) .await; } } } } Err(( act_ref.chapter_index, act_ref.act_index, format!( "第{}章第{}幕「{}」背景图连续生成 {} 次失败:{}", act_ref.chapter_index + 1, act_ref.act_index + 1, act_ref.scene_name, DRAFT_ASSET_GENERATION_MAX_ATTEMPTS, last_error.unwrap_or_else(|| "未知错误".to_string()) ), )) }); } let mut errors = Vec::new(); let mut generated_count = 0usize; while let Some(result) = generation_tasks.join_next().await { let task_result = result.map_err(|error| error.to_string())?; let (chapter_index, act_index, generated) = match task_result { Ok(value) => value, Err((chapter_index, act_index, message)) => { mark_scene_act_background_generation_error( draft_profile, chapter_index, act_index, &message, ); errors.push(message); continue; } }; if let Some(act_object) = draft_profile .get_mut("sceneChapterBlueprints") .and_then(Value::as_array_mut) .and_then(|chapters| chapters.get_mut(chapter_index)) .and_then(|chapter| chapter.get_mut("acts")) .and_then(Value::as_array_mut) .and_then(|acts| acts.get_mut(act_index)) .and_then(Value::as_object_mut) { act_object.insert( "backgroundImageSrc".to_string(), Value::String(generated.image_src), ); act_object.insert( "backgroundAssetId".to_string(), Value::String(generated.asset_id), ); act_object.insert( "generatedScenePrompt".to_string(), Value::String(generated.prompt), ); act_object.insert( "generatedSceneModel".to_string(), Value::String(generated.model), ); generated_count += 1; } } if !errors.is_empty() { if generated_count > 0 { // 自动草稿生成和手动生成用的是同一套生图与资产入库能力;这里不能因为批量中的个别幕失败, // 把已经写入 profile 分支的 backgroundImageSrc 一起丢掉,否则前端就看不到已经生成好的图。 tracing::warn!( generated_count, failed_count = errors.len(), error_message = %join_unique_error_messages(errors), "部分幕背景图生成失败,已保留成功生成的幕图" ); return Ok(()); } return Err(join_unique_error_messages(errors)); } Ok(()) } fn mark_scene_act_background_generation_error( draft_profile: &mut Value, chapter_index: usize, act_index: usize, message: &str, ) { if let Some(act_object) = draft_profile .get_mut("sceneChapterBlueprints") .and_then(Value::as_array_mut) .and_then(|chapters| chapters.get_mut(chapter_index)) .and_then(|chapter| chapter.get_mut("acts")) .and_then(Value::as_array_mut) .and_then(|acts| acts.get_mut(act_index)) .and_then(Value::as_object_mut) { act_object.insert( "backgroundGenerationError".to_string(), Value::String(message.trim().to_string()), ); } } fn join_unique_error_messages(messages: Vec) -> String { // 并行图片任务可能从同一个上游故障返回完全相同的业务错误;用户侧只需要看到去重后的失败项。 messages .into_iter() .map(|message| message.trim().to_string()) .filter(|message| !message.is_empty()) .collect::>() .into_iter() .collect::>() .join(";") } struct RoleVisualGenerationRef { key: String, index: usize, role_id: String, name: String, prompt: String, } struct SceneActGenerationRef { chapter_index: usize, act_index: usize, scene_id: String, scene_name: String, scene_description: String, prompt: String, scene_image_prompt: String, } fn collect_scene_act_refs(draft_profile: &Value) -> Vec { let world_name = json_text_from_value(draft_profile, "name").unwrap_or_else(|| "未命名世界".to_string()); let world_tone = json_text_from_value(draft_profile, "tone").unwrap_or_default(); let scene_context_by_id = collect_scene_context_by_id(draft_profile); draft_profile .get("sceneChapterBlueprints") .and_then(Value::as_array) .into_iter() .flatten() .enumerate() .flat_map(|(chapter_index, chapter)| { let chapter_scene_id = json_text_from_value(chapter, "sceneId") .or_else(|| json_text_from_value(chapter, "id")) .unwrap_or_else(|| format!("chapter-{chapter_index}")); let chapter_scene_name = json_first_text_from_value( chapter, &["sceneName", "landmarkName", "name", "title"], ) .unwrap_or_else(|| chapter_scene_id.clone()); let chapter_scene_context = scene_context_by_id .get(&chapter_scene_id) .cloned() .unwrap_or_else(|| SceneImageContext { id: chapter_scene_id.clone(), name: chapter_scene_name.clone(), description: json_text_from_value(chapter, "description") .or_else(|| json_text_from_value(chapter, "summary")) .unwrap_or_default(), }); let scene_contexts = scene_context_by_id.clone(); let world_name = world_name.clone(); let world_tone = world_tone.clone(); chapter .get("acts") .and_then(Value::as_array) .into_iter() .flatten() .enumerate() .map(move |(act_index, act)| { let prompt = json_first_text_from_value( act, &[ "backgroundPromptText", "scenePromptText", "visualPromptText", "promptText", "imagePromptText", "backgroundPrompt", "visualPrompt", ], ) .unwrap_or_default(); let scene_name = json_first_text_from_value( act, &["sceneName", "landmarkName", "locationName"], ) .unwrap_or_else(|| chapter_scene_context.name.clone()); let act_scene_id = json_text_from_value(act, "sceneId") .unwrap_or_else(|| chapter_scene_context.id.clone()); let scene_context = scene_contexts .get(&act_scene_id) .cloned() .unwrap_or_else(|| SceneImageContext { id: act_scene_id.clone(), name: scene_name, description: chapter_scene_context.description.clone(), }); let title = json_text_from_value(act, "title") .unwrap_or_else(|| format!("第{}幕", act_index + 1)); let summary = json_text_from_value(act, "summary").unwrap_or_default(); let act_goal = json_text_from_value(act, "actGoal").unwrap_or_default(); let transition_hook = json_text_from_value(act, "transitionHook").unwrap_or_default(); let primary_role_name = json_first_text_from_value( act, &["primaryRoleName", "primaryRole", "mainRoleName"], ) .unwrap_or_default(); let scene_image_prompt = build_scene_act_background_image_prompt(SceneActBackgroundPromptParams { world_name: world_name.as_str(), world_tone: world_tone.as_str(), scene_name: scene_context.name.as_str(), title: title.as_str(), summary: summary.as_str(), act_goal: act_goal.as_str(), transition_hook: transition_hook.as_str(), primary_role_name: primary_role_name.as_str(), support_role_names: collect_scene_act_support_role_names(act), prompt_text: prompt.as_str(), }); SceneActGenerationRef { chapter_index, act_index, scene_id: act_scene_id, scene_name: scene_context.name, scene_description: scene_context.description, prompt, scene_image_prompt, } }) }) .collect() } #[derive(Clone, Debug)] struct SceneImageContext { id: String, name: String, description: String, } fn collect_scene_context_by_id(draft_profile: &Value) -> BTreeMap { let mut contexts = BTreeMap::new(); if let Some(camp) = draft_profile.get("camp").and_then(Value::as_object) { if let Some(context) = scene_context_from_object(camp, "camp") { contexts.insert(context.id.clone(), context); } } if let Some(landmarks) = draft_profile.get("landmarks").and_then(Value::as_array) { for landmark in landmarks.iter().filter_map(Value::as_object) { if let Some(context) = scene_context_from_object(landmark, "landmark") { contexts.insert(context.id.clone(), context); } } } contexts } fn scene_context_from_object( object: &Map, fallback_id: &str, ) -> Option { let id = read_string_field(object, "id") .or_else(|| read_string_field(object, "sceneId")) .unwrap_or_else(|| fallback_id.to_string()); let name = read_string_field(object, "name") .or_else(|| read_string_field(object, "sceneName")) .unwrap_or_else(|| id.clone()); Some(SceneImageContext { id, name, description: read_string_field(object, "description") .or_else(|| read_string_field(object, "visualDescription")) .unwrap_or_default(), }) } fn collect_scene_act_support_role_names(act: &Value) -> Vec { // 兼容旧 Node 自动资产链路可能写入的 supportRoleNames,也兼容单字段字符串,避免迁移后丢上下文。 let mut names = act .get("supportRoleNames") .and_then(Value::as_array) .into_iter() .flatten() .filter_map(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) .collect::>(); names.extend(json_text_from_value(act, "supportRoleName")); names.extend(json_text_from_value(act, "supportRoles")); names } fn validate_scene_act_background_prompts(act_refs: &[SceneActGenerationRef]) -> Result<(), String> { if let Some(act_ref) = act_refs.iter().find(|act_ref| act_ref.prompt.is_empty()) { return Err(format!( "第{}章第{}幕「{}」缺少 backgroundPromptText,不能在幕背景图描述文本生成前直接生图。", act_ref.chapter_index + 1, act_ref.act_index + 1, act_ref.scene_name )); } Ok(()) } fn json_first_text_from_value(value: &Value, keys: &[&str]) -> Option { keys.iter().find_map(|key| json_text_from_value(value, key)) } fn merge_generated_act_backgrounds(target_profile: &mut Value, background_profile: &Value) { let Some(target_chapters) = target_profile .get_mut("sceneChapterBlueprints") .and_then(Value::as_array_mut) else { return; }; let Some(background_chapters) = background_profile .get("sceneChapterBlueprints") .and_then(Value::as_array) else { return; }; for (chapter_index, background_chapter) in background_chapters.iter().enumerate() { let Some(target_acts) = target_chapters .get_mut(chapter_index) .and_then(|chapter| chapter.get_mut("acts")) .and_then(Value::as_array_mut) else { continue; }; let Some(background_acts) = background_chapter.get("acts").and_then(Value::as_array) else { continue; }; for (act_index, background_act) in background_acts.iter().enumerate() { let Some(target_act_object) = target_acts .get_mut(act_index) .and_then(Value::as_object_mut) else { continue; }; let Some(background_act_object) = background_act.as_object() else { continue; }; // 只合并图片生成产物字段,避免并行分支把其他草稿内容互相覆盖。 for key in [ "backgroundImageSrc", "backgroundAssetId", "generatedScenePrompt", "generatedSceneModel", ] { if let Some(value) = background_act_object.get(key) { target_act_object.insert(key.to_string(), value.clone()); } } } } } fn json_text_from_value(value: &Value, key: &str) -> Option { value .get(key) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) } async fn persist_partial_draft_foundation_after_asset_failure( state: &AppState, session: &CustomWorldAgentSessionRecord, owner_user_id: &str, operation_id: &str, draft_profile: &Value, phase_label: &str, error_message: &str, ) { let draft_profile_json = match serde_json::to_string(draft_profile) { Ok(value) => Some(value), Err(error) => { tracing::warn!(error = %error, "素材失败后的部分底稿序列化失败"); None } }; let finalize_result = state .spacetime_client() .finalize_custom_world_agent_message(CustomWorldAgentMessageFinalizeRecordInput { session_id: session.session_id.clone(), owner_user_id: owner_user_id.to_string(), operation_id: operation_id.to_string(), assistant_message_id: None, assistant_reply_text: None, phase_label: phase_label.to_string(), phase_detail: format!("已保存成功生成的素材,失败项超过 {DRAFT_ASSET_GENERATION_MAX_ATTEMPTS} 次重试:{error_message}"), operation_status: "failed".to_string(), operation_progress: fallback_draft_foundation_failure_progress(phase_label), stage: session.stage.clone(), progress_percent: session.progress_percent, focus_card_id: session.focus_card_id.clone(), anchor_content_json: session.anchor_content.to_string(), creator_intent_json: Some(session.creator_intent.to_string()), creator_intent_readiness_json: session.creator_intent_readiness.to_string(), anchor_pack_json: Some(session.anchor_pack.to_string()), draft_profile_json, pending_clarifications_json: Value::Array(session.pending_clarifications.clone()).to_string(), suggested_actions_json: Value::Array(session.suggested_actions.clone()).to_string(), recommended_replies_json: json!(session.recommended_replies).to_string(), quality_findings_json: Value::Array(session.quality_findings.clone()).to_string(), asset_coverage_json: session.asset_coverage.to_string(), error_message: Some(error_message.to_string()), updated_at_micros: current_utc_micros(), }) .await; if let Err(error) = finalize_result { tracing::warn!(error = %error, "素材失败后的部分底稿持久化失败"); let _ = upsert_custom_world_draft_foundation_progress( state, &session.session_id, owner_user_id, operation_id, "failed", phase_label, error_message, fallback_draft_foundation_failure_progress(phase_label), Some(error_message.to_string()), ) .await; } } async fn upsert_custom_world_draft_foundation_progress( state: &AppState, session_id: &str, owner_user_id: &str, operation_id: &str, status: &str, phase_label: &str, phase_detail: &str, progress: u32, error_message: Option, ) -> Result { state .spacetime_client() .upsert_custom_world_agent_operation_progress( CustomWorldAgentOperationProgressRecordInput { session_id: session_id.to_string(), owner_user_id: owner_user_id.to_string(), operation_id: operation_id.to_string(), operation_type: "draft_foundation".to_string(), operation_status: status.to_string(), phase_label: phase_label.to_string(), phase_detail: phase_detail.to_string(), operation_progress: progress.min(100), error_message, updated_at_micros: current_utc_micros(), }, ) .await .map(|operation| { info!( operation_id = %operation.operation_id, phase_label = %operation.phase_label, progress = operation.progress, "世界草稿生成阶段进度已写入" ); operation }) } fn map_custom_world_library_entry_response( state: &AppState, entry: CustomWorldLibraryEntryRecord, ) -> CustomWorldLibraryEntryResponse { let author = resolve_work_author_by_user_id( state, &entry.owner_user_id, Some(&entry.author_display_name), entry.author_public_user_code.as_deref(), ); CustomWorldLibraryEntryResponse { owner_user_id: entry.owner_user_id, profile_id: entry.profile_id, public_work_code: entry.public_work_code, author_public_user_code: author.public_user_code.or(entry.author_public_user_code), profile: entry.profile, visibility: entry.visibility, published_at: entry.published_at, updated_at: entry.updated_at, author_display_name: author.display_name, world_name: entry.world_name, subtitle: entry.subtitle, summary_text: entry.summary_text, cover_image_src: entry.cover_image_src, theme_mode: entry.theme_mode, playable_npc_count: entry.playable_npc_count, landmark_count: entry.landmark_count, play_count: entry.play_count, remix_count: entry.remix_count, like_count: entry.like_count, recent_play_count_7d: 0, } } fn map_custom_world_library_entry_response_from_work_summary( state: &AppState, item: CustomWorldWorkSummaryRecord, owner_user_id: &str, ) -> Option { let profile_id = item.profile_id.as_ref()?.clone(); let profile = build_custom_world_library_list_profile_payload(&item, &profile_id); let author = resolve_work_author_by_user_id(state, owner_user_id, None, None); Some(CustomWorldLibraryEntryResponse { owner_user_id: owner_user_id.to_string(), public_work_code: (item.status == "published") .then(|| build_public_work_code_from_profile_id(&profile_id)), profile_id, author_public_user_code: author.public_user_code, profile, visibility: item.status, published_at: item.published_at, updated_at: item.updated_at, author_display_name: author.display_name, world_name: item.title, subtitle: item.subtitle, summary_text: item.summary, cover_image_src: item.cover_image_src, theme_mode: "mythic".to_string(), playable_npc_count: item.playable_npc_count, landmark_count: item.landmark_count, play_count: 0, remix_count: 0, like_count: 0, recent_play_count_7d: 0, }) } fn build_public_work_code_from_profile_id(profile_id: &str) -> String { let digits = profile_id .chars() .filter(|character| character.is_ascii_digit()) .collect::(); let normalized_digits = if digits.is_empty() { let checksum = profile_id.bytes().fold(0u32, |accumulator, value| { accumulator.wrapping_mul(131) + u32::from(value) }); format!("{:08}", checksum % 100_000_000) } else { format!("{:0>8}", &digits[digits.len().saturating_sub(8)..]) }; format!("CW-{normalized_digits}") } fn build_custom_world_library_list_profile_payload( item: &CustomWorldWorkSummaryRecord, profile_id: &str, ) -> Value { json!({ "id": profile_id, "name": item.title, "subtitle": item.subtitle, "summary": item.summary, "tone": "", "playerGoal": "", "settingText": "", "themeMode": "mythic", "templateWorldType": "WUXIA", "compatibilityTemplateWorldType": Value::Null, "cover": item.cover_image_src.as_ref().map(|image_src| json!({ "sourceType": "generated", "imageSrc": image_src, })), "majorFactions": [], "coreConflicts": [], "playableNpcs": [], "storyNpcs": [], "items": [], "camp": Value::Null, "landmarks": [], "ownedSettingLayers": Value::Null, }) } fn map_custom_world_gallery_card_response( state: &AppState, entry: CustomWorldGalleryEntryRecord, ) -> CustomWorldGalleryCardResponse { let author = resolve_work_author_by_user_id( state, &entry.owner_user_id, Some(&entry.author_display_name), Some(&entry.author_public_user_code), ); CustomWorldGalleryCardResponse { owner_user_id: entry.owner_user_id, profile_id: entry.profile_id, public_work_code: entry.public_work_code, author_public_user_code: author .public_user_code .unwrap_or(entry.author_public_user_code), visibility: entry.visibility, published_at: entry.published_at, updated_at: entry.updated_at, author_display_name: author.display_name, world_name: entry.world_name, subtitle: entry.subtitle, summary_text: entry.summary_text, cover_image_src: entry.cover_image_src, theme_mode: entry.theme_mode, playable_npc_count: entry.playable_npc_count, landmark_count: entry.landmark_count, play_count: entry.play_count, remix_count: entry.remix_count, like_count: entry.like_count, recent_play_count_7d: entry.recent_play_count_7d, } } fn map_custom_world_work_summary_response( item: CustomWorldWorkSummaryRecord, ) -> CustomWorldWorkSummaryResponse { CustomWorldWorkSummaryResponse { work_id: item.work_id, source_type: item.source_type, status: item.status, title: item.title, subtitle: item.subtitle, summary: item.summary, cover_image_src: item.cover_image_src, cover_render_mode: item.cover_render_mode, cover_character_image_srcs: item.cover_character_image_srcs, updated_at: item.updated_at, published_at: item.published_at, stage: item.stage, stage_label: item.stage_label, playable_npc_count: item.playable_npc_count, landmark_count: item.landmark_count, role_visual_ready_count: item.role_visual_ready_count, role_animation_ready_count: item.role_animation_ready_count, role_asset_summary_label: item.role_asset_summary_label, session_id: item.session_id, profile_id: item.profile_id, can_resume: item.can_resume, can_enter_world: item.can_enter_world, blocker_count: item.blocker_count, publish_ready: item.publish_ready, } } fn map_custom_world_agent_session_response( session: CustomWorldAgentSessionRecord, ) -> CustomWorldAgentSessionSnapshotResponse { CustomWorldAgentSessionSnapshotResponse { session_id: session.session_id, current_turn: session.current_turn, anchor_content: session.anchor_content, progress_percent: session.progress_percent, last_assistant_reply: session.last_assistant_reply, stage: session.stage, focus_card_id: session.focus_card_id, creator_intent: session.creator_intent, creator_intent_readiness: session.creator_intent_readiness, anchor_pack: session.anchor_pack, lock_state: session.lock_state, draft_profile: session.draft_profile, messages: session .messages .into_iter() .map(map_custom_world_agent_message_response) .collect(), draft_cards: session .draft_cards .into_iter() .map(map_custom_world_draft_card_response) .collect(), pending_clarifications: session.pending_clarifications, suggested_actions: session.suggested_actions, recommended_replies: session.recommended_replies, quality_findings: session.quality_findings, asset_coverage: session.asset_coverage, checkpoints: session .checkpoints .into_iter() .map(map_custom_world_agent_checkpoint_response) .collect(), supported_actions: session .supported_actions .into_iter() .map(map_custom_world_supported_action_response) .collect(), publish_gate: session .publish_gate .map(map_custom_world_publish_gate_response), result_preview: session.result_preview, updated_at: session.updated_at, } } fn build_custom_world_creation_result_view_response( session: CustomWorldAgentSessionRecord, ) -> CustomWorldCreationResultViewResponse { let profile_from_preview = session .result_preview .as_ref() .and_then(|preview| preview.get("preview")) .and_then(normalize_json_object_value); let profile_from_draft = if profile_from_preview.is_none() && is_agent_result_stage(session.stage.as_str()) { normalize_json_object_value(&session.draft_profile) // 中文注释:legacyResultProfile 只在服务端作为历史会话恢复兜底, // 前端不再直接解释 legacy 字段的真相优先级。 .or_else(|| { session .draft_profile .get("legacyResultProfile") .and_then(normalize_json_object_value) }) } else { None }; let (profile, profile_source) = match (profile_from_preview, profile_from_draft) { (Some(profile), _) => (Some(profile), "result_preview"), (None, Some(profile)) => (Some(profile), "draft_profile"), (None, None) => (None, "none"), }; let publish_ready = session .publish_gate .as_ref() .map(|gate| gate.publish_ready) .or_else(|| { session .result_preview .as_ref() .and_then(|preview| preview.get("publishReady")) .and_then(Value::as_bool) }) .unwrap_or(false); let can_enter_world = session .publish_gate .as_ref() .map(|gate| gate.can_enter_world) .or_else(|| { session .result_preview .as_ref() .and_then(|preview| preview.get("canEnterWorld")) .and_then(Value::as_bool) }) .unwrap_or(false); let blocker_count = session .publish_gate .as_ref() .map(|gate| gate.blocker_count) .or_else(|| { session .result_preview .as_ref() .and_then(|preview| preview.get("blockers")) .and_then(Value::as_array) .map(|items| items.len() as u32) }) .unwrap_or(0); let has_profile = profile.is_some(); let generation_failed = session.stage == "error" || session .messages .iter() .any(|message| message.kind == "warning" && message.text.contains("失败")); let result_stage = is_agent_result_stage(session.stage.as_str()); let ( target_stage, generation_view_source, result_view_source, recovery_action, recovery_reason, ) = if has_profile && result_stage { ( "custom-world-result", None, Some("agent-draft"), "open_result", None, ) } else if generation_failed { ( "custom-world-generating", Some("agent-draft-foundation"), None, "resume_generation", Some("当前草稿生成失败或缺少结果预览,需要回到生成过程页继续处理。"), ) } else { ( "agent-workspace", None, None, "continue_agent", Some("当前会话还没有可打开的结果页真相源。"), ) }; let can_sync_result_profile = is_agent_result_profile_sync_stage(session.stage.as_str()); CustomWorldCreationResultViewResponse { session: map_custom_world_agent_session_response(session), profile, profile_source: profile_source.to_string(), target_stage: target_stage.to_string(), generation_view_source: generation_view_source.map(ToOwned::to_owned), result_view_source: result_view_source.map(ToOwned::to_owned), can_autosave_library: has_profile && result_stage, can_sync_result_profile, publish_ready, can_enter_world, blocker_count, recovery_action: recovery_action.to_string(), recovery_reason: recovery_reason.map(ToOwned::to_owned), } } fn is_agent_result_stage(stage: &str) -> bool { matches!( stage, "object_refining" | "visual_refining" | "long_tail_review" | "ready_to_publish" | "published" ) } fn is_agent_result_profile_sync_stage(stage: &str) -> bool { matches!( stage, "object_refining" | "visual_refining" | "long_tail_review" | "ready_to_publish" ) } fn normalize_json_object_value(value: &Value) -> Option { value.as_object().and_then(|object| { if object.is_empty() { None } else { Some(Value::Object(object.clone())) } }) } fn log_custom_world_publish_gate_diagnostics( source: &str, session: &CustomWorldAgentSessionRecord, ) { let draft_profile = session.draft_profile.as_object(); let preview_profile = session .result_preview .as_ref() .and_then(|preview| preview.get("preview")) .and_then(Value::as_object); let profile = preview_profile.or(draft_profile); let blocker_codes = session .publish_gate .as_ref() .map(|gate| { gate.blockers .iter() .map(|blocker| blocker.code.as_str()) .collect::>() .join(",") }) .unwrap_or_default(); info!( target: "custom_world.publish_gate", source, session_id = %session.session_id, stage = %session.stage, publish_ready = session.publish_gate.as_ref().map(|gate| gate.publish_ready).unwrap_or(false), blocker_count = session.publish_gate.as_ref().map(|gate| gate.blocker_count).unwrap_or(0), blocker_codes = %blocker_codes, has_draft_profile = session.draft_profile.as_object().map(|value| !value.is_empty()).unwrap_or(false), has_result_preview = session.result_preview.is_some(), preview_source = session.result_preview.as_ref().and_then(|value| value.get("source")).and_then(serde_json::Value::as_str).unwrap_or(""), has_world_hook = has_custom_world_publish_text(profile, &["worldHook", "creatorIntent.worldHook", "anchorContent.worldPromise", "anchorContent.worldPromise.hook", "settingText"]), has_player_premise = has_custom_world_publish_text(profile, &["playerPremise", "creatorIntent.playerPremise", "anchorContent.playerEntryPoint", "anchorContent.playerEntryPoint.openingIdentity", "anchorContent.playerEntryPoint.openingProblem", "anchorContent.playerEntryPoint.entryMotivation"]), has_core_conflicts = has_custom_world_non_empty_text_array(profile, "coreConflicts"), has_main_chapter = has_custom_world_array(profile, "chapters") || has_custom_world_array(profile, "sceneChapterBlueprints") || has_custom_world_array(profile, "sceneChapters"), has_scene_act = has_custom_world_scene_act(profile), "custom world publish gate diagnostics" ); } fn has_custom_world_publish_text(profile: Option<&Map>, paths: &[&str]) -> bool { paths.iter().any(|path| { let mut segments = path.split('.'); let Some(first_segment) = segments.next() else { return false; }; let mut current = profile.and_then(|value| value.get(first_segment)); for segment in segments { current = current.and_then(|value| value.get(segment)); } current .and_then(Value::as_str) .map(str::trim) .map(|value| !value.is_empty()) .unwrap_or(false) }) } fn has_custom_world_non_empty_text_array(profile: Option<&Map>, key: &str) -> bool { profile .and_then(|value| value.get(key)) .and_then(Value::as_array) .map(|items| { items.iter().any(|item| { item.as_str() .map(str::trim) .is_some_and(|value| !value.is_empty()) }) }) .unwrap_or(false) } fn has_custom_world_array(profile: Option<&Map>, key: &str) -> bool { profile .and_then(|value| value.get(key)) .and_then(Value::as_array) .map(|items| !items.is_empty()) .unwrap_or(false) } fn has_custom_world_scene_act(profile: Option<&Map>) -> bool { profile .and_then(|value| { value .get("sceneChapterBlueprints") .or_else(|| value.get("sceneChapters")) }) .and_then(Value::as_array) .map(|chapters| { chapters.iter().any(|chapter| { chapter .get("acts") .and_then(Value::as_array) .map(|acts| !acts.is_empty()) .unwrap_or(false) }) }) .unwrap_or(false) } fn map_custom_world_publish_gate_response( gate: CustomWorldPublishGateRecord, ) -> CustomWorldPublishGateResponse { CustomWorldPublishGateResponse { profile_id: gate.profile_id, blockers: gate .blockers .into_iter() .map(map_custom_world_result_preview_blocker_response) .collect(), blocker_count: gate.blocker_count, publish_ready: gate.publish_ready, can_enter_world: gate.can_enter_world, } } fn map_custom_world_agent_message_response( message: CustomWorldAgentMessageRecord, ) -> CustomWorldAgentMessageResponse { CustomWorldAgentMessageResponse { id: message.message_id, role: message.role, kind: message.kind, text: message.text, created_at: message.created_at, related_operation_id: message.related_operation_id, } } fn map_custom_world_agent_operation_response( operation: CustomWorldAgentOperationRecord, ) -> CustomWorldAgentOperationResponse { CustomWorldAgentOperationResponse { operation_id: operation.operation_id, operation_type: operation.operation_type, status: operation.status, phase_label: operation.phase_label, phase_detail: operation.phase_detail, progress: operation.progress, error: operation.error_message, started_at: Some(timestamp_micros_to_rfc3339(operation.started_at_micros)), updated_at: Some(timestamp_micros_to_rfc3339(operation.updated_at_micros)), } } fn map_custom_world_draft_card_response( card: CustomWorldDraftCardRecord, ) -> CustomWorldDraftCardSummaryResponse { CustomWorldDraftCardSummaryResponse { id: card.card_id, kind: card.kind, title: card.title, subtitle: card.subtitle, summary: card.summary, status: card.status, linked_ids: card.linked_ids, warning_count: card.warning_count, asset_status: card.asset_status, asset_status_label: card.asset_status_label, } } fn map_custom_world_draft_card_detail_response( card: CustomWorldDraftCardDetailRecord, ) -> CustomWorldDraftCardDetailResponse { CustomWorldDraftCardDetailResponse { id: card.card_id, kind: card.kind, title: card.title, sections: card .sections .into_iter() .map(map_custom_world_draft_card_detail_section_response) .collect(), linked_ids: card.linked_ids, locked: card.locked, editable: card.editable, editable_section_ids: card.editable_section_ids, warning_messages: card.warning_messages, asset_status: card.asset_status, asset_status_label: card.asset_status_label, } } fn map_custom_world_draft_card_detail_section_response( section: CustomWorldDraftCardDetailSectionRecord, ) -> CustomWorldDraftCardDetailSectionResponse { CustomWorldDraftCardDetailSectionResponse { id: section.section_id, label: section.label, value: section.value, } } fn map_custom_world_agent_checkpoint_response( checkpoint: CustomWorldAgentCheckpointRecord, ) -> CustomWorldAgentCheckpointResponse { CustomWorldAgentCheckpointResponse { checkpoint_id: checkpoint.checkpoint_id, created_at: checkpoint.created_at, label: checkpoint.label, } } fn map_custom_world_supported_action_response( action: CustomWorldSupportedActionRecord, ) -> CustomWorldSupportedActionResponse { CustomWorldSupportedActionResponse { action: action.action, enabled: action.enabled, reason: action.reason, } } fn map_custom_world_result_preview_blocker_response( blocker: CustomWorldResultPreviewBlockerRecord, ) -> CustomWorldResultPreviewBlockerResponse { CustomWorldResultPreviewBlockerResponse { id: blocker.id, code: blocker.code, message: blocker.message, } } fn map_custom_world_client_error(error: SpacetimeClientError) -> AppError { let status = match &error { SpacetimeClientError::Procedure(message) if message.contains("custom_world_profile 不存在") => { StatusCode::NOT_FOUND } SpacetimeClientError::Procedure(message) if message.contains("custom_world_agent_session 不存在") => { StatusCode::NOT_FOUND } SpacetimeClientError::Procedure(message) if message.contains("custom_world_agent_operation 不存在") => { StatusCode::NOT_FOUND } SpacetimeClientError::Procedure(message) if message.contains("当前模型不可用") || message.contains("设定生成失败") || message.contains("解析失败") || message.contains("缺少有效回复") => { StatusCode::BAD_GATEWAY } SpacetimeClientError::Runtime(_) => StatusCode::BAD_REQUEST, _ => StatusCode::BAD_GATEWAY, }; AppError::from_status(status).with_details(json!({ "provider": "spacetimedb", "message": error.to_string(), })) } fn custom_world_error_response(request_context: &RequestContext, error: AppError) -> Response { error.into_response_with_context(Some(request_context)) } fn ensure_non_empty( request_context: &RequestContext, value: &str, field_name: &str, ) -> Result<(), Response> { if value.trim().is_empty() { return Err(custom_world_error_response( request_context, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "custom-world", "message": format!("{field_name} is required"), })), )); } Ok(()) } fn custom_world_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 custom_world_sse_json_event_or_error(event_name: &str, payload: Value) -> Event { match custom_world_sse_json_event(event_name, payload) { Ok(event) => event, Err(_) => custom_world_sse_error_event_message("SSE payload 序列化失败".to_string()), } } fn custom_world_sse_error_event_message(message: String) -> Event { let payload = format!( "{{\"message\":{}}}", serde_json::to_string(&message) .unwrap_or_else(|_| "\"SSE 错误事件序列化失败\"".to_string()) ); Event::default().event("error").data(payload) } 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 resolve_author_public_user_code( state: &AppState, authenticated: &AuthenticatedAccessToken, request_context: &RequestContext, ) -> Result { state .auth_user_service() .get_user_by_id(authenticated.claims().user_id()) .map_err(|error| { custom_world_error_response( request_context, AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ "provider": "custom-world-library", "message": format!("作者百梦号读取失败:{error}"), })), ) })? .map(|user| user.public_user_code) .filter(|value| !value.trim().is_empty()) .ok_or_else(|| { custom_world_error_response( request_context, AppError::from_status(StatusCode::UNAUTHORIZED).with_details(json!({ "provider": "custom-world-library", "message": "当前登录用户缺少百梦号", })), ) }) } fn build_custom_world_agent_welcome_text(seed_text: &str) -> String { if seed_text.trim().is_empty() { return "我会先帮你把世界的核心锚点整理出来。你可以从世界钩子、玩家身份、主题氛围、核心冲突、关键关系或标志性元素开始。" .to_string(); } "我已经收到你的世界起点,会先把它整理成创作锚点。你可以继续补充玩家身份、核心冲突、关键关系或标志性元素。".to_string() } struct CustomWorldProfileMetadata { world_name: String, subtitle: String, summary_text: String, cover_image_src: Option, theme_mode: CustomWorldThemeMode, playable_npc_count: u32, landmark_count: u32, } fn extract_custom_world_metadata(profile: &Value) -> Result { let object = profile .as_object() .ok_or_else(|| "profile 必须是 JSON object".to_string())?; let world_name = read_string_field(object, "name").unwrap_or_else(|| "未命名世界".to_string()); let subtitle = read_string_field(object, "subtitle").unwrap_or_default(); let summary_text = read_string_field(object, "summary").unwrap_or_default(); let cover_image_src = resolve_cover_image_src(object); let theme_mode = read_string_field(object, "themeMode") .and_then(|value| CustomWorldThemeMode::from_client_str(&value)) .unwrap_or(CustomWorldThemeMode::Mythic); let playable_npc_count = count_profile_roles(object); let landmark_count = object .get("landmarks") .and_then(Value::as_array) .map(|entries| entries.len() as u32) .unwrap_or(0); Ok(CustomWorldProfileMetadata { world_name, subtitle, summary_text, cover_image_src, theme_mode, playable_npc_count, landmark_count, }) } fn read_string_field(object: &Map, key: &str) -> Option { object .get(key) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) } fn resolve_cover_image_src(object: &Map) -> Option { object .get("cover") .and_then(Value::as_object) .and_then(|cover| read_string_field(cover, "imageSrc")) .or_else(|| { object .get("camp") .and_then(Value::as_object) .and_then(|camp| read_string_field(camp, "imageSrc")) }) .or_else(|| { object .get("landmarks") .and_then(Value::as_array) .and_then(|entries| entries.first()) .and_then(Value::as_object) .and_then(|landmark| read_string_field(landmark, "imageSrc")) }) } fn count_profile_roles(object: &Map) -> u32 { let playable = object .get("playableNpcs") .and_then(Value::as_array) .map(|entries| entries.len() as u32) .unwrap_or(0); let story = object .get("storyNpcs") .and_then(Value::as_array) .map(|entries| entries.len() as u32) .unwrap_or(0); playable.saturating_add(story) } fn current_utc_micros() -> i64 { use std::time::{SystemTime, UNIX_EPOCH}; let duration = SystemTime::now() .duration_since(UNIX_EPOCH) .expect("system clock should be after unix epoch"); i64::try_from(duration.as_micros()).expect("current unix micros should fit in i64") } #[cfg(test)] mod tests { use super::*; use axum::{Router, body::Body, http::Request}; use tower::ServiceExt; use crate::{app::build_router, config::AppConfig}; #[test] fn incomplete_role_asset_text_draft_profile_is_not_reused() { let mut session = CustomWorldAgentSessionRecord { session_id: "session-with-broken-draft".to_string(), seed_text: "深海异常调查".to_string(), current_turn: 1, anchor_content: json!({}), progress_percent: 80, last_assistant_reply: None, stage: "foundation_review".to_string(), focus_card_id: None, creator_intent: json!({}), creator_intent_readiness: json!({}), anchor_pack: json!({}), lock_state: json!({}), draft_profile: json!({ "name": "深海裂隙", "playableNpcs": [{ "name": "海洋生物学家", "title": "深海观察员", "role": "调查者", "description": "记录异常海沟的人" }], "storyNpcs": [] }), messages: Vec::new(), draft_cards: Vec::new(), pending_clarifications: Vec::new(), suggested_actions: Vec::new(), recommended_replies: Vec::new(), quality_findings: Vec::new(), asset_coverage: json!({}), checkpoints: Vec::new(), supported_actions: Vec::new(), publish_gate: None, result_preview: None, updated_at: "2026-04-25T00:00:00Z".to_string(), }; assert!(reusable_draft_profile_for_asset_generation(&session).is_none()); if let Some(role) = session .draft_profile .get_mut("playableNpcs") .and_then(Value::as_array_mut) .and_then(|roles| roles.first_mut()) .and_then(Value::as_object_mut) { role.insert( "visualDescription".to_string(), json!("防水研究外套挂满盐痕,护目镜映着蓝绿海光,手提样本箱。"), ); role.insert( "actionDescription".to_string(), json!("蹲身取样并快速记录潮汐数据,遇险时护住样本箱后撤。"), ); role.insert( "sceneVisualDescription".to_string(), json!("她常站在潮湿实验船甲板边,身后是发光海沟与摇晃仪器。"), ); } assert!(reusable_draft_profile_for_asset_generation(&session).is_some()); } #[test] fn generated_profile_finalize_adds_required_frontend_fields() { let mut profile = json!({ "name": "雾港归航", "summary": "守灯人与群岛议会围绕沉船旧案对峙。", "playableNpcs": [], "storyNpcs": [], "landmarks": [] }); finalize_generated_custom_world_profile( &mut profile, "在失真的海图上追查一场被篡改的沉船事故。", "fast", json!({ "worldHook": "海图会撒谎" }), ); assert_eq!( profile.get("settingText").and_then(Value::as_str), Some("在失真的海图上追查一场被篡改的沉船事故。") ); assert_eq!( profile.get("generationMode").and_then(Value::as_str), Some("fast") ); assert_eq!( profile.get("generationStatus").and_then(Value::as_str), Some("key_only") ); assert_eq!( profile.get("templateWorldType").and_then(Value::as_str), Some("WUXIA") ); assert!(profile.get("items").and_then(Value::as_array).is_some()); assert!( profile .get("id") .and_then(Value::as_str) .is_some_and(|value| value.starts_with("custom-world-")) ); assert!(profile.get("creatorIntent").is_some()); } #[test] fn sync_result_profile_payload_is_canonicalized_on_server() { let payload = ExecuteCustomWorldAgentActionRequest { action: "sync_result_profile".to_string(), profile: Some(json!({ "id": "cwprof_001", "settingText": "前端保存前不再改写这段文字", "creatorIntent": { "worldHook": "海图会在午夜改写群岛航路", "playerPremise": "玩家是失忆领航员", "openingSituation": "正在禁航区醒来", "themeKeywords": ["海雾"], "toneDirectives": ["悬疑"], "coreConflicts": ["议会隐瞒沉船真相"], "keyCharacters": [{ "name": "顾潮音", "role": "守灯人", "relationToPlayer": "旧识", "hiddenHook": "掌握伪造海图" }], "iconicElements": ["会说谎的罗盘"] } })), profile_id: None, draft_profile: None, legacy_result_profile: None, setting_text: None, card_id: None, sections: None, count: None, role_type: None, prompt_text: None, anchor_card_ids: None, role_ids: None, role_id: None, scene_ids: None, portrait_path: None, generated_visual_asset_id: None, generated_animation_set_id: None, animation_map: None, scene_id: None, scene_kind: None, image_src: None, generated_scene_asset_id: None, generated_scene_prompt: None, generated_scene_model: None, checkpoint_id: None, }; let payload_json = serialize_sync_result_profile_action_payload(&payload).expect("payload serializes"); let payload_value: Value = serde_json::from_str(&payload_json).expect("payload should be valid JSON"); assert_eq!( payload_value .get("profile") .and_then(|profile| profile.get("settingText")) .and_then(Value::as_str), Some( "世界一句话:海图会在午夜改写群岛航路\n玩家开局:玩家是失忆领航员;正在禁航区醒来\n主题气质:海雾 / 悬疑\n核心冲突:议会隐瞒沉船真相\n关键关系:顾潮音 · 守灯人 · 与玩家 旧识 · 暗线 掌握伪造海图\n标志元素:会说谎的罗盘" ) ); } #[test] fn custom_world_library_profile_payload_is_canonicalized_on_server() { let (profile, metadata) = canonicalize_custom_world_library_profile_payload(json!({ "id": "cwprof_001", "name": "潮雾列岛", "summary": "群岛与旧灯塔之间的沉船疑案。", "settingText": "前端保存前不再改写这段文字", "playableNpcs": [{"id": "pc-1"}], "storyNpcs": [{"id": "story-1"}], "landmarks": [{"id": "scene-1"}], "creatorIntent": { "worldHook": "海图会在午夜改写群岛航路", "playerPremise": "玩家是失忆领航员", "openingSituation": "正在禁航区醒来", "themeKeywords": ["海雾"], "toneDirectives": ["悬疑"], "coreConflicts": ["议会隐瞒沉船真相"], "keyCharacters": [{ "name": "顾潮音", "role": "守灯人", "relationToPlayer": "旧识", "hiddenHook": "掌握伪造海图" }], "iconicElements": ["会说谎的罗盘"] } })) .expect("profile should be canonicalized"); assert_eq!(metadata.world_name, "潮雾列岛"); assert_eq!(metadata.playable_npc_count, 2); assert_eq!(metadata.landmark_count, 1); assert_eq!( profile.get("settingText").and_then(Value::as_str), Some( "世界一句话:海图会在午夜改写群岛航路\n玩家开局:玩家是失忆领航员;正在禁航区醒来\n主题气质:海雾 / 悬疑\n核心冲突:议会隐瞒沉船真相\n关键关系:顾潮音 · 守灯人 · 与玩家 旧识 · 暗线 掌握伪造海图\n标志元素:会说谎的罗盘" ) ); } #[tokio::test] async fn custom_world_profile_generation_requires_authentication() { let app: Router = build_router(AppState::new(AppConfig::default()).expect("state should build")); let response = app .oneshot( Request::builder() .method("POST") .uri("/api/runtime/custom-world/profile") .header("content-type", "application/json") .body(Body::from(r#"{"settingText":"海雾会吞掉记错航线的人。"}"#)) .expect("request should build"), ) .await .expect("request should succeed"); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } #[test] fn collect_scene_act_refs_accepts_scene_prompt_text_alias() { let draft_profile = json!({ "name": "雾港纪元", "tone": "潮湿、悬疑、低照度", "landmarks": [ { "id": "scene-office", "name": "旧港办公室", "description": "旧港边缘的玻璃办公室,窗外能看到潮湿码头。" } ], "sceneChapterBlueprints": [ { "sceneId": "scene-office", "sceneName": "旧港办公室", "acts": [ { "title": "深夜工位", "summary": "团队在凌晨三点继续赶版本。", "actGoal": "找到丢失的部署钥匙", "transitionHook": "电梯门在无人操作时打开", "primaryRoleName": "林澈", "supportRoleNames": ["阿岚"], "scenePromptText": "现代创业公司办公室,凌晨灯光,紧张忙碌" } ] } ] }); let act_refs = collect_scene_act_refs(&draft_profile); assert_eq!(act_refs.len(), 1); assert_eq!(act_refs[0].prompt, "现代创业公司办公室,凌晨灯光,紧张忙碌"); assert_eq!(act_refs[0].scene_id, "scene-office"); assert_eq!(act_refs[0].scene_name, "旧港办公室"); assert_eq!( act_refs[0].scene_description, "旧港边缘的玻璃办公室,窗外能看到潮湿码头。" ); assert!(validate_scene_act_background_prompts(&act_refs).is_ok()); } }