#[spacetimedb::table( accessor = custom_world_profile, index(accessor = by_custom_world_profile_owner_user_id, btree(columns = [owner_user_id])), index( accessor = by_custom_world_profile_publication_status, btree(columns = [publication_status]) ) )] pub struct CustomWorldProfile { #[primary_key] profile_id: String, // 当前 profile 承接 library / publish / enter-world 的正式世界工件真相。 owner_user_id: String, source_agent_session_id: Option, publication_status: CustomWorldPublicationStatus, world_name: String, subtitle: String, summary_text: String, theme_mode: CustomWorldThemeMode, cover_image_src: Option, profile_payload_json: String, playable_npc_count: u32, landmark_count: u32, author_display_name: String, published_at: Option, // 软删除后保留 profile 真相,供审计与幂等删除使用。 deleted_at: Option, created_at: Timestamp, updated_at: Timestamp, } #[spacetimedb::table( accessor = custom_world_session, index(accessor = by_custom_world_session_owner_user_id, btree(columns = [owner_user_id])) )] pub struct CustomWorldSession { #[primary_key] session_id: String, // 这张表只承接旧 custom-world/sessions 传统问答流,不和 agent 会话混存。 owner_user_id: String, generation_mode: CustomWorldGenerationMode, status: CustomWorldSessionStatus, setting_text: String, creator_intent_json: Option, question_snapshot_json: String, result_payload_json: Option, last_error_message: Option, created_at: Timestamp, updated_at: Timestamp, } #[spacetimedb::table( accessor = custom_world_agent_session, index( accessor = by_custom_world_agent_session_owner_user_id, btree(columns = [owner_user_id]) ), index(accessor = by_custom_world_agent_session_stage, btree(columns = [stage])) )] pub struct CustomWorldAgentSession { #[primary_key] session_id: String, // Agent 会话只保留会话级聚合字段,消息、操作、卡片都拆到独立表。 owner_user_id: String, seed_text: String, current_turn: u32, progress_percent: u32, stage: RpgAgentStage, focus_card_id: Option, anchor_content_json: String, creator_intent_json: Option, creator_intent_readiness_json: String, anchor_pack_json: Option, lock_state_json: Option, draft_profile_json: Option, last_assistant_reply: Option, publish_gate_json: Option, result_preview_json: Option, pending_clarifications_json: String, quality_findings_json: String, suggested_actions_json: String, recommended_replies_json: String, asset_coverage_json: String, checkpoints_json: String, created_at: Timestamp, updated_at: Timestamp, } #[spacetimedb::table( accessor = custom_world_agent_message, index(accessor = by_custom_world_agent_message_session_id, btree(columns = [session_id])) )] pub struct CustomWorldAgentMessage { #[primary_key] message_id: String, // 消息流水单独成表,避免继续塞回 session 大 JSON。 session_id: String, role: RpgAgentMessageRole, kind: RpgAgentMessageKind, text: String, related_operation_id: Option, created_at: Timestamp, } #[spacetimedb::table( accessor = custom_world_agent_operation, index(accessor = by_custom_world_agent_operation_session_id, btree(columns = [session_id])) )] pub struct CustomWorldAgentOperation { #[primary_key] operation_id: String, // 异步操作单独建表,为 message stream / operation query 提供真相源。 session_id: String, operation_type: RpgAgentOperationType, status: RpgAgentOperationStatus, phase_label: String, phase_detail: String, progress: u32, error_message: Option, created_at: Timestamp, updated_at: Timestamp, } #[spacetimedb::table( accessor = custom_world_draft_card, index(accessor = by_custom_world_draft_card_session_id, btree(columns = [session_id])), index(accessor = by_custom_world_draft_card_kind, btree(columns = [kind])) )] pub struct CustomWorldDraftCard { #[primary_key] card_id: String, // 卡片实体从 agent session 拆出,后续 detail / update 都直接对这张表操作。 session_id: String, kind: RpgAgentDraftCardKind, status: RpgAgentDraftCardStatus, title: String, subtitle: String, summary: String, linked_ids_json: String, warning_count: u32, asset_status: Option, asset_status_label: Option, detail_payload_json: Option, created_at: Timestamp, updated_at: Timestamp, } #[spacetimedb::table( accessor = custom_world_gallery_entry, public, index(accessor = by_custom_world_gallery_owner_user_id, btree(columns = [owner_user_id])), index(accessor = by_custom_world_gallery_theme_mode, btree(columns = [theme_mode])) )] pub struct CustomWorldGalleryEntry { #[primary_key] profile_id: String, // 画廊是公开订阅读模型,不再运行时从 profile 即席拼装。 owner_user_id: String, author_display_name: String, world_name: String, subtitle: String, summary_text: String, cover_image_src: Option, theme_mode: CustomWorldThemeMode, playable_npc_count: u32, landmark_count: u32, published_at: Timestamp, updated_at: Timestamp, } #[spacetimedb::procedure] pub fn create_custom_world_agent_session( ctx: &mut ProcedureContext, input: CustomWorldAgentSessionCreateInput, ) -> CustomWorldAgentSessionProcedureResult { match ctx.try_with_tx(|tx| create_custom_world_agent_session_tx(tx, input.clone())) { Ok(session) => CustomWorldAgentSessionProcedureResult { ok: true, session: Some(session), error_message: None, }, Err(message) => CustomWorldAgentSessionProcedureResult { ok: false, session: None, error_message: Some(message), }, } } // Stage 6 读取拆表后的最小 Agent session snapshot,供 Axum 兼容旧前端 contract。 #[spacetimedb::procedure] pub fn get_custom_world_agent_session( ctx: &mut ProcedureContext, input: CustomWorldAgentSessionGetInput, ) -> CustomWorldAgentSessionProcedureResult { match ctx.try_with_tx(|tx| get_custom_world_agent_session_tx(tx, input.clone())) { Ok(session) => CustomWorldAgentSessionProcedureResult { ok: true, session: Some(session), error_message: None, }, Err(message) => CustomWorldAgentSessionProcedureResult { ok: false, session: None, error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn submit_custom_world_agent_message( ctx: &mut ProcedureContext, input: CustomWorldAgentMessageSubmitInput, ) -> CustomWorldAgentOperationProcedureResult { match ctx.try_with_tx(|tx| submit_custom_world_agent_message_tx(tx, input.clone())) { Ok(operation) => CustomWorldAgentOperationProcedureResult { ok: true, operation: Some(operation), error_message: None, }, Err(message) => CustomWorldAgentOperationProcedureResult { ok: false, operation: None, error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn get_custom_world_agent_operation( ctx: &mut ProcedureContext, input: CustomWorldAgentOperationGetInput, ) -> CustomWorldAgentOperationProcedureResult { match ctx.try_with_tx(|tx| get_custom_world_agent_operation_tx(tx, input.clone())) { Ok(operation) => CustomWorldAgentOperationProcedureResult { ok: true, operation: Some(operation), error_message: None, }, Err(message) => CustomWorldAgentOperationProcedureResult { ok: false, operation: None, error_message: Some(message), }, } } fn continue_story_tx( ctx: &ReducerContext, input: StoryContinueInput, ) -> Result<(StorySessionSnapshot, StoryEventSnapshot), String> { validate_story_continue_input(&input).map_err(|error| error.to_string())?; let current = ctx .db .story_session() .story_session_id() .find(&input.story_session_id) .ok_or_else(|| "story_session 不存在,无法继续推进".to_string())?; let current_snapshot = StorySessionSnapshot { story_session_id: current.story_session_id.clone(), runtime_session_id: current.runtime_session_id.clone(), actor_user_id: current.actor_user_id.clone(), world_profile_id: current.world_profile_id.clone(), initial_prompt: current.initial_prompt.clone(), opening_summary: current.opening_summary.clone(), latest_narrative_text: current.latest_narrative_text.clone(), latest_choice_function_id: current.latest_choice_function_id.clone(), status: current.status, version: current.version, created_at_micros: current.created_at.to_micros_since_unix_epoch(), updated_at_micros: current.updated_at.to_micros_since_unix_epoch(), }; let (next_snapshot, event_snapshot) = apply_story_continue(current_snapshot, input).map_err(|error| error.to_string())?; ctx.db .story_session() .story_session_id() .delete(¤t.story_session_id); ctx.db.story_session().insert(StorySession { story_session_id: next_snapshot.story_session_id.clone(), runtime_session_id: next_snapshot.runtime_session_id.clone(), actor_user_id: next_snapshot.actor_user_id.clone(), world_profile_id: next_snapshot.world_profile_id.clone(), initial_prompt: next_snapshot.initial_prompt.clone(), opening_summary: next_snapshot.opening_summary.clone(), latest_narrative_text: next_snapshot.latest_narrative_text.clone(), latest_choice_function_id: next_snapshot.latest_choice_function_id.clone(), status: next_snapshot.status, version: next_snapshot.version, created_at: Timestamp::from_micros_since_unix_epoch(next_snapshot.created_at_micros), updated_at: Timestamp::from_micros_since_unix_epoch(next_snapshot.updated_at_micros), }); ctx.db.story_event().insert(StoryEvent { event_id: event_snapshot.event_id.clone(), story_session_id: event_snapshot.story_session_id.clone(), event_kind: event_snapshot.event_kind, narrative_text: event_snapshot.narrative_text.clone(), choice_function_id: event_snapshot.choice_function_id.clone(), created_at: Timestamp::from_micros_since_unix_epoch(event_snapshot.created_at_micros), }); Ok((next_snapshot, event_snapshot)) } fn get_story_session_state_tx( ctx: &ReducerContext, input: StorySessionStateInput, ) -> Result<(StorySessionSnapshot, Vec), String> { validate_story_session_state_input(&input).map_err(|error| error.to_string())?; let session = ctx .db .story_session() .story_session_id() .find(&input.story_session_id) .ok_or_else(|| "story_session 不存在".to_string())?; let session_snapshot = build_story_session_snapshot_from_row(&session); let mut events = ctx .db .story_event() .iter() .filter(|row| row.story_session_id == input.story_session_id) .map(|row| build_story_event_snapshot_from_row(&row)) .collect::>(); events.sort_by_key(|event| (event.created_at_micros, event.event_id.clone())); Ok((session_snapshot, events)) } fn create_custom_world_agent_session_tx( ctx: &ReducerContext, input: CustomWorldAgentSessionCreateInput, ) -> Result { validate_custom_world_agent_session_create_input(&input).map_err(|error| error.to_string())?; if ctx .db .custom_world_agent_session() .session_id() .find(&input.session_id) .is_some() { return Err("custom_world_agent_session.session_id 已存在".to_string()); } if ctx .db .custom_world_agent_message() .message_id() .find(&input.welcome_message_id) .is_some() { return Err("custom_world_agent_message.message_id 已存在".to_string()); } let created_at = Timestamp::from_micros_since_unix_epoch(input.created_at_micros); ctx.db .custom_world_agent_session() .insert(CustomWorldAgentSession { session_id: input.session_id.clone(), owner_user_id: input.owner_user_id.clone(), seed_text: input.seed_text.trim().to_string(), current_turn: 0, progress_percent: 0, stage: RpgAgentStage::CollectingIntent, focus_card_id: None, anchor_content_json: input.anchor_content_json.clone(), creator_intent_json: input.creator_intent_json.clone(), creator_intent_readiness_json: input.creator_intent_readiness_json.clone(), anchor_pack_json: input.anchor_pack_json.clone(), lock_state_json: input.lock_state_json.clone(), draft_profile_json: input.draft_profile_json.clone(), last_assistant_reply: Some(input.welcome_message_text.trim().to_string()), publish_gate_json: None, result_preview_json: None, pending_clarifications_json: input.pending_clarifications_json.clone(), quality_findings_json: input.quality_findings_json.clone(), suggested_actions_json: input.suggested_actions_json.clone(), recommended_replies_json: input.recommended_replies_json.clone(), asset_coverage_json: input.asset_coverage_json.clone(), checkpoints_json: input.checkpoints_json.clone(), created_at, updated_at: created_at, }); ctx.db .custom_world_agent_message() .insert(CustomWorldAgentMessage { message_id: input.welcome_message_id, session_id: input.session_id.clone(), role: RpgAgentMessageRole::Assistant, kind: RpgAgentMessageKind::Chat, text: input.welcome_message_text.trim().to_string(), related_operation_id: None, created_at, }); get_custom_world_agent_session_tx( ctx, CustomWorldAgentSessionGetInput { session_id: input.session_id, owner_user_id: input.owner_user_id, }, ) } fn get_custom_world_agent_session_tx( ctx: &ReducerContext, input: CustomWorldAgentSessionGetInput, ) -> Result { validate_custom_world_agent_session_get_input(&input).map_err(|error| error.to_string())?; let session = ctx .db .custom_world_agent_session() .session_id() .find(&input.session_id) .filter(|row| row.owner_user_id == input.owner_user_id) .ok_or_else(|| "custom_world_agent_session 不存在".to_string())?; Ok(build_custom_world_agent_session_snapshot(ctx, &session)) } fn submit_custom_world_agent_message_tx( ctx: &ReducerContext, input: CustomWorldAgentMessageSubmitInput, ) -> Result { validate_custom_world_agent_message_submit_input(&input).map_err(|error| error.to_string())?; if input.user_message_text.contains("__phase1_force_fail__") { return Err("forced failure".to_string()); } let session = ctx .db .custom_world_agent_session() .session_id() .find(&input.session_id) .filter(|row| row.owner_user_id == input.owner_user_id) .ok_or_else(|| "custom_world_agent_session 不存在".to_string())?; if ctx .db .custom_world_agent_message() .message_id() .find(&input.user_message_id) .is_some() { return Err("custom_world_agent_message.message_id 已存在".to_string()); } if ctx .db .custom_world_agent_operation() .operation_id() .find(&input.operation_id) .is_some() { return Err("custom_world_agent_operation.operation_id 已存在".to_string()); } let submitted_at = Timestamp::from_micros_since_unix_epoch(input.submitted_at_micros); let user_message_text = input.user_message_text.trim().to_string(); let assistant_message_id = format!("assistant-{}", input.operation_id); if ctx .db .custom_world_agent_message() .message_id() .find(&assistant_message_id) .is_some() { return Err("custom_world_agent_message.assistant_message_id 已存在".to_string()); } ctx.db .custom_world_agent_message() .insert(CustomWorldAgentMessage { message_id: input.user_message_id, session_id: input.session_id.clone(), role: RpgAgentMessageRole::User, kind: RpgAgentMessageKind::Chat, text: user_message_text, related_operation_id: Some(input.operation_id.clone()), created_at: submitted_at, }); let user_message_count = ctx .db .custom_world_agent_message() .iter() .filter(|row| { row.session_id == input.session_id && matches!(row.role, RpgAgentMessageRole::User) }) .count() as u32; let next_turn = session.current_turn.saturating_add(1); let (next_stage, next_progress_percent, next_readiness_json, next_pending_clarifications_json) = if user_message_count >= 2 { ( RpgAgentStage::FoundationReview, 100, r#"{"isReady":true,"completedKeys":["seed_input"],"missingKeys":[]}"#.to_string(), "[]".to_string(), ) } else { ( RpgAgentStage::Clarifying, session.progress_percent.max(20), session.creator_intent_readiness_json.clone(), format!( r#"[{{"id":"clarify-{next_turn}","label":"补充核心设定","question":"请继续补充这个世界的玩家身份、主题氛围或核心冲突。","targetKey":"core_conflict","priority":1}}]"# ), ) }; let assistant_reply = "已记录这条设定。我会先把它当作新的世界线索收进当前草稿,你可以继续补充玩家身份、主题氛围、核心冲突、关键关系或标志性元素。".to_string(); ctx.db .custom_world_agent_operation() .insert(CustomWorldAgentOperation { operation_id: input.operation_id.clone(), session_id: input.session_id.clone(), operation_type: RpgAgentOperationType::ProcessMessage, status: RpgAgentOperationStatus::Completed, phase_label: "消息已处理".to_string(), phase_detail: if next_stage == RpgAgentStage::FoundationReview { "当前上下文已达到最小 foundation_review 门槛。".to_string() } else { "当前上下文已记录,继续收集世界关键锚点。".to_string() }, progress: 100, error_message: None, created_at: submitted_at, updated_at: submitted_at, }); ctx.db .custom_world_agent_message() .insert(CustomWorldAgentMessage { message_id: assistant_message_id, session_id: input.session_id.clone(), role: RpgAgentMessageRole::Assistant, kind: RpgAgentMessageKind::Chat, text: assistant_reply.clone(), related_operation_id: Some(input.operation_id.clone()), created_at: submitted_at, }); ctx.db .custom_world_agent_session() .session_id() .update(CustomWorldAgentSession { session_id: session.session_id.clone(), owner_user_id: session.owner_user_id.clone(), seed_text: session.seed_text.clone(), current_turn: next_turn, progress_percent: next_progress_percent, stage: next_stage, focus_card_id: session.focus_card_id.clone(), anchor_content_json: session.anchor_content_json.clone(), creator_intent_json: session.creator_intent_json.clone(), creator_intent_readiness_json: next_readiness_json, anchor_pack_json: session.anchor_pack_json.clone(), lock_state_json: session.lock_state_json.clone(), draft_profile_json: session.draft_profile_json.clone(), last_assistant_reply: Some(assistant_reply), publish_gate_json: session.publish_gate_json.clone(), result_preview_json: session.result_preview_json.clone(), pending_clarifications_json: next_pending_clarifications_json, quality_findings_json: session.quality_findings_json.clone(), suggested_actions_json: session.suggested_actions_json.clone(), recommended_replies_json: session.recommended_replies_json.clone(), asset_coverage_json: session.asset_coverage_json.clone(), checkpoints_json: session.checkpoints_json.clone(), created_at: session.created_at, updated_at: submitted_at, }); get_custom_world_agent_operation_tx( ctx, CustomWorldAgentOperationGetInput { session_id: input.session_id, owner_user_id: input.owner_user_id, operation_id: input.operation_id, }, ) } fn get_custom_world_agent_operation_tx( ctx: &ReducerContext, input: CustomWorldAgentOperationGetInput, ) -> Result { validate_custom_world_agent_operation_get_input(&input).map_err(|error| error.to_string())?; ctx.db .custom_world_agent_session() .session_id() .find(&input.session_id) .filter(|row| row.owner_user_id == input.owner_user_id) .ok_or_else(|| "custom_world_agent_session 不存在".to_string())?; let operation = ctx .db .custom_world_agent_operation() .operation_id() .find(&input.operation_id) .filter(|row| row.session_id == input.session_id) .ok_or_else(|| "custom_world_agent_operation 不存在".to_string())?; Ok(build_custom_world_agent_operation_snapshot(&operation)) } // M5 Stage 2 先把 library profile upsert 固定成最小正式写入口;已发布作品在这里同步刷新 gallery 投影。 #[spacetimedb::reducer] pub fn upsert_custom_world_profile( ctx: &ReducerContext, input: CustomWorldProfileUpsertInput, ) -> Result<(), String> { upsert_custom_world_profile_record(ctx, input).map(|_| ()) } // procedure 面向 Axum 返回 profile 与可能同步出的 gallery 投影,避免 HTTP 层再二次查询私有表。 #[spacetimedb::procedure] pub fn upsert_custom_world_profile_and_return( ctx: &mut ProcedureContext, input: CustomWorldProfileUpsertInput, ) -> CustomWorldLibraryMutationResult { match ctx.try_with_tx(|tx| upsert_custom_world_profile_record(tx, input.clone())) { Ok((entry, gallery_entry)) => CustomWorldLibraryMutationResult { ok: true, entry: Some(entry), gallery_entry, error_message: None, }, Err(message) => CustomWorldLibraryMutationResult { ok: false, entry: None, gallery_entry: None, error_message: Some(message), }, } } // publish 负责同时推进 profile 发布态与 gallery 公开投影,避免公开列表继续运行时拼装。 #[spacetimedb::reducer] pub fn publish_custom_world_profile( ctx: &ReducerContext, input: CustomWorldProfilePublishInput, ) -> Result<(), String> { publish_custom_world_profile_record(ctx, input).map(|_| ()) } #[spacetimedb::procedure] pub fn publish_custom_world_profile_and_return( ctx: &mut ProcedureContext, input: CustomWorldProfilePublishInput, ) -> CustomWorldLibraryMutationResult { match ctx.try_with_tx(|tx| publish_custom_world_profile_record(tx, input.clone())) { Ok((entry, gallery_entry)) => CustomWorldLibraryMutationResult { ok: true, entry: Some(entry), gallery_entry, error_message: None, }, Err(message) => CustomWorldLibraryMutationResult { ok: false, entry: None, gallery_entry: None, error_message: Some(message), }, } } // unpublish 负责撤掉 gallery 投影,并把 profile 恢复为 draft。 #[spacetimedb::reducer] pub fn unpublish_custom_world_profile( ctx: &ReducerContext, input: CustomWorldProfileUnpublishInput, ) -> Result<(), String> { unpublish_custom_world_profile_record(ctx, input).map(|_| ()) } #[spacetimedb::procedure] pub fn unpublish_custom_world_profile_and_return( ctx: &mut ProcedureContext, input: CustomWorldProfileUnpublishInput, ) -> CustomWorldLibraryMutationResult { match ctx.try_with_tx(|tx| unpublish_custom_world_profile_record(tx, input.clone())) { Ok((entry, gallery_entry)) => CustomWorldLibraryMutationResult { ok: true, entry: Some(entry), gallery_entry, error_message: None, }, Err(message) => CustomWorldLibraryMutationResult { ok: false, entry: None, gallery_entry: None, error_message: Some(message), }, } } // 删除入口继续走 owner-only 软删除,不直接物理删除 profile 真相。 #[spacetimedb::procedure] pub fn delete_custom_world_profile_and_return( ctx: &mut ProcedureContext, input: CustomWorldProfileDeleteInput, ) -> CustomWorldProfileListResult { match ctx.try_with_tx(|tx| { delete_custom_world_profile_record(tx, input.clone())?; list_custom_world_profile_snapshots( tx, CustomWorldProfileListInput { owner_user_id: input.owner_user_id.clone(), }, ) }) { Ok(entries) => CustomWorldProfileListResult { ok: true, entries, error_message: None, }, Err(message) => CustomWorldProfileListResult { ok: false, entries: Vec::new(), error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn list_custom_world_profiles( ctx: &mut ProcedureContext, input: CustomWorldProfileListInput, ) -> CustomWorldProfileListResult { match ctx.try_with_tx(|tx| list_custom_world_profile_snapshots(tx, input.clone())) { Ok(entries) => CustomWorldProfileListResult { ok: true, entries, error_message: None, }, Err(message) => CustomWorldProfileListResult { ok: false, entries: Vec::new(), error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn list_custom_world_gallery_entries( ctx: &mut ProcedureContext, ) -> CustomWorldGalleryListResult { match ctx.try_with_tx(|tx| Ok::<_, String>(list_custom_world_gallery_snapshots(tx))) { Ok(entries) => CustomWorldGalleryListResult { ok: true, entries, error_message: None, }, Err(message) => CustomWorldGalleryListResult { ok: false, entries: Vec::new(), error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn get_custom_world_library_detail( ctx: &mut ProcedureContext, input: CustomWorldLibraryDetailInput, ) -> CustomWorldLibraryMutationResult { match ctx.try_with_tx(|tx| get_custom_world_library_detail_record(tx, input.clone())) { Ok((entry, gallery_entry)) => CustomWorldLibraryMutationResult { ok: true, entry, gallery_entry, error_message: None, }, Err(message) => CustomWorldLibraryMutationResult { ok: false, entry: None, gallery_entry: None, error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn get_custom_world_gallery_detail( ctx: &mut ProcedureContext, input: CustomWorldGalleryDetailInput, ) -> CustomWorldLibraryMutationResult { match ctx.try_with_tx(|tx| get_custom_world_gallery_detail_record(tx, input.clone())) { Ok((entry, gallery_entry)) => CustomWorldLibraryMutationResult { ok: true, entry, gallery_entry, error_message: None, }, Err(message) => CustomWorldLibraryMutationResult { ok: false, entry: None, gallery_entry: None, error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn list_custom_world_works( ctx: &mut ProcedureContext, input: CustomWorldWorksListInput, ) -> CustomWorldWorksListResult { match ctx.try_with_tx(|tx| list_custom_world_work_snapshots(tx, input.clone())) { Ok(items) => CustomWorldWorksListResult { ok: true, items, error_message: None, }, Err(message) => CustomWorldWorksListResult { ok: false, items: Vec::new(), error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn get_custom_world_agent_card_detail( ctx: &mut ProcedureContext, input: CustomWorldAgentCardDetailGetInput, ) -> CustomWorldDraftCardDetailResult { match ctx.try_with_tx(|tx| get_custom_world_agent_card_detail_tx(tx, input.clone())) { Ok(card) => CustomWorldDraftCardDetailResult { ok: true, card: Some(card), error_message: None, }, Err(message) => CustomWorldDraftCardDetailResult { ok: false, card: None, error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn execute_custom_world_agent_action( ctx: &mut ProcedureContext, input: CustomWorldAgentActionExecuteInput, ) -> CustomWorldAgentActionExecuteResult { match ctx.try_with_tx(|tx| execute_custom_world_agent_action_tx(tx, input.clone())) { Ok(operation) => CustomWorldAgentActionExecuteResult { ok: true, operation: Some(operation), error_message: None, }, Err(message) => CustomWorldAgentActionExecuteResult { ok: false, operation: None, error_message: Some(message), }, } } // procedure 面向 Axum 同步拉取浏览历史,继续沿用旧 Node 的 visitedAt 倒序输出语义。 #[spacetimedb::procedure] pub fn list_platform_browse_history( ctx: &mut ProcedureContext, input: RuntimeBrowseHistoryListInput, ) -> RuntimeBrowseHistoryProcedureResult { match ctx.try_with_tx(|tx| list_platform_browse_history_rows(tx, input.clone())) { Ok(entries) => RuntimeBrowseHistoryProcedureResult { ok: true, entries, error_message: None, }, Err(message) => RuntimeBrowseHistoryProcedureResult { ok: false, entries: Vec::new(), error_message: Some(message), }, } } // procedure 面向 Axum 承接 browse history 的单条/批量 POST,同步返回当前用户的完整列表。 #[spacetimedb::procedure] pub fn upsert_platform_browse_history_and_return( ctx: &mut ProcedureContext, input: RuntimeBrowseHistorySyncInput, ) -> RuntimeBrowseHistoryProcedureResult { match ctx.try_with_tx(|tx| upsert_platform_browse_history_rows(tx, input.clone())) { Ok(entries) => RuntimeBrowseHistoryProcedureResult { ok: true, entries, error_message: None, }, Err(message) => RuntimeBrowseHistoryProcedureResult { ok: false, entries: Vec::new(), error_message: Some(message), }, } } // procedure 面向 Axum 清空当前用户浏览历史,并直接返回空列表响应。 #[spacetimedb::procedure] pub fn clear_platform_browse_history_and_return( ctx: &mut ProcedureContext, input: RuntimeBrowseHistoryClearInput, ) -> RuntimeBrowseHistoryProcedureResult { match ctx.try_with_tx(|tx| clear_platform_browse_history_rows(tx, input.clone())) { Ok(entries) => RuntimeBrowseHistoryProcedureResult { ok: true, entries, error_message: None, }, Err(message) => RuntimeBrowseHistoryProcedureResult { ok: false, entries: Vec::new(), error_message: Some(message), }, } } // Stage 3 先把 published profile compile 作为独立 procedure 暴露,避免把编译逻辑和表写入、发布动作强耦合。 #[spacetimedb::procedure] pub fn compile_custom_world_published_profile( _ctx: &mut ProcedureContext, input: CustomWorldPublishedProfileCompileInput, ) -> CustomWorldPublishedProfileCompileResult { match build_custom_world_published_profile_compile_snapshot(input) { Ok(record) => CustomWorldPublishedProfileCompileResult { ok: true, record: Some(record), error_message: None, }, Err(error) => CustomWorldPublishedProfileCompileResult { ok: false, record: None, error_message: Some(error.to_string()), }, } } // Stage 4 把 publish_world 串成单事务主链:compile -> profile upsert -> profile publish -> session.stage 推进。 #[spacetimedb::procedure] pub fn publish_custom_world_world( ctx: &mut ProcedureContext, input: CustomWorldPublishWorldInput, ) -> CustomWorldPublishWorldResult { match ctx.try_with_tx(|tx| publish_custom_world_world_record(tx, input.clone())) { Ok((compiled_record, entry, gallery_entry, session_stage)) => { CustomWorldPublishWorldResult { ok: true, compiled_record: Some(compiled_record), entry: Some(entry), gallery_entry, session_stage: Some(session_stage), error_message: None, } } Err(message) => CustomWorldPublishWorldResult { ok: false, compiled_record: None, entry: None, gallery_entry: None, session_stage: None, error_message: Some(message), }, } } fn upsert_custom_world_profile_record( ctx: &ReducerContext, input: CustomWorldProfileUpsertInput, ) -> Result< ( CustomWorldProfileSnapshot, Option, ), String, > { validate_custom_world_profile_upsert_input(&input).map_err(|error| error.to_string())?; let updated_at = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros); let current = ctx .db .custom_world_profile() .profile_id() .find(&input.profile_id) .filter(|row| row.owner_user_id == input.owner_user_id) .or_else(|| { input .source_agent_session_id .as_ref() .and_then(|session_id| { ctx.db.custom_world_profile().iter().find(|row| { is_same_agent_draft_profile_candidate(row, &input.owner_user_id, session_id) }) }) }); let next_row = match current { Some(existing) => { ctx.db .custom_world_profile() .profile_id() .delete(&existing.profile_id); CustomWorldProfile { profile_id: existing.profile_id.clone(), owner_user_id: existing.owner_user_id.clone(), source_agent_session_id: input.source_agent_session_id.clone(), publication_status: existing.publication_status, world_name: input.world_name.clone(), subtitle: input.subtitle.clone(), summary_text: input.summary_text.clone(), theme_mode: input.theme_mode, cover_image_src: input.cover_image_src.clone(), profile_payload_json: input.profile_payload_json.clone(), playable_npc_count: input.playable_npc_count, landmark_count: input.landmark_count, author_display_name: input.author_display_name.clone(), published_at: existing.published_at, deleted_at: None, created_at: existing.created_at, updated_at, } } None => CustomWorldProfile { profile_id: input.profile_id.clone(), owner_user_id: input.owner_user_id.clone(), source_agent_session_id: input.source_agent_session_id.clone(), publication_status: CustomWorldPublicationStatus::Draft, world_name: input.world_name.clone(), subtitle: input.subtitle.clone(), summary_text: input.summary_text.clone(), theme_mode: input.theme_mode, cover_image_src: input.cover_image_src.clone(), profile_payload_json: input.profile_payload_json.clone(), playable_npc_count: input.playable_npc_count, landmark_count: input.landmark_count, author_display_name: input.author_display_name.clone(), published_at: None, deleted_at: None, created_at: updated_at, updated_at, }, }; let inserted = ctx.db.custom_world_profile().insert(next_row); let gallery_entry = if inserted.publication_status == CustomWorldPublicationStatus::Published { Some(sync_custom_world_gallery_entry_from_profile( ctx, &inserted, )?) } else { ctx.db .custom_world_gallery_entry() .profile_id() .delete(&inserted.profile_id); None }; Ok(( build_custom_world_profile_snapshot(&inserted), gallery_entry, )) } fn publish_custom_world_world_record( ctx: &ReducerContext, input: CustomWorldPublishWorldInput, ) -> Result< ( module_custom_world::CustomWorldPublishedProfileCompileSnapshot, CustomWorldProfileSnapshot, Option, RpgAgentStage, ), String, > { validate_custom_world_publish_world_input(&input).map_err(|error| error.to_string())?; let compiled_record = build_custom_world_published_profile_compile_snapshot( CustomWorldPublishedProfileCompileInput { session_id: input.session_id.clone(), profile_id: input.profile_id.clone(), owner_user_id: input.owner_user_id.clone(), draft_profile_json: input.draft_profile_json.clone(), legacy_result_profile_json: input.legacy_result_profile_json.clone(), setting_text: input.setting_text.clone(), author_display_name: input.author_display_name.clone(), updated_at_micros: input.published_at_micros, }, ) .map_err(|error| error.to_string())?; let _ = upsert_custom_world_profile_record( ctx, CustomWorldProfileUpsertInput { profile_id: compiled_record.profile_id.clone(), owner_user_id: compiled_record.owner_user_id.clone(), source_agent_session_id: Some(input.session_id.clone()), world_name: compiled_record.world_name.clone(), subtitle: compiled_record.subtitle.clone(), summary_text: compiled_record.summary_text.clone(), theme_mode: compiled_record.theme_mode, cover_image_src: compiled_record.cover_image_src.clone(), profile_payload_json: compiled_record.compiled_profile_payload_json.clone(), playable_npc_count: compiled_record.playable_npc_count, landmark_count: compiled_record.landmark_count, author_display_name: compiled_record.author_display_name.clone(), updated_at_micros: input.published_at_micros, }, )?; let (entry, gallery_entry) = publish_custom_world_profile_record( ctx, CustomWorldProfilePublishInput { profile_id: compiled_record.profile_id.clone(), owner_user_id: compiled_record.owner_user_id.clone(), author_display_name: compiled_record.author_display_name.clone(), published_at_micros: input.published_at_micros, }, )?; let session_stage = mark_custom_world_agent_session_published( ctx, &input.session_id, &input.owner_user_id, input.published_at_micros, )?; Ok((compiled_record, entry, gallery_entry, session_stage)) } fn publish_custom_world_profile_record( ctx: &ReducerContext, input: CustomWorldProfilePublishInput, ) -> Result< ( CustomWorldProfileSnapshot, Option, ), String, > { validate_custom_world_profile_publish_input(&input).map_err(|error| error.to_string())?; let existing = ctx .db .custom_world_profile() .profile_id() .find(&input.profile_id) .filter(|row| row.owner_user_id == input.owner_user_id) .ok_or_else(|| "custom_world_profile 不存在,无法发布".to_string())?; let published_at = Timestamp::from_micros_since_unix_epoch(input.published_at_micros); ctx.db .custom_world_profile() .profile_id() .delete(&existing.profile_id); let next_row = CustomWorldProfile { profile_id: existing.profile_id.clone(), owner_user_id: existing.owner_user_id.clone(), source_agent_session_id: existing.source_agent_session_id.clone(), publication_status: CustomWorldPublicationStatus::Published, world_name: existing.world_name.clone(), subtitle: existing.subtitle.clone(), summary_text: existing.summary_text.clone(), theme_mode: existing.theme_mode, cover_image_src: existing.cover_image_src.clone(), profile_payload_json: existing.profile_payload_json.clone(), playable_npc_count: existing.playable_npc_count, landmark_count: existing.landmark_count, author_display_name: input.author_display_name.clone(), published_at: Some(published_at), deleted_at: None, created_at: existing.created_at, updated_at: published_at, }; let inserted = ctx.db.custom_world_profile().insert(next_row); let gallery_entry = sync_custom_world_gallery_entry_from_profile(ctx, &inserted)?; Ok(( build_custom_world_profile_snapshot(&inserted), Some(gallery_entry), )) } fn unpublish_custom_world_profile_record( ctx: &ReducerContext, input: CustomWorldProfileUnpublishInput, ) -> Result< ( CustomWorldProfileSnapshot, Option, ), String, > { validate_custom_world_profile_unpublish_input(&input).map_err(|error| error.to_string())?; let existing = ctx .db .custom_world_profile() .profile_id() .find(&input.profile_id) .filter(|row| row.owner_user_id == input.owner_user_id) .ok_or_else(|| "custom_world_profile 不存在,无法取消发布".to_string())?; let updated_at = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros); ctx.db .custom_world_profile() .profile_id() .delete(&existing.profile_id); ctx.db .custom_world_gallery_entry() .profile_id() .delete(&existing.profile_id); let next_row = CustomWorldProfile { profile_id: existing.profile_id.clone(), owner_user_id: existing.owner_user_id.clone(), source_agent_session_id: existing.source_agent_session_id.clone(), publication_status: CustomWorldPublicationStatus::Draft, world_name: existing.world_name.clone(), subtitle: existing.subtitle.clone(), summary_text: existing.summary_text.clone(), theme_mode: existing.theme_mode, cover_image_src: existing.cover_image_src.clone(), profile_payload_json: existing.profile_payload_json.clone(), playable_npc_count: existing.playable_npc_count, landmark_count: existing.landmark_count, author_display_name: input.author_display_name.clone(), published_at: None, deleted_at: None, created_at: existing.created_at, updated_at, }; let inserted = ctx.db.custom_world_profile().insert(next_row); Ok((build_custom_world_profile_snapshot(&inserted), None)) } fn delete_custom_world_profile_record( ctx: &ReducerContext, input: CustomWorldProfileDeleteInput, ) -> Result<(), String> { validate_custom_world_profile_delete_input(&input).map_err(|error| error.to_string())?; let Some(existing) = ctx .db .custom_world_profile() .profile_id() .find(&input.profile_id) .filter(|row| row.owner_user_id == input.owner_user_id) else { return Ok(()); }; if existing.deleted_at.is_some() { return Ok(()); } let deleted_at = Timestamp::from_micros_since_unix_epoch(input.deleted_at_micros); ctx.db .custom_world_profile() .profile_id() .delete(&existing.profile_id); ctx.db .custom_world_gallery_entry() .profile_id() .delete(&existing.profile_id); let next_row = CustomWorldProfile { profile_id: existing.profile_id.clone(), owner_user_id: existing.owner_user_id.clone(), source_agent_session_id: existing.source_agent_session_id.clone(), publication_status: CustomWorldPublicationStatus::Draft, world_name: existing.world_name.clone(), subtitle: existing.subtitle.clone(), summary_text: existing.summary_text.clone(), theme_mode: existing.theme_mode, cover_image_src: existing.cover_image_src.clone(), profile_payload_json: existing.profile_payload_json.clone(), playable_npc_count: existing.playable_npc_count, landmark_count: existing.landmark_count, author_display_name: existing.author_display_name.clone(), published_at: None, deleted_at: Some(deleted_at), created_at: existing.created_at, updated_at: deleted_at, }; let _ = ctx.db.custom_world_profile().insert(next_row); Ok(()) } fn list_custom_world_profile_snapshots( ctx: &ReducerContext, input: CustomWorldProfileListInput, ) -> Result, String> { validate_custom_world_profile_list_input(&input).map_err(|error| error.to_string())?; let mut entries = ctx .db .custom_world_profile() .iter() .filter(|row| row.owner_user_id == input.owner_user_id && row.deleted_at.is_none()) .map(|row| build_custom_world_profile_snapshot(&row)) .collect::>(); entries.sort_by(|left, right| right.updated_at_micros.cmp(&left.updated_at_micros)); Ok(entries) } fn list_custom_world_gallery_snapshots( ctx: &ReducerContext, ) -> Vec { let mut entries = ctx .db .custom_world_gallery_entry() .iter() .map(|row| build_custom_world_gallery_entry_snapshot(&row)) .collect::>(); entries.sort_by(|left, right| { right .published_at_micros .cmp(&left.published_at_micros) .then(right.updated_at_micros.cmp(&left.updated_at_micros)) }); entries } fn get_custom_world_library_detail_record( ctx: &ReducerContext, input: CustomWorldLibraryDetailInput, ) -> Result< ( Option, Option, ), String, > { validate_custom_world_library_detail_input(&input).map_err(|error| error.to_string())?; let profile = ctx .db .custom_world_profile() .profile_id() .find(&input.profile_id) .filter(|row| row.owner_user_id == input.owner_user_id && row.deleted_at.is_none()); let gallery_entry = profile .as_ref() .filter(|row| row.publication_status == CustomWorldPublicationStatus::Published) .and_then(|row| { ctx.db .custom_world_gallery_entry() .profile_id() .find(&row.profile_id) .filter(|gallery_row| gallery_row.owner_user_id == row.owner_user_id) }); Ok(( profile.as_ref().map(build_custom_world_profile_snapshot), gallery_entry .as_ref() .map(build_custom_world_gallery_entry_snapshot), )) } fn get_custom_world_gallery_detail_record( ctx: &ReducerContext, input: CustomWorldGalleryDetailInput, ) -> Result< ( Option, Option, ), String, > { validate_custom_world_gallery_detail_input(&input).map_err(|error| error.to_string())?; let profile = ctx .db .custom_world_profile() .profile_id() .find(&input.profile_id) .filter(|row| { row.owner_user_id == input.owner_user_id && row.publication_status == CustomWorldPublicationStatus::Published && row.deleted_at.is_none() }); let gallery_entry = ctx .db .custom_world_gallery_entry() .profile_id() .find(&input.profile_id) .filter(|row| row.owner_user_id == input.owner_user_id); Ok(( profile.as_ref().map(build_custom_world_profile_snapshot), gallery_entry .as_ref() .map(build_custom_world_gallery_entry_snapshot), )) } fn list_custom_world_work_snapshots( ctx: &ReducerContext, input: CustomWorldWorksListInput, ) -> Result, String> { validate_custom_world_works_list_input(&input).map_err(|error| error.to_string())?; let mut items = Vec::new(); for session in ctx.db.custom_world_agent_session().iter().filter(|row| { row.owner_user_id == input.owner_user_id && row.stage != RpgAgentStage::Published }) { let gate = build_custom_world_publish_gate_from_session(&session); let draft_profile = parse_optional_session_object(session.draft_profile_json.as_deref()); let title = resolve_session_work_title(&session, draft_profile.as_ref()); let summary = resolve_session_work_summary(&session, draft_profile.as_ref()); let stage_label = Some(resolve_rpg_agent_stage_label(session.stage).to_string()); let subtitle = resolve_session_work_subtitle(draft_profile.as_ref(), stage_label.as_deref()); let (playable_npc_count, landmark_count) = resolve_session_work_counts(ctx, &session, draft_profile.as_ref()); items.push(CustomWorldWorkSummarySnapshot { work_id: format!("draft:{}", session.session_id), source_type: "agent_session".to_string(), status: "draft".to_string(), title, subtitle, summary, cover_image_src: resolve_session_work_cover_image_src(draft_profile.as_ref()), cover_render_mode: None, cover_character_image_srcs_json: "[]".to_string(), updated_at_micros: session.updated_at.to_micros_since_unix_epoch(), published_at_micros: None, stage: Some(session.stage), stage_label, playable_npc_count, landmark_count, role_visual_ready_count: None, role_animation_ready_count: None, role_asset_summary_label: None, session_id: Some(session.session_id.clone()), profile_id: None, can_resume: true, can_enter_world: gate.can_enter_world, blocker_count: gate.blocker_count, publish_ready: gate.publish_ready, }); } for profile in ctx .db .custom_world_profile() .iter() .filter(|row| row.owner_user_id == input.owner_user_id && row.deleted_at.is_none()) { items.push(CustomWorldWorkSummarySnapshot { work_id: format!("published:{}", profile.profile_id), source_type: "published_profile".to_string(), status: profile.publication_status.as_str().to_string(), title: profile.world_name.clone(), subtitle: profile.subtitle.clone(), summary: profile.summary_text.clone(), cover_image_src: profile.cover_image_src.clone(), cover_render_mode: None, cover_character_image_srcs_json: "[]".to_string(), updated_at_micros: profile.updated_at.to_micros_since_unix_epoch(), published_at_micros: profile .published_at .map(|value| value.to_micros_since_unix_epoch()), stage: None, stage_label: None, playable_npc_count: profile.playable_npc_count, landmark_count: profile.landmark_count, role_visual_ready_count: None, role_animation_ready_count: None, role_asset_summary_label: None, session_id: profile.source_agent_session_id.clone(), profile_id: Some(profile.profile_id.clone()), can_resume: false, can_enter_world: profile.publication_status == CustomWorldPublicationStatus::Published, blocker_count: 0, publish_ready: true, }); } items.sort_by(|left, right| { right .updated_at_micros .cmp(&left.updated_at_micros) .then_with(|| { let left_rank = if left.source_type == "agent_session" { 0 } else { 1 }; let right_rank = if right.source_type == "agent_session" { 0 } else { 1 }; left_rank.cmp(&right_rank) }) .then(left.work_id.cmp(&right.work_id)) }); Ok(items) } fn get_custom_world_agent_card_detail_tx( ctx: &ReducerContext, input: CustomWorldAgentCardDetailGetInput, ) -> Result { validate_custom_world_agent_card_detail_get_input(&input).map_err(|error| error.to_string())?; ctx.db .custom_world_agent_session() .session_id() .find(&input.session_id) .filter(|row| row.owner_user_id == input.owner_user_id) .ok_or_else(|| "custom_world_agent_session 不存在".to_string())?; let card = ctx .db .custom_world_draft_card() .card_id() .find(&input.card_id) .filter(|row| row.session_id == input.session_id) .ok_or_else(|| "custom_world_draft_card 不存在".to_string())?; build_custom_world_draft_card_detail_snapshot(&card) } fn execute_custom_world_agent_action_tx( ctx: &ReducerContext, input: CustomWorldAgentActionExecuteInput, ) -> Result { validate_custom_world_agent_action_execute_input(&input).map_err(|error| error.to_string())?; let session = ctx .db .custom_world_agent_session() .session_id() .find(&input.session_id) .filter(|row| row.owner_user_id == input.owner_user_id) .ok_or_else(|| "custom_world_agent_session 不存在".to_string())?; if ctx .db .custom_world_agent_operation() .operation_id() .find(&input.operation_id) .is_some() { return Err("custom_world_agent_operation.operation_id 已存在".to_string()); } let payload = parse_optional_session_object(input.payload_json.as_deref()).unwrap_or_default(); match input.action.trim() { "draft_foundation" => execute_draft_foundation_action(ctx, &session, &input, &payload), "update_draft_card" => execute_update_draft_card_action(ctx, &session, &input, &payload), "sync_result_profile" => { execute_sync_result_profile_action(ctx, &session, &input, &payload) } "publish_world" => execute_publish_world_action(ctx, &session, &input, &payload), "revert_checkpoint" => execute_revert_checkpoint_action(ctx, &session, &input, &payload), "generate_characters" | "generate_landmarks" => { execute_generate_entities_action(ctx, &session, &input, &payload) } "delete_characters" | "delete_landmarks" => { execute_delete_entities_action(ctx, &session, &input, &payload) } "generate_role_assets" | "generate_scene_assets" => { execute_prepare_asset_studio_action(ctx, &session, &input, &payload) } "sync_role_assets" => execute_sync_role_assets_action(ctx, &session, &input, &payload), "sync_scene_assets" => execute_sync_scene_assets_action(ctx, &session, &input, &payload), "expand_long_tail" => execute_placeholder_custom_world_action(ctx, &session, &input), other => Err(format!("custom world action `{other}` 当前尚未支持")), } } fn execute_draft_foundation_action( ctx: &ReducerContext, session: &CustomWorldAgentSession, input: &CustomWorldAgentActionExecuteInput, payload: &JsonMap, ) -> Result { if session.progress_percent < 100 { return Err("draft_foundation requires progressPercent >= 100".to_string()); } let updated_at = input.submitted_at_micros; let draft_profile = payload .get("draftProfile") .and_then(JsonValue::as_object) .cloned() .ok_or_else(|| { "draft_foundation requires externally generated payload.draftProfile".to_string() })?; let draft_profile_json = serde_json::to_string(&JsonValue::Object(draft_profile.clone())) .map_err(|error| format!("draft_foundation 无法序列化 draft_profile_json: {error}"))?; let gate = summarize_publish_gate_from_json( &input.session_id, RpgAgentStage::ObjectRefining, Some(&draft_profile), &parse_json_array_or_empty(&session.quality_findings_json), ); let next_session = rebuild_custom_world_agent_session_row( session, CustomWorldAgentSessionPatch { progress_percent: Some(100), stage: Some(RpgAgentStage::ObjectRefining), draft_profile_json: Some(Some(draft_profile_json.clone())), last_assistant_reply: Some(Some( "世界底稿已整理完成,接下来可以继续细化卡片和发布预览。".to_string(), )), publish_gate_json: Some(Some(serialize_json_value(&publish_gate_to_json_value( &gate, ))?)), result_preview_json: Some(build_result_preview_json( Some(&draft_profile), &gate, &parse_json_array_or_empty(&session.quality_findings_json), updated_at, )?), checkpoints_json: Some(append_checkpoint_json( &session.checkpoints_json, &build_session_checkpoint_value("foundation-ready", "底稿整理完成", session), )?), updated_at_micros: Some(updated_at), ..CustomWorldAgentSessionPatch::default() }, )?; replace_custom_world_agent_session(ctx, session, next_session); upsert_world_foundation_card(ctx, &session.session_id, &draft_profile, updated_at)?; append_custom_world_action_result_message( ctx, &session.session_id, &input.operation_id, "已整理出第一版世界底稿,并同步生成世界基础卡片。", updated_at, ); let operation = build_and_insert_custom_world_operation( ctx, &input.operation_id, &session.session_id, RpgAgentOperationType::DraftFoundation, "底稿已整理", "第一版 foundation draft 已写入会话与世界卡。", updated_at, ); Ok(build_custom_world_agent_operation_snapshot(&operation)) } fn execute_update_draft_card_action( ctx: &ReducerContext, session: &CustomWorldAgentSession, input: &CustomWorldAgentActionExecuteInput, payload: &JsonMap, ) -> Result { ensure_refining_stage(session.stage, "update_draft_card")?; let card_id = read_required_payload_text(payload, "cardId", "update_draft_card requires cardId")?; let card = ctx .db .custom_world_draft_card() .card_id() .find(&card_id) .filter(|row| row.session_id == session.session_id) .ok_or_else(|| "update_draft_card target card does not exist".to_string())?; let sections = payload .get("sections") .and_then(JsonValue::as_array) .ok_or_else(|| "update_draft_card requires sections".to_string())?; if sections.is_empty() { return Err("update_draft_card requires sections".to_string()); } let mut detail_object = parse_optional_session_object(card.detail_payload_json.as_deref()).unwrap_or_default(); let mut detail_sections = detail_object .get("sections") .and_then(JsonValue::as_array) .cloned() .unwrap_or_else(|| build_fallback_card_sections_json(&card)); for patch in sections { let patch_object = patch .as_object() .ok_or_else(|| "update_draft_card.sections 必须是 object 数组".to_string())?; let section_id = read_required_payload_text( patch_object, "sectionId", "update_draft_card section.sectionId is required", )?; let value = patch_object .get("value") .and_then(JsonValue::as_str) .unwrap_or_default() .trim() .to_string(); let mut updated = false; for existing in &mut detail_sections { if existing.get("id").and_then(JsonValue::as_str) == Some(section_id.as_str()) { if let Some(object) = existing.as_object_mut() { object.insert("value".to_string(), JsonValue::String(value.clone())); } updated = true; break; } } if !updated { detail_sections.push(json!({ "id": section_id, "label": section_id, "value": value, })); } } detail_object.insert("id".to_string(), JsonValue::String(card.card_id.clone())); detail_object.insert( "kind".to_string(), JsonValue::String(card.kind.as_str().to_string()), ); detail_object.insert("title".to_string(), JsonValue::String(card.title.clone())); detail_object.insert( "sections".to_string(), JsonValue::Array(detail_sections.clone()), ); detail_object.insert( "linkedIds".to_string(), serde_json::from_str::(&card.linked_ids_json) .unwrap_or_else(|_| JsonValue::Array(Vec::new())), ); detail_object.insert("locked".to_string(), JsonValue::Bool(false)); detail_object.insert("editable".to_string(), JsonValue::Bool(false)); detail_object.insert( "editableSectionIds".to_string(), JsonValue::Array(Vec::new()), ); detail_object.insert("warningMessages".to_string(), JsonValue::Array(Vec::new())); let updated_title = extract_detail_section_value(&detail_sections, "title") .unwrap_or_else(|| card.title.clone()); let updated_subtitle = extract_detail_section_value(&detail_sections, "subtitle") .unwrap_or_else(|| card.subtitle.clone()); let updated_summary = extract_detail_section_value(&detail_sections, "summary") .unwrap_or_else(|| card.summary.clone()); let detail_payload_json = serde_json::to_string(&JsonValue::Object(detail_object)) .map_err(|error| format!("update_draft_card 无法序列化 detail_payload_json: {error}"))?; replace_custom_world_draft_card( ctx, &card, CustomWorldDraftCard { card_id: card.card_id.clone(), session_id: card.session_id.clone(), kind: card.kind, status: card.status, title: updated_title.clone(), subtitle: updated_subtitle.clone(), summary: updated_summary.clone(), linked_ids_json: card.linked_ids_json.clone(), warning_count: card.warning_count, asset_status: card.asset_status, asset_status_label: card.asset_status_label.clone(), detail_payload_json: Some(detail_payload_json), created_at: card.created_at, updated_at: Timestamp::from_micros_since_unix_epoch(input.submitted_at_micros), }, ); let next_session = sync_session_draft_profile_from_card_update( session, &card, &updated_title, &updated_subtitle, &updated_summary, input.submitted_at_micros, )?; replace_custom_world_agent_session(ctx, session, next_session); append_custom_world_action_result_message( ctx, &session.session_id, &input.operation_id, &format!("已更新卡片《{}》的草稿内容。", updated_title), input.submitted_at_micros, ); let operation = build_and_insert_custom_world_operation( ctx, &input.operation_id, &session.session_id, RpgAgentOperationType::UpdateDraftCard, "卡片已更新", &format!("卡片 {} 的 detail 与摘要字段已同步更新。", card_id), input.submitted_at_micros, ); Ok(build_custom_world_agent_operation_snapshot(&operation)) } fn execute_sync_result_profile_action( ctx: &ReducerContext, session: &CustomWorldAgentSession, input: &CustomWorldAgentActionExecuteInput, payload: &JsonMap, ) -> Result { ensure_refining_stage(session.stage, "sync_result_profile")?; let mut profile = payload .get("profile") .and_then(JsonValue::as_object) .cloned() .ok_or_else(|| "sync_result_profile requires profile".to_string())?; if let Some(stable_profile_id) = resolve_stable_agent_draft_profile_id(session) { // 结果页回写时必须沿用当前草稿的稳定身份,避免把同一草稿写成新条目。 profile.insert( "id".to_string(), JsonValue::String(stable_profile_id.clone()), ); upsert_nested_result_profile_id(&mut profile, &stable_profile_id); } let draft_profile = ensure_minimal_draft_profile(profile, &session.seed_text); let gate = summarize_publish_gate_from_json( &session.session_id, session.stage, Some(&draft_profile), &parse_json_array_or_empty(&session.quality_findings_json), ); let next_session = rebuild_custom_world_agent_session_row( session, CustomWorldAgentSessionPatch { draft_profile_json: Some(Some(serialize_json_value(&JsonValue::Object( draft_profile.clone(), ))?)), last_assistant_reply: Some(Some("结果页草稿已同步回当前会话。".to_string())), publish_gate_json: Some(Some(serialize_json_value(&publish_gate_to_json_value( &gate, ))?)), result_preview_json: Some(build_result_preview_json( Some(&draft_profile), &gate, &parse_json_array_or_empty(&session.quality_findings_json), input.submitted_at_micros, )?), checkpoints_json: Some(append_checkpoint_json( &session.checkpoints_json, &build_session_checkpoint_value("sync-result-profile", "同步结果页草稿", session), )?), updated_at_micros: Some(input.submitted_at_micros), ..CustomWorldAgentSessionPatch::default() }, )?; replace_custom_world_agent_session(ctx, session, next_session); append_custom_world_action_result_message( ctx, &session.session_id, &input.operation_id, "结果页 profile 已回写当前会话,并重建预览。", input.submitted_at_micros, ); let operation = build_and_insert_custom_world_operation( ctx, &input.operation_id, &session.session_id, RpgAgentOperationType::SyncResultProfile, "结果页已同步", "draft_profile_json 与 result_preview 已更新。", input.submitted_at_micros, ); Ok(build_custom_world_agent_operation_snapshot(&operation)) } fn resolve_stable_agent_draft_profile_id(session: &CustomWorldAgentSession) -> Option { parse_optional_session_object(session.draft_profile_json.as_deref()) .and_then(|profile| read_optional_text_field(&profile, &["legacyResultProfile.id", "id"])) } fn upsert_nested_result_profile_id( profile: &mut JsonMap, stable_profile_id: &str, ) { let legacy_result_profile = profile .entry("legacyResultProfile".to_string()) .or_insert_with(|| JsonValue::Object(JsonMap::new())); if let Some(object) = legacy_result_profile.as_object_mut() { object.insert( "id".to_string(), JsonValue::String(stable_profile_id.to_string()), ); } } fn is_same_agent_draft_profile_candidate( row: &CustomWorldProfile, owner_user_id: &str, source_agent_session_id: &str, ) -> bool { row.owner_user_id == owner_user_id && row.deleted_at.is_none() && row.publication_status == CustomWorldPublicationStatus::Draft && row.source_agent_session_id.as_deref() == Some(source_agent_session_id) } fn execute_publish_world_action( ctx: &ReducerContext, session: &CustomWorldAgentSession, input: &CustomWorldAgentActionExecuteInput, payload: &JsonMap, ) -> Result { ensure_publishable_stage(session.stage, "publish_world")?; let draft_profile = if let Some(explicit) = payload.get("draftProfile").and_then(JsonValue::as_object) { explicit.clone() } else { parse_optional_session_object(session.draft_profile_json.as_deref()) .ok_or_else(|| "publish_world requires draft_profile_json".to_string())? }; let gate = summarize_publish_gate_from_json( &session.session_id, session.stage, Some(&draft_profile), &parse_json_array_or_empty(&session.quality_findings_json), ); if !gate.publish_ready { return Err(format!( "当前世界仍有 {} 个 blocker,暂时不能发布", gate.blocker_count )); } let profile_id = payload .get("profileId") .and_then(JsonValue::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) .unwrap_or_else(|| gate.profile_id.clone()); let setting_text = payload .get("settingText") .and_then(JsonValue::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) .unwrap_or_else(|| session.seed_text.clone()); let legacy_result_profile_json = payload .get("legacyResultProfile") .map(serialize_json_value) .transpose()?; let publish_result = publish_custom_world_world_record( ctx, CustomWorldPublishWorldInput { session_id: session.session_id.clone(), profile_id, owner_user_id: session.owner_user_id.clone(), draft_profile_json: serialize_json_value(&JsonValue::Object(draft_profile.clone()))?, legacy_result_profile_json, setting_text, author_display_name: "创作者".to_string(), published_at_micros: input.submitted_at_micros, }, )?; append_custom_world_action_result_message( ctx, &session.session_id, &input.operation_id, &format!("正式世界档案已发布:{}。", publish_result.1.profile_id), input.submitted_at_micros, ); let operation = build_and_insert_custom_world_operation( ctx, &input.operation_id, &session.session_id, RpgAgentOperationType::PublishWorld, "世界已发布", &format!( "正式世界档案已写入作品库:{}。", publish_result.1.profile_id ), input.submitted_at_micros, ); Ok(build_custom_world_agent_operation_snapshot(&operation)) } fn execute_revert_checkpoint_action( ctx: &ReducerContext, session: &CustomWorldAgentSession, input: &CustomWorldAgentActionExecuteInput, payload: &JsonMap, ) -> Result { ensure_long_tail_stage(session.stage, "revert_checkpoint")?; let checkpoint_id = read_required_payload_text( payload, "checkpointId", "revert_checkpoint requires checkpointId", )?; let checkpoint = parse_json_array_or_empty(&session.checkpoints_json) .into_iter() .find(|entry| { entry .get("checkpointId") .and_then(JsonValue::as_str) .map(str::trim) == Some(checkpoint_id.as_str()) }) .ok_or_else(|| "revert_checkpoint target checkpoint does not exist".to_string())?; let snapshot = checkpoint .get("snapshot") .and_then(JsonValue::as_object) .cloned() .ok_or_else(|| { "revert_checkpoint target checkpoint does not contain a restorable snapshot".to_string() })?; let restored_stage = snapshot .get("stage") .and_then(JsonValue::as_str) .and_then(parse_rpg_agent_stage) .unwrap_or(session.stage); let restored_progress = snapshot .get("progressPercent") .and_then(JsonValue::as_u64) .and_then(|value| u32::try_from(value).ok()) .unwrap_or(session.progress_percent); let restored_draft_profile = snapshot .get("draftProfile") .and_then(JsonValue::as_object) .cloned(); let restored_quality_findings = snapshot .get("qualityFindings") .and_then(JsonValue::as_array) .cloned() .unwrap_or_else(Vec::new); let gate = summarize_publish_gate_from_json( &session.session_id, restored_stage, restored_draft_profile.as_ref(), &restored_quality_findings, ); let next_session = rebuild_custom_world_agent_session_row( session, CustomWorldAgentSessionPatch { progress_percent: Some(restored_progress), stage: Some(restored_stage), draft_profile_json: Some( restored_draft_profile .as_ref() .map(|value| serialize_json_value(&JsonValue::Object(value.clone()))) .transpose()?, ), last_assistant_reply: Some(Some( "已恢复到所选 checkpoint 的世界草稿状态。".to_string(), )), quality_findings_json: Some(serialize_json_value(&JsonValue::Array( restored_quality_findings, ))?), publish_gate_json: Some(Some(serialize_json_value(&publish_gate_to_json_value( &gate, ))?)), result_preview_json: Some(build_result_preview_json( restored_draft_profile.as_ref(), &gate, &parse_json_array_or_empty(&serialize_json_value(&JsonValue::Array( snapshot .get("qualityFindings") .and_then(JsonValue::as_array) .cloned() .unwrap_or_else(Vec::new), ))?), input.submitted_at_micros, )?), updated_at_micros: Some(input.submitted_at_micros), ..CustomWorldAgentSessionPatch::default() }, )?; replace_custom_world_agent_session(ctx, session, next_session); append_custom_world_action_result_message( ctx, &session.session_id, &input.operation_id, "已恢复到所选 checkpoint。", input.submitted_at_micros, ); let operation = build_and_insert_custom_world_operation( ctx, &input.operation_id, &session.session_id, RpgAgentOperationType::RevertCheckpoint, "已回滚 checkpoint", &format!("会话已恢复到 checkpoint {}。", checkpoint_id), input.submitted_at_micros, ); Ok(build_custom_world_agent_operation_snapshot(&operation)) } fn execute_generate_entities_action( ctx: &ReducerContext, session: &CustomWorldAgentSession, input: &CustomWorldAgentActionExecuteInput, payload: &JsonMap, ) -> Result { ensure_draft_refining_stage(session.stage, input.action.as_str())?; let mut draft_profile = parse_optional_session_object(session.draft_profile_json.as_deref()) .ok_or_else(|| format!("{} requires an existing draft foundation", input.action))?; let (payload_key, profile_key, card_kind, operation_type, checkpoint_label, message_prefix) = match input.action.as_str() { "generate_characters" => ( "generatedCharacters", if payload.get("roleType").and_then(JsonValue::as_str) == Some("playable") { "playableNpcs" } else { "storyNpcs" }, RpgAgentDraftCardKind::Character, RpgAgentOperationType::GenerateCharacters, if payload.get("roleType").and_then(JsonValue::as_str) == Some("playable") { "新增可扮演角色" } else { "新增场景角色" }, if payload.get("roleType").and_then(JsonValue::as_str) == Some("playable") { "已补出新可扮演角色" } else { "已补出新场景角色" }, ), "generate_landmarks" => ( "generatedLandmarks", "landmarks", RpgAgentDraftCardKind::Landmark, RpgAgentOperationType::GenerateLandmarks, "新增地点", "已补出新地点", ), other => return Err(format!("unsupported generated entity action: {other}")), }; let generated_entities = payload .get(payload_key) .and_then(JsonValue::as_array) .cloned() .ok_or_else(|| format!("{} requires payload.{payload_key}", input.action))?; if generated_entities.is_empty() { return Err(format!("{} generated entity list is empty", input.action)); } let profile_entities = draft_profile .entry(profile_key.to_string()) .or_insert_with(|| JsonValue::Array(Vec::new())) .as_array_mut() .ok_or_else(|| format!("draftProfile.{profile_key} must be array"))?; let mut inserted_names = Vec::new(); for entity in generated_entities { let normalized_entity = ensure_generated_entity_id(entity, card_kind, profile_entities.len()); if let Some(name) = normalized_entity .get("name") .and_then(JsonValue::as_str) .map(str::trim) .filter(|value| !value.is_empty()) { inserted_names.push(name.to_string()); } upsert_generated_entity_card( ctx, &session.session_id, card_kind, &normalized_entity, input.submitted_at_micros, )?; profile_entities.push(normalized_entity); } let gate = summarize_publish_gate_from_json( &input.session_id, session.stage, Some(&draft_profile), &parse_json_array_or_empty(&session.quality_findings_json), ); let draft_profile_json = serialize_json_value(&JsonValue::Object(draft_profile.clone()))?; let next_session = rebuild_custom_world_agent_session_row( session, CustomWorldAgentSessionPatch { draft_profile_json: Some(Some(draft_profile_json)), focus_card_id: Some( inserted_names .first() .map(|name| build_generated_entity_card_id(card_kind, name, 0)), ), last_assistant_reply: Some(Some(format!( "{} {} 个,已同步到草稿卡片列表。", message_prefix, inserted_names.len() ))), publish_gate_json: Some(Some(serialize_json_value(&publish_gate_to_json_value( &gate, ))?)), result_preview_json: Some(build_result_preview_json( Some(&draft_profile), &gate, &parse_json_array_or_empty(&session.quality_findings_json), input.submitted_at_micros, )?), checkpoints_json: Some(append_checkpoint_json( &session.checkpoints_json, &build_session_checkpoint_value( &format!("{}-{}", input.action, input.operation_id), &format!("{} {} 个", checkpoint_label, inserted_names.len()), session, ), )?), updated_at_micros: Some(input.submitted_at_micros), ..CustomWorldAgentSessionPatch::default() }, )?; replace_custom_world_agent_session(ctx, session, next_session); append_custom_world_action_result_message( ctx, &session.session_id, &input.operation_id, &format!( "{}:{}。", message_prefix, if inserted_names.is_empty() { "无新增对象".to_string() } else { inserted_names.join("、") } ), input.submitted_at_micros, ); let operation = build_and_insert_custom_world_operation( ctx, &input.operation_id, &session.session_id, operation_type, checkpoint_label, &format!("{} {} 个。", message_prefix, inserted_names.len()), input.submitted_at_micros, ); Ok(build_custom_world_agent_operation_snapshot(&operation)) } fn execute_delete_entities_action( ctx: &ReducerContext, session: &CustomWorldAgentSession, input: &CustomWorldAgentActionExecuteInput, payload: &JsonMap, ) -> Result { ensure_draft_refining_stage(session.stage, input.action.as_str())?; let mut draft_profile = parse_optional_session_object(session.draft_profile_json.as_deref()) .ok_or_else(|| format!("{} requires an existing draft foundation", input.action))?; let (ids, operation_type, label, message_prefix) = match input.action.as_str() { "delete_characters" => ( read_payload_string_array(payload, "roleIds"), RpgAgentOperationType::DeleteCharacters, "删除角色", "已删除角色", ), "delete_landmarks" => ( read_payload_string_array(payload, "sceneIds"), RpgAgentOperationType::DeleteLandmarks, "删除场景", "已删除场景", ), other => return Err(format!("unsupported delete entity action: {other}")), }; if ids.is_empty() { return Err(format!("{} requires non-empty ids", input.action)); } let removed_names = if input.action == "delete_characters" { let mut names = remove_profile_entities_by_ids(&mut draft_profile, "playableNpcs", &ids)?; names.extend(remove_profile_entities_by_ids(&mut draft_profile, "storyNpcs", &ids)?); names } else { let names = remove_profile_entities_by_ids(&mut draft_profile, "landmarks", &ids)?; remove_deleted_landmark_connections(&mut draft_profile, &ids); names }; for id in &ids { delete_draft_card_by_entity_id(ctx, &session.session_id, id); } let gate = summarize_publish_gate_from_json( &input.session_id, session.stage, Some(&draft_profile), &parse_json_array_or_empty(&session.quality_findings_json), ); let next_session = rebuild_custom_world_agent_session_row( session, CustomWorldAgentSessionPatch { draft_profile_json: Some(Some(serialize_json_value(&JsonValue::Object( draft_profile.clone(), ))?)), focus_card_id: Some(None), last_assistant_reply: Some(Some(format!( "{} {} 个,已同步更新草稿。", message_prefix, removed_names.len() ))), publish_gate_json: Some(Some(serialize_json_value(&publish_gate_to_json_value( &gate, ))?)), result_preview_json: Some(build_result_preview_json( Some(&draft_profile), &gate, &parse_json_array_or_empty(&session.quality_findings_json), input.submitted_at_micros, )?), checkpoints_json: Some(append_checkpoint_json( &session.checkpoints_json, &build_session_checkpoint_value( &format!("{}-{}", input.action, input.operation_id), &format!("{} {} 个", label, removed_names.len()), session, ), )?), updated_at_micros: Some(input.submitted_at_micros), ..CustomWorldAgentSessionPatch::default() }, )?; replace_custom_world_agent_session(ctx, session, next_session); append_custom_world_action_result_message( ctx, &session.session_id, &input.operation_id, &format!( "{}:{}。", message_prefix, if removed_names.is_empty() { ids.join("、") } else { removed_names.join("、") } ), input.submitted_at_micros, ); let operation = build_and_insert_custom_world_operation( ctx, &input.operation_id, &session.session_id, operation_type, label, &format!("{} {} 个。", message_prefix, ids.len()), input.submitted_at_micros, ); Ok(build_custom_world_agent_operation_snapshot(&operation)) } fn execute_prepare_asset_studio_action( ctx: &ReducerContext, session: &CustomWorldAgentSession, input: &CustomWorldAgentActionExecuteInput, payload: &JsonMap, ) -> Result { ensure_draft_refining_stage(session.stage, input.action.as_str())?; let draft_profile = parse_optional_session_object(session.draft_profile_json.as_deref()) .ok_or_else(|| format!("{} requires an existing draft foundation", input.action))?; let (focus_id, operation_type, message_text, phase_label, phase_detail) = if input.action == "generate_role_assets" { let role_id = read_first_payload_text(payload, "roleIds", "roleId") .ok_or_else(|| "generate_role_assets requires roleIds".to_string())?; let role = find_profile_entity_by_id(&draft_profile, &["playableNpcs", "storyNpcs"], &role_id) .ok_or_else(|| "未找到目标角色,无法进入角色资产工坊。".to_string())?; let role_name = read_optional_text_field(role, &["name"]).unwrap_or_else(|| "角色".to_string()); ( role_id, RpgAgentOperationType::GenerateRoleAssets, format!("已为「{}」准备好角色资产工坊,先生成主图候选,再补核心动作。", role_name), "角色资产工坊已就绪", format!("「{}」现在可以开始生成主图和动作。", role_name), ) } else { let scene_id = read_first_payload_text(payload, "sceneIds", "sceneId") .ok_or_else(|| "generate_scene_assets requires sceneIds".to_string())?; let scene_kind = payload .get("sceneKind") .and_then(JsonValue::as_str) .map(str::trim) .unwrap_or("landmark"); let scene = if scene_kind == "camp" { draft_profile.get("camp").and_then(JsonValue::as_object) } else { find_profile_entity_by_id(&draft_profile, &["landmarks"], &scene_id) } .ok_or_else(|| "未找到目标场景,无法进入场景资产工坊。".to_string())?; let scene_name = read_optional_text_field(scene, &["name"]) .unwrap_or_else(|| if scene_kind == "camp" { "开局营地" } else { "未命名场景" }.to_string()); ( scene_id, RpgAgentOperationType::GenerateSceneAssets, format!("已为「{}」准备好场景图工坊,保存生成结果后会自动同步回当前草稿。", scene_name), "场景资产工坊已就绪", format!("「{}」现在可以继续生成和确认正式场景图。", scene_name), ) }; let next_session = rebuild_custom_world_agent_session_row( session, CustomWorldAgentSessionPatch { stage: Some(RpgAgentStage::VisualRefining), focus_card_id: Some(Some(focus_id)), last_assistant_reply: Some(Some(message_text.clone())), updated_at_micros: Some(input.submitted_at_micros), ..CustomWorldAgentSessionPatch::default() }, )?; replace_custom_world_agent_session(ctx, session, next_session); append_custom_world_action_result_message( ctx, &session.session_id, &input.operation_id, &message_text, input.submitted_at_micros, ); let operation = build_and_insert_custom_world_operation( ctx, &input.operation_id, &session.session_id, operation_type, phase_label, &phase_detail, input.submitted_at_micros, ); Ok(build_custom_world_agent_operation_snapshot(&operation)) } fn execute_sync_role_assets_action( ctx: &ReducerContext, session: &CustomWorldAgentSession, input: &CustomWorldAgentActionExecuteInput, payload: &JsonMap, ) -> Result { ensure_draft_refining_stage(session.stage, "sync_role_assets")?; let mut draft_profile = parse_optional_session_object(session.draft_profile_json.as_deref()) .ok_or_else(|| "sync_role_assets requires an existing draft foundation".to_string())?; let role_id = read_required_payload_text(payload, "roleId", "sync_role_assets requires roleId")?; let portrait_path = read_required_payload_text(payload, "portraitPath", "sync_role_assets requires portraitPath")?; let generated_visual_asset_id = read_required_payload_text( payload, "generatedVisualAssetId", "sync_role_assets requires generatedVisualAssetId", )?; let generated_animation_set_id = payload .get("generatedAnimationSetId") .and_then(JsonValue::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned); let animation_map = payload.get("animationMap").cloned(); let updated_role = apply_role_asset_publish_result( &mut draft_profile, &role_id, &portrait_path, &generated_visual_asset_id, generated_animation_set_id.as_deref(), animation_map, )?; let role_name = read_optional_text_field(&updated_role, &["name"]).unwrap_or_else(|| "当前角色".to_string()); let asset_status = resolve_role_asset_status(&updated_role); let asset_status_label = resolve_role_asset_status_label(asset_status).to_string(); upsert_asset_role_card( ctx, &session.session_id, &role_id, &updated_role, asset_status, &asset_status_label, input.submitted_at_micros, )?; let gate = summarize_publish_gate_from_json( &input.session_id, RpgAgentStage::VisualRefining, Some(&draft_profile), &parse_json_array_or_empty(&session.quality_findings_json), ); let next_session = rebuild_custom_world_agent_session_row( session, CustomWorldAgentSessionPatch { stage: Some(RpgAgentStage::VisualRefining), focus_card_id: Some(Some(role_id.clone())), draft_profile_json: Some(Some(serialize_json_value(&JsonValue::Object(draft_profile.clone()))?)), last_assistant_reply: Some(Some(format!( "已把「{}」的角色资产写回草稿,当前状态:{}。", role_name, asset_status_label ))), publish_gate_json: Some(Some(serialize_json_value(&publish_gate_to_json_value(&gate))?)), result_preview_json: Some(build_result_preview_json( Some(&draft_profile), &gate, &parse_json_array_or_empty(&session.quality_findings_json), input.submitted_at_micros, )?), checkpoints_json: Some(append_checkpoint_json( &session.checkpoints_json, &build_session_checkpoint_value( "sync-role-assets", &format!("同步角色资产 {}", role_name), session, ), )?), asset_coverage_json: Some(build_asset_coverage_json(&draft_profile)?), updated_at_micros: Some(input.submitted_at_micros), ..CustomWorldAgentSessionPatch::default() }, )?; replace_custom_world_agent_session(ctx, session, next_session); append_custom_world_action_result_message( ctx, &session.session_id, &input.operation_id, &format!("已把「{}」的角色资产写回草稿,当前状态:{}。", role_name, asset_status_label), input.submitted_at_micros, ); let operation = build_and_insert_custom_world_operation( ctx, &input.operation_id, &session.session_id, RpgAgentOperationType::SyncRoleAssets, "角色资产已同步", &format!("「{}」的资产状态已更新为{}。", role_name, asset_status_label), input.submitted_at_micros, ); Ok(build_custom_world_agent_operation_snapshot(&operation)) } fn execute_sync_scene_assets_action( ctx: &ReducerContext, session: &CustomWorldAgentSession, input: &CustomWorldAgentActionExecuteInput, payload: &JsonMap, ) -> Result { ensure_draft_refining_stage(session.stage, "sync_scene_assets")?; let mut draft_profile = parse_optional_session_object(session.draft_profile_json.as_deref()) .ok_or_else(|| "sync_scene_assets requires an existing draft foundation".to_string())?; let scene_id = read_required_payload_text(payload, "sceneId", "sync_scene_assets requires sceneId")?; let scene_kind = read_required_payload_text(payload, "sceneKind", "sync_scene_assets requires sceneKind")?; let image_src = read_required_payload_text(payload, "imageSrc", "sync_scene_assets requires imageSrc")?; let generated_scene_asset_id = read_required_payload_text( payload, "generatedSceneAssetId", "sync_scene_assets requires generatedSceneAssetId", )?; let generated_scene_prompt = payload.get("generatedScenePrompt").cloned().unwrap_or(JsonValue::Null); let generated_scene_model = payload.get("generatedSceneModel").cloned().unwrap_or(JsonValue::Null); let updated_scene = apply_scene_asset_publish_result( &mut draft_profile, &scene_id, &scene_kind, &image_src, &generated_scene_asset_id, generated_scene_prompt, generated_scene_model, )?; let scene_name = read_optional_text_field(&updated_scene, &["name"]) .unwrap_or_else(|| if scene_kind == "camp" { "开局营地" } else { "当前场景" }.to_string()); upsert_asset_scene_card( ctx, &session.session_id, &scene_id, &scene_kind, &updated_scene, input.submitted_at_micros, )?; let gate = summarize_publish_gate_from_json( &input.session_id, RpgAgentStage::VisualRefining, Some(&draft_profile), &parse_json_array_or_empty(&session.quality_findings_json), ); let next_session = rebuild_custom_world_agent_session_row( session, CustomWorldAgentSessionPatch { stage: Some(RpgAgentStage::VisualRefining), focus_card_id: Some(Some(scene_id.clone())), draft_profile_json: Some(Some(serialize_json_value(&JsonValue::Object(draft_profile.clone()))?)), last_assistant_reply: Some(Some(format!( "已把「{}」的场景图写回草稿,并同步刷新地点卡与幕背景状态。", scene_name ))), publish_gate_json: Some(Some(serialize_json_value(&publish_gate_to_json_value(&gate))?)), result_preview_json: Some(build_result_preview_json( Some(&draft_profile), &gate, &parse_json_array_or_empty(&session.quality_findings_json), input.submitted_at_micros, )?), checkpoints_json: Some(append_checkpoint_json( &session.checkpoints_json, &build_session_checkpoint_value( "sync-scene-assets", &format!("同步场景资产 {}", scene_name), session, ), )?), asset_coverage_json: Some(build_asset_coverage_json(&draft_profile)?), updated_at_micros: Some(input.submitted_at_micros), ..CustomWorldAgentSessionPatch::default() }, )?; replace_custom_world_agent_session(ctx, session, next_session); append_custom_world_action_result_message( ctx, &session.session_id, &input.operation_id, &format!("已把「{}」的场景图写回草稿,并同步刷新地点卡与幕背景状态。", scene_name), input.submitted_at_micros, ); let operation = build_and_insert_custom_world_operation( ctx, &input.operation_id, &session.session_id, RpgAgentOperationType::SyncSceneAssets, "场景资产已同步", &format!("「{}」的场景图已经进入当前草稿。", scene_name), input.submitted_at_micros, ); Ok(build_custom_world_agent_operation_snapshot(&operation)) } fn execute_placeholder_custom_world_action( ctx: &ReducerContext, session: &CustomWorldAgentSession, input: &CustomWorldAgentActionExecuteInput, ) -> Result { let operation_type = map_action_name_to_operation_type(input.action.as_str()) .ok_or_else(|| format!("action {} 无法映射到 operation type", input.action))?; append_custom_world_action_result_message( ctx, &session.session_id, &input.operation_id, &format!( "动作 {} 已接入最小兼容占位,后续会继续补真实编排。", input.action ), input.submitted_at_micros, ); let operation = build_and_insert_custom_world_operation( ctx, &input.operation_id, &session.session_id, operation_type, "动作已完成", &format!("{} 当前已走最小兼容闭环。", input.action), input.submitted_at_micros, ); Ok(build_custom_world_agent_operation_snapshot(&operation)) } #[derive(Clone, Debug, Default)] struct CustomWorldAgentSessionPatch { progress_percent: Option, stage: Option, focus_card_id: Option>, anchor_content_json: Option, creator_intent_json: Option>, creator_intent_readiness_json: Option, anchor_pack_json: Option>, lock_state_json: Option>, draft_profile_json: Option>, last_assistant_reply: Option>, publish_gate_json: Option>, result_preview_json: Option>, pending_clarifications_json: Option, quality_findings_json: Option, suggested_actions_json: Option, recommended_replies_json: Option, asset_coverage_json: Option, checkpoints_json: Option, updated_at_micros: Option, } fn build_custom_world_publish_gate_from_session( session: &CustomWorldAgentSession, ) -> CustomWorldPublishGateSnapshot { let quality_findings = parse_json_array_or_empty(&session.quality_findings_json); summarize_publish_gate_from_json( &session.session_id, session.stage, parse_optional_session_object(session.draft_profile_json.as_deref()).as_ref(), &quality_findings, ) } fn summarize_publish_gate_from_json( session_id: &str, stage: RpgAgentStage, draft_profile: Option<&JsonMap>, quality_findings: &[JsonValue], ) -> CustomWorldPublishGateSnapshot { let profile_id = draft_profile .and_then(|profile| read_optional_text_field(profile, &["legacyResultProfile.id", "id"])) .unwrap_or_else(|| format!("agent-draft-{session_id}")); let mut blockers = Vec::new(); if draft_profile.is_none() { blockers.push(CustomWorldPublishBlockerSnapshot { blocker_id: "publish_empty_draft".to_string(), code: "publish_empty_draft".to_string(), message: "当前世界草稿为空,无法发布。".to_string(), }); } if let Some(profile) = draft_profile { if read_optional_text_field( profile, &[ "worldHook", "creatorIntent.worldHook", "anchorContent.worldPromise.hook", "settingText", ], ) .is_none() { blockers.push(CustomWorldPublishBlockerSnapshot { blocker_id: "publish_missing_world_hook".to_string(), code: "publish_missing_world_hook".to_string(), message: "当前世界缺少 world hook,发布前需要先补齐世界一句话钩子。".to_string(), }); } if read_optional_text_field( profile, &[ "playerPremise", "creatorIntent.playerPremise", "anchorContent.playerEntryPoint.openingIdentity", "anchorContent.playerEntryPoint.openingProblem", "anchorContent.playerEntryPoint.entryMotivation", ], ) .is_none() { blockers.push(CustomWorldPublishBlockerSnapshot { blocker_id: "publish_missing_player_premise".to_string(), code: "publish_missing_player_premise".to_string(), message: "当前世界缺少玩家身份与切入前提,发布前需要先补齐玩家 premise。" .to_string(), }); } if !json_array_has_non_empty_text(profile.get("coreConflicts")) { blockers.push(CustomWorldPublishBlockerSnapshot { blocker_id: "publish_missing_core_conflict".to_string(), code: "publish_missing_core_conflict".to_string(), message: "当前世界缺少核心冲突,发布前需要先补齐核心冲突。".to_string(), }); } let has_main_chapter = profile .get("chapters") .and_then(JsonValue::as_array) .map(|value| !value.is_empty()) .unwrap_or(false) || profile .get("sceneChapterBlueprints") .and_then(JsonValue::as_array) .map(|value| !value.is_empty()) .unwrap_or(false) || profile .get("sceneChapters") .and_then(JsonValue::as_array) .map(|value| !value.is_empty()) .unwrap_or(false); if !has_main_chapter { blockers.push(CustomWorldPublishBlockerSnapshot { blocker_id: "publish_missing_main_chapter".to_string(), code: "publish_missing_main_chapter".to_string(), message: "当前世界还没有主线章节草稿,发布前至少要保留主线第一幕。".to_string(), }); } let has_scene_act = profile .get("sceneChapterBlueprints") .or_else(|| profile.get("sceneChapters")) .and_then(JsonValue::as_array) .map(|chapters| { chapters.iter().any(|chapter| { chapter .get("acts") .and_then(JsonValue::as_array) .map(|acts| !acts.is_empty()) .unwrap_or(false) }) }) .unwrap_or(false); if !has_scene_act { blockers.push(CustomWorldPublishBlockerSnapshot { blocker_id: "publish_missing_first_act".to_string(), code: "publish_missing_first_act".to_string(), message: "当前世界还没有主线第一幕,发布前至少要保留一个场景幕。".to_string(), }); } } for finding in quality_findings { if finding.get("severity").and_then(JsonValue::as_str) == Some("blocker") { blockers.push(CustomWorldPublishBlockerSnapshot { blocker_id: finding .get("id") .and_then(JsonValue::as_str) .unwrap_or("publish-quality-blocker") .to_string(), code: finding .get("code") .and_then(JsonValue::as_str) .unwrap_or("publish_quality_blocker") .to_string(), message: finding .get("message") .and_then(JsonValue::as_str) .unwrap_or("当前世界仍存在 blocker。") .to_string(), }); } } let blocker_count = blockers.len() as u32; let publish_ready = blocker_count == 0; CustomWorldPublishGateSnapshot { profile_id, blockers, blocker_count, publish_ready, can_enter_world: stage == RpgAgentStage::Published && publish_ready, } } fn publish_gate_to_json_value(gate: &CustomWorldPublishGateSnapshot) -> JsonValue { json!({ "profileId": gate.profile_id, "blockers": gate.blockers.iter().map(|entry| { json!({ "id": entry.blocker_id, "code": entry.code, "message": entry.message, }) }).collect::>(), "blockerCount": gate.blocker_count, "publishReady": gate.publish_ready, "canEnterWorld": gate.can_enter_world, }) } fn build_result_preview_json( draft_profile: Option<&JsonMap>, gate: &CustomWorldPublishGateSnapshot, quality_findings: &[JsonValue], generated_at_micros: i64, ) -> Result, String> { let Some(profile) = draft_profile else { return Ok(None); }; serialize_json_value(&json!({ "preview": JsonValue::Object(profile.clone()), "source": "session_preview", "generatedAt": format_timestamp_micros(generated_at_micros), "qualityFindings": quality_findings, "blockers": gate.blockers.iter().map(|entry| { json!({ "id": entry.blocker_id, "code": entry.code, "message": entry.message, }) }).collect::>(), "publishReady": gate.publish_ready, "canEnterWorld": gate.can_enter_world, })) .map(Some) } fn build_supported_actions_json( stage: RpgAgentStage, progress_percent: u32, gate: &CustomWorldPublishGateSnapshot, checkpoints: &[JsonValue], ) -> Vec { let has_checkpoint = checkpoints .iter() .any(|entry| entry.get("snapshot").is_some()); let draft_refining_enabled = matches!( stage, RpgAgentStage::ObjectRefining | RpgAgentStage::VisualRefining ); let long_tail_enabled = matches!( stage, RpgAgentStage::ObjectRefining | RpgAgentStage::VisualRefining | RpgAgentStage::LongTailReview | RpgAgentStage::ReadyToPublish ); vec![ build_supported_action_json( "draft_foundation", progress_percent >= 100, (progress_percent < 100).then(|| "draft_foundation requires progressPercent >= 100".to_string()), ), build_supported_action_json( "update_draft_card", draft_refining_enabled, (!draft_refining_enabled).then(|| { "update_draft_card is only available during object_refining or visual_refining" .to_string() }), ), build_supported_action_json( "sync_result_profile", draft_refining_enabled, (!draft_refining_enabled).then(|| { "sync_result_profile is only available during object_refining or visual_refining" .to_string() }), ), build_supported_action_json( "generate_characters", draft_refining_enabled, (!draft_refining_enabled).then(|| { "generate_characters is only available during object_refining or visual_refining" .to_string() }), ), build_supported_action_json( "generate_landmarks", draft_refining_enabled, (!draft_refining_enabled).then(|| { "generate_landmarks is only available during object_refining or visual_refining" .to_string() }), ), build_supported_action_json( "delete_characters", draft_refining_enabled, (!draft_refining_enabled).then(|| { "delete_characters is only available during object_refining or visual_refining" .to_string() }), ), build_supported_action_json( "delete_landmarks", draft_refining_enabled, (!draft_refining_enabled).then(|| { "delete_landmarks is only available during object_refining or visual_refining" .to_string() }), ), build_supported_action_json( "generate_role_assets", draft_refining_enabled, (!draft_refining_enabled).then(|| { "generate_role_assets is only available during object_refining or visual_refining" .to_string() }), ), build_supported_action_json( "sync_role_assets", draft_refining_enabled, (!draft_refining_enabled).then(|| { "sync_role_assets is only available during object_refining or visual_refining" .to_string() }), ), build_supported_action_json( "generate_scene_assets", draft_refining_enabled, (!draft_refining_enabled).then(|| { "generate_scene_assets is only available during object_refining or visual_refining" .to_string() }), ), build_supported_action_json( "sync_scene_assets", draft_refining_enabled, (!draft_refining_enabled).then(|| { "sync_scene_assets is only available during object_refining or visual_refining" .to_string() }), ), build_supported_action_json( "expand_long_tail", long_tail_enabled, (!long_tail_enabled).then(|| { "expand_long_tail is only available during object_refining, visual_refining, long_tail_review or ready_to_publish".to_string() }), ), build_supported_action_json( "publish_world", long_tail_enabled && gate.publish_ready, (!long_tail_enabled) .then(|| { "publish_world is only available during object_refining, visual_refining, long_tail_review or ready_to_publish".to_string() }) .or_else(|| (!gate.publish_ready).then(|| "publish_world requires publish gate without blockers".to_string())), ), build_supported_action_json( "revert_checkpoint", long_tail_enabled && has_checkpoint, (!long_tail_enabled) .then(|| { "revert_checkpoint is only available during object_refining, visual_refining, long_tail_review or ready_to_publish".to_string() }) .or_else(|| (!has_checkpoint).then(|| "revert_checkpoint requires at least one restorable checkpoint snapshot".to_string())), ), ] } fn build_supported_action_json(action: &str, enabled: bool, reason: Option) -> JsonValue { json!({ "action": action, "enabled": enabled, "reason": reason, }) } fn build_custom_world_draft_card_detail_snapshot( card: &CustomWorldDraftCard, ) -> Result { if let Some(detail_payload_json) = card.detail_payload_json.as_deref() { let detail_value = serde_json::from_str::(detail_payload_json).map_err(|error| { format!("custom_world_draft_card.detail_payload_json 非法: {error}") })?; if let Some(object) = detail_value.as_object() { let sections = object .get("sections") .and_then(JsonValue::as_array) .map(|entries| { entries .iter() .filter_map(|entry| { let object = entry.as_object()?; Some(CustomWorldDraftCardDetailSectionSnapshot { section_id: object.get("id")?.as_str()?.to_string(), label: object .get("label") .and_then(JsonValue::as_str) .unwrap_or_default() .to_string(), value: object .get("value") .and_then(JsonValue::as_str) .unwrap_or_default() .to_string(), }) }) .collect::>() }) .unwrap_or_else(|| build_fallback_card_sections(&card)); return Ok(CustomWorldDraftCardDetailSnapshot { card_id: card.card_id.clone(), kind: card.kind, title: object .get("title") .and_then(JsonValue::as_str) .unwrap_or(card.title.as_str()) .to_string(), sections, linked_ids_json: card.linked_ids_json.clone(), locked: object .get("locked") .and_then(JsonValue::as_bool) .unwrap_or(false), editable: object .get("editable") .and_then(JsonValue::as_bool) .unwrap_or(false), editable_section_ids_json: serialize_json_value( object .get("editableSectionIds") .unwrap_or(&JsonValue::Array(Vec::new())), )?, warning_messages_json: serialize_json_value( object .get("warningMessages") .unwrap_or(&JsonValue::Array(Vec::new())), )?, asset_status: card.asset_status, asset_status_label: card.asset_status_label.clone(), }); } } Ok(CustomWorldDraftCardDetailSnapshot { card_id: card.card_id.clone(), kind: card.kind, title: card.title.clone(), sections: build_fallback_card_sections(card), linked_ids_json: card.linked_ids_json.clone(), locked: false, editable: false, editable_section_ids_json: "[]".to_string(), warning_messages_json: "[]".to_string(), asset_status: card.asset_status, asset_status_label: card.asset_status_label.clone(), }) } fn build_fallback_card_sections( card: &CustomWorldDraftCard, ) -> Vec { vec![ CustomWorldDraftCardDetailSectionSnapshot { section_id: "title".to_string(), label: "标题".to_string(), value: card.title.clone(), }, CustomWorldDraftCardDetailSectionSnapshot { section_id: "subtitle".to_string(), label: "副标题".to_string(), value: card.subtitle.clone(), }, CustomWorldDraftCardDetailSectionSnapshot { section_id: "summary".to_string(), label: "摘要".to_string(), value: card.summary.clone(), }, ] } fn build_fallback_card_sections_json(card: &CustomWorldDraftCard) -> Vec { build_fallback_card_sections(card) .into_iter() .map(|section| { json!({ "id": section.section_id, "label": section.label, "value": section.value, }) }) .collect() } fn rebuild_custom_world_agent_session_row( current: &CustomWorldAgentSession, patch: CustomWorldAgentSessionPatch, ) -> Result { Ok(CustomWorldAgentSession { session_id: current.session_id.clone(), owner_user_id: current.owner_user_id.clone(), seed_text: current.seed_text.clone(), current_turn: current.current_turn, progress_percent: patch.progress_percent.unwrap_or(current.progress_percent), stage: patch.stage.unwrap_or(current.stage), focus_card_id: patch .focus_card_id .unwrap_or_else(|| current.focus_card_id.clone()), anchor_content_json: patch .anchor_content_json .unwrap_or_else(|| current.anchor_content_json.clone()), creator_intent_json: patch .creator_intent_json .unwrap_or_else(|| current.creator_intent_json.clone()), creator_intent_readiness_json: patch .creator_intent_readiness_json .unwrap_or_else(|| current.creator_intent_readiness_json.clone()), anchor_pack_json: patch .anchor_pack_json .unwrap_or_else(|| current.anchor_pack_json.clone()), lock_state_json: patch .lock_state_json .unwrap_or_else(|| current.lock_state_json.clone()), draft_profile_json: patch .draft_profile_json .unwrap_or_else(|| current.draft_profile_json.clone()), last_assistant_reply: patch .last_assistant_reply .unwrap_or_else(|| current.last_assistant_reply.clone()), publish_gate_json: patch .publish_gate_json .unwrap_or_else(|| current.publish_gate_json.clone()), result_preview_json: patch .result_preview_json .unwrap_or_else(|| current.result_preview_json.clone()), pending_clarifications_json: patch .pending_clarifications_json .unwrap_or_else(|| current.pending_clarifications_json.clone()), quality_findings_json: patch .quality_findings_json .unwrap_or_else(|| current.quality_findings_json.clone()), suggested_actions_json: patch .suggested_actions_json .unwrap_or_else(|| current.suggested_actions_json.clone()), recommended_replies_json: patch .recommended_replies_json .unwrap_or_else(|| current.recommended_replies_json.clone()), asset_coverage_json: patch .asset_coverage_json .unwrap_or_else(|| current.asset_coverage_json.clone()), checkpoints_json: patch .checkpoints_json .unwrap_or_else(|| current.checkpoints_json.clone()), created_at: current.created_at, updated_at: Timestamp::from_micros_since_unix_epoch( patch .updated_at_micros .unwrap_or_else(|| current.updated_at.to_micros_since_unix_epoch()), ), }) } fn replace_custom_world_agent_session( ctx: &ReducerContext, current: &CustomWorldAgentSession, next: CustomWorldAgentSession, ) { ctx.db .custom_world_agent_session() .session_id() .delete(¤t.session_id); ctx.db.custom_world_agent_session().insert(next); } fn replace_custom_world_draft_card( ctx: &ReducerContext, current: &CustomWorldDraftCard, next: CustomWorldDraftCard, ) { ctx.db .custom_world_draft_card() .card_id() .delete(¤t.card_id); ctx.db.custom_world_draft_card().insert(next); } fn build_and_insert_custom_world_operation( ctx: &ReducerContext, operation_id: &str, session_id: &str, operation_type: RpgAgentOperationType, phase_label: &str, phase_detail: &str, timestamp_micros: i64, ) -> CustomWorldAgentOperation { let row = CustomWorldAgentOperation { operation_id: operation_id.to_string(), session_id: session_id.to_string(), operation_type, status: RpgAgentOperationStatus::Completed, phase_label: phase_label.to_string(), phase_detail: phase_detail.to_string(), progress: 100, error_message: None, created_at: Timestamp::from_micros_since_unix_epoch(timestamp_micros), updated_at: Timestamp::from_micros_since_unix_epoch(timestamp_micros), }; ctx.db.custom_world_agent_operation().insert(row) } fn append_custom_world_action_result_message( ctx: &ReducerContext, session_id: &str, operation_id: &str, text: &str, timestamp_micros: i64, ) { let row = CustomWorldAgentMessage { message_id: format!("message-action-{}-{}", operation_id, timestamp_micros), session_id: session_id.to_string(), role: RpgAgentMessageRole::Assistant, kind: RpgAgentMessageKind::ActionResult, text: text.to_string(), related_operation_id: Some(operation_id.to_string()), created_at: Timestamp::from_micros_since_unix_epoch(timestamp_micros), }; ctx.db.custom_world_agent_message().insert(row); } fn upsert_world_foundation_card( ctx: &ReducerContext, session_id: &str, draft_profile: &JsonMap, updated_at_micros: i64, ) -> Result<(), String> { let card_id = "world-foundation".to_string(); let title = read_optional_text_field(draft_profile, &["name", "title"]) .unwrap_or_else(|| "世界底稿".to_string()); let subtitle = read_optional_text_field(draft_profile, &["subtitle"]).unwrap_or_default(); let summary = read_optional_text_field(draft_profile, &["summary"]) .unwrap_or_else(|| "第一版世界底稿已生成。".to_string()); let detail_payload_json = serialize_json_value(&json!({ "id": card_id, "kind": "world", "title": title, "sections": [ { "id": "title", "label": "标题", "value": read_optional_text_field(draft_profile, &["name", "title"]).unwrap_or_else(|| "世界底稿".to_string()) }, { "id": "subtitle", "label": "副标题", "value": subtitle }, { "id": "summary", "label": "摘要", "value": summary }, ], "linkedIds": [], "locked": false, "editable": false, "editableSectionIds": [], "warningMessages": [], }))?; if let Some(existing) = ctx .db .custom_world_draft_card() .card_id() .find(&card_id) .filter(|row| row.session_id == session_id) { replace_custom_world_draft_card( ctx, &existing, CustomWorldDraftCard { card_id: existing.card_id.clone(), session_id: existing.session_id.clone(), kind: RpgAgentDraftCardKind::World, status: RpgAgentDraftCardStatus::Confirmed, title: read_optional_text_field(draft_profile, &["name", "title"]) .unwrap_or_else(|| "世界底稿".to_string()), subtitle: read_optional_text_field(draft_profile, &["subtitle"]) .unwrap_or_default(), summary: read_optional_text_field(draft_profile, &["summary"]) .unwrap_or_else(|| "第一版世界底稿已生成。".to_string()), linked_ids_json: "[]".to_string(), warning_count: 0, asset_status: None, asset_status_label: None, detail_payload_json: Some(detail_payload_json), created_at: existing.created_at, updated_at: Timestamp::from_micros_since_unix_epoch(updated_at_micros), }, ); } else { ctx.db .custom_world_draft_card() .insert(CustomWorldDraftCard { card_id, session_id: session_id.to_string(), kind: RpgAgentDraftCardKind::World, status: RpgAgentDraftCardStatus::Confirmed, title: read_optional_text_field(draft_profile, &["name", "title"]) .unwrap_or_else(|| "世界底稿".to_string()), subtitle: read_optional_text_field(draft_profile, &["subtitle"]) .unwrap_or_default(), summary: read_optional_text_field(draft_profile, &["summary"]) .unwrap_or_else(|| "第一版世界底稿已生成。".to_string()), linked_ids_json: "[]".to_string(), warning_count: 0, asset_status: None, asset_status_label: None, detail_payload_json: Some(detail_payload_json), created_at: Timestamp::from_micros_since_unix_epoch(updated_at_micros), updated_at: Timestamp::from_micros_since_unix_epoch(updated_at_micros), }); } Ok(()) } fn upsert_generated_entity_card( ctx: &ReducerContext, session_id: &str, kind: RpgAgentDraftCardKind, entity: &JsonValue, updated_at_micros: i64, ) -> Result<(), String> { let entity_object = entity .as_object() .ok_or_else(|| "generated entity must be object".to_string())?; let name = read_optional_text_field(entity_object, &["name"]) .unwrap_or_else(|| "未命名对象".to_string()); let card_id = build_generated_entity_card_id(kind, name.as_str(), 0); let subtitle = match kind { RpgAgentDraftCardKind::Character => { read_optional_text_field(entity_object, &["role", "relationToPlayer", "publicMask"]) .unwrap_or_else(|| "新角色".to_string()) } RpgAgentDraftCardKind::Landmark => { read_optional_text_field(entity_object, &["purpose", "mood", "dangerLevel"]) .unwrap_or_else(|| "新地点".to_string()) } _ => "新增对象".to_string(), }; let summary = read_optional_text_field( entity_object, &[ "summary", "description", "publicMask", "secret", "hiddenHook", ], ) .unwrap_or_else(|| "新增内容已写入世界草稿。".to_string()); let linked_ids = entity_object .get("threadIds") .or_else(|| entity_object.get("characterIds")) .cloned() .unwrap_or_else(|| JsonValue::Array(Vec::new())); let detail_payload_json = serialize_json_value(&json!({ "id": card_id, "kind": kind.as_str(), "title": name, "sections": build_generated_entity_detail_sections(entity_object, kind), "linkedIds": linked_ids, "locked": false, "editable": true, "editableSectionIds": ["summary"], "warningMessages": [], }))?; if let Some(existing) = ctx .db .custom_world_draft_card() .card_id() .find(&card_id) .filter(|row| row.session_id == session_id) { replace_custom_world_draft_card( ctx, &existing, CustomWorldDraftCard { card_id: existing.card_id.clone(), session_id: existing.session_id.clone(), kind, status: RpgAgentDraftCardStatus::Draft, title: name, subtitle, summary, linked_ids_json: serialize_json_value(&linked_ids)?, warning_count: 0, asset_status: None, asset_status_label: None, detail_payload_json: Some(detail_payload_json), created_at: existing.created_at, updated_at: Timestamp::from_micros_since_unix_epoch(updated_at_micros), }, ); } else { ctx.db .custom_world_draft_card() .insert(CustomWorldDraftCard { card_id, session_id: session_id.to_string(), kind, status: RpgAgentDraftCardStatus::Draft, title: name, subtitle, summary, linked_ids_json: serialize_json_value(&linked_ids)?, warning_count: 0, asset_status: None, asset_status_label: None, detail_payload_json: Some(detail_payload_json), created_at: Timestamp::from_micros_since_unix_epoch(updated_at_micros), updated_at: Timestamp::from_micros_since_unix_epoch(updated_at_micros), }); } Ok(()) } fn build_generated_entity_detail_sections( entity: &JsonMap, kind: RpgAgentDraftCardKind, ) -> Vec { let mut sections = vec![json!({ "id": "summary", "label": "摘要", "value": read_optional_text_field(entity, &["summary", "description", "publicMask"]) .unwrap_or_default(), })]; if kind == RpgAgentDraftCardKind::Character { sections.push(json!({ "id": "relationToPlayer", "label": "玩家关系", "value": read_optional_text_field(entity, &["relationToPlayer", "role"]).unwrap_or_default(), })); sections.push(json!({ "id": "hiddenHook", "label": "隐藏钩子", "value": read_optional_text_field(entity, &["hiddenHook"]).unwrap_or_default(), })); } else if kind == RpgAgentDraftCardKind::Landmark { sections.push(json!({ "id": "purpose", "label": "用途", "value": read_optional_text_field(entity, &["purpose"]).unwrap_or_default(), })); sections.push(json!({ "id": "secret", "label": "秘密", "value": read_optional_text_field(entity, &["secret"]).unwrap_or_default(), })); } sections } fn ensure_generated_entity_id( mut entity: JsonValue, kind: RpgAgentDraftCardKind, index: usize, ) -> JsonValue { if let Some(object) = entity.as_object_mut() { let name = object .get("name") .and_then(JsonValue::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or("entry") .to_string(); object.entry("id".to_string()).or_insert_with(|| { JsonValue::String(build_generated_entity_card_id(kind, &name, index)) }); } entity } fn build_generated_entity_card_id(kind: RpgAgentDraftCardKind, name: &str, index: usize) -> String { let prefix = match kind { RpgAgentDraftCardKind::Character => "character", RpgAgentDraftCardKind::Landmark => "landmark", _ => "card", }; let slug = name .trim() .to_lowercase() .chars() .map(|ch| { if ch.is_ascii_alphanumeric() || ('\u{4e00}'..='\u{9fa5}').contains(&ch) { ch } else { '-' } }) .collect::() .trim_matches('-') .to_string(); format!( "{}-{}-{}", prefix, if slug.is_empty() { "entry" } else { slug.as_str() }, index + 1 ) } fn read_payload_string_array(payload: &JsonMap, key: &str) -> Vec { payload .get(key) .and_then(JsonValue::as_array) .into_iter() .flatten() .filter_map(JsonValue::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) .collect() } fn remove_profile_entities_by_ids( draft_profile: &mut JsonMap, key: &str, ids: &[String], ) -> Result, String> { let Some(value) = draft_profile.get_mut(key) else { return Ok(Vec::new()); }; let entries = value .as_array_mut() .ok_or_else(|| format!("draftProfile.{key} must be array"))?; let mut removed_names = Vec::new(); entries.retain(|entry| { let entry_id = entry.get("id").and_then(JsonValue::as_str).unwrap_or_default(); let should_remove = ids.iter().any(|id| id == entry_id); if should_remove { if let Some(name) = entry.get("name").and_then(JsonValue::as_str) { removed_names.push(name.to_string()); } } !should_remove }); Ok(removed_names) } fn remove_deleted_landmark_connections(draft_profile: &mut JsonMap, ids: &[String]) { let Some(landmarks) = draft_profile .get_mut("landmarks") .and_then(JsonValue::as_array_mut) else { return; }; for landmark in landmarks { if let Some(connections) = landmark .get_mut("connections") .and_then(JsonValue::as_array_mut) { connections.retain(|connection| { let target_id = connection .get("targetLandmarkId") .and_then(JsonValue::as_str) .unwrap_or_default(); !ids.iter().any(|id| id == target_id) }); } } } fn delete_draft_card_by_entity_id(ctx: &ReducerContext, session_id: &str, entity_id: &str) { let target = ctx .db .custom_world_draft_card() .iter() .find(|card| { card.session_id == session_id && (card.card_id == entity_id || card .detail_payload_json .as_deref() .and_then(|json_text| serde_json::from_str::(json_text).ok()) .and_then(|value| value.get("id").and_then(JsonValue::as_str).map(str::to_string)) .as_deref() == Some(entity_id)) }); if let Some(card) = target { ctx.db .custom_world_draft_card() .card_id() .delete(&card.card_id); } } fn sync_session_draft_profile_from_card_update( session: &CustomWorldAgentSession, card: &CustomWorldDraftCard, updated_title: &str, updated_subtitle: &str, updated_summary: &str, updated_at_micros: i64, ) -> Result { let mut draft_profile = parse_optional_session_object(session.draft_profile_json.as_deref()) .unwrap_or_else(|| build_minimal_draft_profile_from_seed(&session.seed_text)); if card.kind == RpgAgentDraftCardKind::World { draft_profile.insert( "name".to_string(), JsonValue::String(updated_title.to_string()), ); draft_profile.insert( "subtitle".to_string(), JsonValue::String(updated_subtitle.to_string()), ); draft_profile.insert( "summary".to_string(), JsonValue::String(updated_summary.to_string()), ); } let gate = summarize_publish_gate_from_json( &session.session_id, session.stage, Some(&draft_profile), &parse_json_array_or_empty(&session.quality_findings_json), ); rebuild_custom_world_agent_session_row( session, CustomWorldAgentSessionPatch { draft_profile_json: Some(Some(serialize_json_value(&JsonValue::Object( draft_profile.clone(), ))?)), publish_gate_json: Some(Some(serialize_json_value(&publish_gate_to_json_value( &gate, ))?)), result_preview_json: Some(build_result_preview_json( Some(&draft_profile), &gate, &parse_json_array_or_empty(&session.quality_findings_json), updated_at_micros, )?), last_assistant_reply: Some(Some(format!("卡片《{}》已更新。", updated_title))), updated_at_micros: Some(updated_at_micros), ..CustomWorldAgentSessionPatch::default() }, ) } fn ensure_refining_stage(stage: RpgAgentStage, action: &str) -> Result<(), String> { if matches!( stage, RpgAgentStage::ObjectRefining | RpgAgentStage::VisualRefining ) { Ok(()) } else { Err(format!( "{action} is only available during object_refining or visual_refining" )) } } fn ensure_long_tail_stage(stage: RpgAgentStage, action: &str) -> Result<(), String> { if matches!( stage, RpgAgentStage::ObjectRefining | RpgAgentStage::VisualRefining | RpgAgentStage::LongTailReview | RpgAgentStage::ReadyToPublish ) { Ok(()) } else { Err(format!( "{action} is only available during object_refining, visual_refining, long_tail_review or ready_to_publish" )) } } fn ensure_publishable_stage(stage: RpgAgentStage, action: &str) -> Result<(), String> { ensure_long_tail_stage(stage, action) } fn map_action_name_to_operation_type(action: &str) -> Option { match action { "draft_foundation" => Some(RpgAgentOperationType::DraftFoundation), "update_draft_card" => Some(RpgAgentOperationType::UpdateDraftCard), "sync_result_profile" => Some(RpgAgentOperationType::SyncResultProfile), "generate_characters" => Some(RpgAgentOperationType::GenerateCharacters), "generate_landmarks" => Some(RpgAgentOperationType::GenerateLandmarks), "delete_characters" => Some(RpgAgentOperationType::DeleteCharacters), "delete_landmarks" => Some(RpgAgentOperationType::DeleteLandmarks), "generate_role_assets" => Some(RpgAgentOperationType::GenerateRoleAssets), "sync_role_assets" => Some(RpgAgentOperationType::SyncRoleAssets), "generate_scene_assets" => Some(RpgAgentOperationType::GenerateSceneAssets), "sync_scene_assets" => Some(RpgAgentOperationType::SyncSceneAssets), "expand_long_tail" => Some(RpgAgentOperationType::ExpandLongTail), "publish_world" => Some(RpgAgentOperationType::PublishWorld), "revert_checkpoint" => Some(RpgAgentOperationType::RevertCheckpoint), _ => None, } } fn parse_rpg_agent_stage(value: &str) -> Option { match value.trim() { "collecting_intent" => Some(RpgAgentStage::CollectingIntent), "clarifying" => Some(RpgAgentStage::Clarifying), "foundation_review" => Some(RpgAgentStage::FoundationReview), "object_refining" => Some(RpgAgentStage::ObjectRefining), "visual_refining" => Some(RpgAgentStage::VisualRefining), "long_tail_review" => Some(RpgAgentStage::LongTailReview), "ready_to_publish" => Some(RpgAgentStage::ReadyToPublish), "published" => Some(RpgAgentStage::Published), "error" => Some(RpgAgentStage::Error), _ => None, } } fn resolve_rpg_agent_stage_label(stage: RpgAgentStage) -> &'static str { match stage { RpgAgentStage::CollectingIntent => "收集世界锚点", RpgAgentStage::Clarifying => "补齐关键锚点", RpgAgentStage::FoundationReview => "准备整理底稿", RpgAgentStage::ObjectRefining => "待完善草稿", RpgAgentStage::VisualRefining => "视觉工坊", RpgAgentStage::LongTailReview => "扩展长尾", RpgAgentStage::ReadyToPublish => "准备发布", RpgAgentStage::Published => "已发布", RpgAgentStage::Error => "发生错误", } } fn parse_optional_session_object(value: Option<&str>) -> Option> { value .map(str::trim) .filter(|value| !value.is_empty()) .and_then(|value| serde_json::from_str::(value).ok()) .and_then(|value| value.as_object().cloned()) } fn parse_json_array_or_empty(raw: &str) -> Vec { serde_json::from_str::(raw) .ok() .and_then(|value| value.as_array().cloned()) .unwrap_or_default() } fn read_first_payload_text( payload: &JsonMap, array_key: &str, scalar_key: &str, ) -> Option { payload .get(array_key) .and_then(JsonValue::as_array) .and_then(|values| values.first()) .and_then(JsonValue::as_str) .or_else(|| payload.get(scalar_key).and_then(JsonValue::as_str)) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) } fn find_profile_entity_by_id<'a>( profile: &'a JsonMap, fields: &[&str], entity_id: &str, ) -> Option<&'a JsonMap> { for field in fields { if let Some(entries) = profile.get(*field).and_then(JsonValue::as_array) { for entry in entries { let Some(object) = entry.as_object() else { continue; }; if read_optional_text_field(object, &["id"]).as_deref() == Some(entity_id) { return Some(object); } } } } None } fn apply_role_asset_publish_result( profile: &mut JsonMap, role_id: &str, portrait_path: &str, generated_visual_asset_id: &str, generated_animation_set_id: Option<&str>, animation_map: Option, ) -> Result, String> { for field in ["playableNpcs", "storyNpcs"] { let Some(entries) = profile.get_mut(field).and_then(JsonValue::as_array_mut) else { continue; }; for entry in entries { let Some(object) = entry.as_object_mut() else { continue; }; if read_optional_text_field(object, &["id"]).as_deref() != Some(role_id) { continue; } object.insert("imageSrc".to_string(), JsonValue::String(portrait_path.to_string())); object.insert( "generatedVisualAssetId".to_string(), JsonValue::String(generated_visual_asset_id.to_string()), ); if let Some(asset_id) = generated_animation_set_id { object.insert( "generatedAnimationSetId".to_string(), JsonValue::String(asset_id.to_string()), ); } if let Some(map) = animation_map { object.insert("animationMap".to_string(), map); } return Ok(object.clone()); } } Err("目标角色不存在,无法同步角色资产。".to_string()) } fn apply_scene_asset_publish_result( profile: &mut JsonMap, scene_id: &str, scene_kind: &str, image_src: &str, generated_scene_asset_id: &str, generated_scene_prompt: JsonValue, generated_scene_model: JsonValue, ) -> Result, String> { let updated_scene = if scene_kind == "camp" { let camp = profile .get_mut("camp") .and_then(JsonValue::as_object_mut) .ok_or_else(|| "目标营地不存在,无法同步场景资产。".to_string())?; if read_optional_text_field(camp, &["id"]).as_deref() != Some(scene_id) { return Err("目标营地不存在,无法同步场景资产。".to_string()); } camp.insert("imageSrc".to_string(), JsonValue::String(image_src.to_string())); camp.insert( "generatedSceneAssetId".to_string(), JsonValue::String(generated_scene_asset_id.to_string()), ); camp.insert("generatedScenePrompt".to_string(), generated_scene_prompt); camp.insert("generatedSceneModel".to_string(), generated_scene_model); camp.clone() } else { let landmarks = profile .get_mut("landmarks") .and_then(JsonValue::as_array_mut) .ok_or_else(|| "目标地点不存在,无法同步场景资产。".to_string())?; let mut updated = None; for entry in landmarks { let Some(object) = entry.as_object_mut() else { continue; }; if read_optional_text_field(object, &["id"]).as_deref() != Some(scene_id) { continue; } object.insert("imageSrc".to_string(), JsonValue::String(image_src.to_string())); object.insert( "generatedSceneAssetId".to_string(), JsonValue::String(generated_scene_asset_id.to_string()), ); object.insert("generatedScenePrompt".to_string(), generated_scene_prompt.clone()); object.insert("generatedSceneModel".to_string(), generated_scene_model.clone()); updated = Some(object.clone()); break; } updated.ok_or_else(|| "目标地点不存在,无法同步场景资产。".to_string())? }; update_scene_chapter_acts_for_scene(profile, scene_id, image_src, generated_scene_asset_id); Ok(updated_scene) } fn update_scene_chapter_acts_for_scene( profile: &mut JsonMap, scene_id: &str, image_src: &str, generated_scene_asset_id: &str, ) { let Some(chapters) = profile.get_mut("sceneChapters").and_then(JsonValue::as_array_mut) else { return; }; for chapter in chapters { let Some(chapter_object) = chapter.as_object_mut() else { continue; }; if read_optional_text_field(chapter_object, &["sceneId"]).as_deref() != Some(scene_id) { continue; } let Some(acts) = chapter_object.get_mut("acts").and_then(JsonValue::as_array_mut) else { continue; }; for act in acts { if let Some(act_object) = act.as_object_mut() { act_object.insert("backgroundImageSrc".to_string(), JsonValue::String(image_src.to_string())); act_object.insert( "backgroundAssetId".to_string(), JsonValue::String(generated_scene_asset_id.to_string()), ); } } } } fn resolve_role_asset_status(role: &JsonMap) -> CustomWorldRoleAssetStatus { let has_portrait = read_optional_text_field(role, &["imageSrc"]).is_some() && read_optional_text_field(role, &["generatedVisualAssetId"]).is_some(); if !has_portrait { return CustomWorldRoleAssetStatus::Missing; } let has_animation_set = read_optional_text_field(role, &["generatedAnimationSetId"]).is_some(); let has_animation_map = role .get("animationMap") .and_then(JsonValue::as_object) .map(|map| !map.is_empty()) .unwrap_or(false); if has_animation_set && has_animation_map { CustomWorldRoleAssetStatus::Complete } else if has_animation_set { CustomWorldRoleAssetStatus::AnimationsReady } else { CustomWorldRoleAssetStatus::VisualReady } } fn resolve_role_asset_status_label(status: CustomWorldRoleAssetStatus) -> &'static str { match status { CustomWorldRoleAssetStatus::Complete => "动作已就绪", CustomWorldRoleAssetStatus::AnimationsReady => "动作补齐中", CustomWorldRoleAssetStatus::VisualReady => "主图已就绪", CustomWorldRoleAssetStatus::Missing => "待生成主图", } } fn build_asset_coverage_json(profile: &JsonMap) -> Result { let mut role_assets = Vec::new(); for (field, role_kind) in [("playableNpcs", "playable"), ("storyNpcs", "story")] { if let Some(entries) = profile.get(field).and_then(JsonValue::as_array) { for entry in entries { let Some(role) = entry.as_object() else { continue; }; let Some(role_id) = read_optional_text_field(role, &["id"]) else { continue; }; let status = resolve_role_asset_status(role); role_assets.push(json!({ "roleId": role_id, "roleName": read_optional_text_field(role, &["name"]).unwrap_or_else(|| "角色".to_string()), "roleKind": role_kind, "priorityTier": if role_kind == "playable" { "hero" } else { "support" }, "portraitPath": read_optional_text_field(role, &["imageSrc"]), "generatedVisualAssetId": read_optional_text_field(role, &["generatedVisualAssetId"]), "generatedAnimationSetId": read_optional_text_field(role, &["generatedAnimationSetId"]), "status": role_asset_status_key(status), "missingAnimations": [], "nextPointCost": 0, })); } } } let mut scene_assets = Vec::new(); if let Some(camp) = profile.get("camp").and_then(JsonValue::as_object) { if let Some(scene_id) = read_optional_text_field(camp, &["id"]) { scene_assets.push(build_scene_asset_summary_json(&scene_id, "camp", camp)); } } if let Some(landmarks) = profile.get("landmarks").and_then(JsonValue::as_array) { for entry in landmarks { let Some(scene) = entry.as_object() else { continue; }; if let Some(scene_id) = read_optional_text_field(scene, &["id"]) { scene_assets.push(build_scene_asset_summary_json(&scene_id, "landmark", scene)); } } } let all_role_assets_ready = !role_assets.is_empty() && role_assets.iter().all(|entry| entry.get("status").and_then(JsonValue::as_str) != Some("missing")); let all_scene_assets_ready = !scene_assets.is_empty() && scene_assets.iter().all(|entry| entry.get("status").and_then(JsonValue::as_str) == Some("ready")); serialize_json_value(&json!({ "roleAssets": role_assets, "sceneAssets": scene_assets, "allRoleAssetsReady": all_role_assets_ready, "allSceneAssetsReady": all_scene_assets_ready, })) } fn role_asset_status_key(status: CustomWorldRoleAssetStatus) -> &'static str { match status { CustomWorldRoleAssetStatus::Missing => "missing", CustomWorldRoleAssetStatus::VisualReady => "visual_ready", CustomWorldRoleAssetStatus::AnimationsReady => "animations_ready", CustomWorldRoleAssetStatus::Complete => "complete", } } fn build_scene_asset_summary_json( scene_id: &str, scene_kind: &str, scene: &JsonMap, ) -> JsonValue { let image_src = read_optional_text_field(scene, &["imageSrc"]); let asset_id = read_optional_text_field(scene, &["generatedSceneAssetId"]); json!({ "sceneId": scene_id, "sceneName": read_optional_text_field(scene, &["name"]).unwrap_or_else(|| if scene_kind == "camp" { "开局营地" } else { "未命名场景" }.to_string()), "actId": JsonValue::Null, "actTitle": if scene_kind == "camp" { "营地正式背景图" } else { "场景正式背景图" }, "imageSrc": image_src, "assetId": asset_id, "status": if read_optional_text_field(scene, &["imageSrc"]).is_some() || read_optional_text_field(scene, &["generatedSceneAssetId"]).is_some() { "ready" } else { "missing" }, "nextPointCost": 0, }) } fn upsert_asset_role_card( ctx: &ReducerContext, session_id: &str, role_id: &str, role: &JsonMap, asset_status: CustomWorldRoleAssetStatus, asset_status_label: &str, updated_at_micros: i64, ) -> Result<(), String> { let card_id = resolve_existing_entity_card_id(ctx, session_id, role_id, RpgAgentDraftCardKind::Character) .unwrap_or_else(|| role_id.to_string()); let title = read_optional_text_field(role, &["name"]).unwrap_or_else(|| "角色".to_string()); let subtitle = read_optional_text_field(role, &["role", "relationToPlayer", "publicMask"]) .unwrap_or_else(|| asset_status_label.to_string()); let summary = read_optional_text_field(role, &["summary", "description", "publicMask"]) .unwrap_or_else(|| "角色资产已写回草稿。".to_string()); upsert_asset_card( ctx, session_id, &card_id, RpgAgentDraftCardKind::Character, &title, &subtitle, &summary, asset_status, asset_status_label, json!({ "id": role_id, "kind": "character", "title": title, "sections": build_generated_entity_detail_sections(role, RpgAgentDraftCardKind::Character), "linkedIds": [], "locked": false, "editable": true, "editableSectionIds": ["summary"], "warningMessages": [], "asset": { "imageSrc": read_optional_text_field(role, &["imageSrc"]), "generatedVisualAssetId": read_optional_text_field(role, &["generatedVisualAssetId"]), "generatedAnimationSetId": read_optional_text_field(role, &["generatedAnimationSetId"]), "status": role_asset_status_key(asset_status), "statusLabel": asset_status_label, }, }), updated_at_micros, ) } fn upsert_asset_scene_card( ctx: &ReducerContext, session_id: &str, scene_id: &str, scene_kind: &str, scene: &JsonMap, updated_at_micros: i64, ) -> Result<(), String> { let kind = if scene_kind == "camp" { RpgAgentDraftCardKind::Camp } else { RpgAgentDraftCardKind::Landmark }; let card_id = resolve_existing_entity_card_id(ctx, session_id, scene_id, kind) .unwrap_or_else(|| scene_id.to_string()); let title = read_optional_text_field(scene, &["name"]).unwrap_or_else(|| if scene_kind == "camp" { "开局营地" } else { "场景" }.to_string()); let subtitle = read_optional_text_field(scene, &["purpose", "mood", "dangerLevel"]) .unwrap_or_else(|| "场景资产已就绪".to_string()); let summary = read_optional_text_field(scene, &["summary", "description", "publicMask"]) .unwrap_or_else(|| "场景图已写回草稿。".to_string()); upsert_asset_card( ctx, session_id, &card_id, kind, &title, &subtitle, &summary, CustomWorldRoleAssetStatus::Complete, "场景图已就绪", json!({ "id": scene_id, "kind": kind.as_str(), "title": title, "sections": build_generated_entity_detail_sections(scene, kind), "linkedIds": [], "locked": false, "editable": true, "editableSectionIds": ["summary"], "warningMessages": [], "asset": { "imageSrc": read_optional_text_field(scene, &["imageSrc"]), "generatedSceneAssetId": read_optional_text_field(scene, &["generatedSceneAssetId"]), "generatedScenePrompt": scene.get("generatedScenePrompt").cloned().unwrap_or(JsonValue::Null), "generatedSceneModel": scene.get("generatedSceneModel").cloned().unwrap_or(JsonValue::Null), "status": "ready", "statusLabel": "场景图已就绪", }, }), updated_at_micros, ) } fn upsert_asset_card( ctx: &ReducerContext, session_id: &str, card_id: &str, kind: RpgAgentDraftCardKind, title: &str, subtitle: &str, summary: &str, asset_status: CustomWorldRoleAssetStatus, asset_status_label: &str, detail_payload: JsonValue, updated_at_micros: i64, ) -> Result<(), String> { let row = CustomWorldDraftCard { card_id: card_id.to_string(), session_id: session_id.to_string(), kind, status: RpgAgentDraftCardStatus::Draft, title: title.to_string(), subtitle: subtitle.to_string(), summary: summary.to_string(), linked_ids_json: "[]".to_string(), warning_count: 0, asset_status: Some(asset_status), asset_status_label: Some(asset_status_label.to_string()), detail_payload_json: Some(serialize_json_value(&detail_payload)?), created_at: Timestamp::from_micros_since_unix_epoch(updated_at_micros), updated_at: Timestamp::from_micros_since_unix_epoch(updated_at_micros), }; if let Some(existing) = ctx .db .custom_world_draft_card() .card_id() .find(&card_id.to_string()) .filter(|entry| entry.session_id == session_id) { replace_custom_world_draft_card( ctx, &existing, CustomWorldDraftCard { created_at: existing.created_at, ..row }, ); } else { ctx.db.custom_world_draft_card().insert(row); } Ok(()) } fn resolve_existing_entity_card_id( ctx: &ReducerContext, session_id: &str, entity_id: &str, kind: RpgAgentDraftCardKind, ) -> Option { for card in ctx .db .custom_world_draft_card() .iter() .filter(|row| row.session_id == session_id && row.kind == kind) { if card.card_id == entity_id { return Some(card.card_id); } if let Some(detail) = card .detail_payload_json .as_deref() .and_then(parse_optional_session_object) { if read_optional_text_field(&detail, &["id"]).as_deref() == Some(entity_id) { return Some(card.card_id); } } } None } fn serialize_json_value(value: &JsonValue) -> Result { serde_json::to_string(value).map_err(|error| format!("JSON 序列化失败: {error}")) } fn read_required_payload_text( payload: &JsonMap, key: &str, error_message: &str, ) -> Result { payload .get(key) .and_then(JsonValue::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) .ok_or_else(|| error_message.to_string()) } fn read_optional_text_field(object: &JsonMap, keys: &[&str]) -> Option { for key in keys { let mut current = JsonValue::Object(object.clone()); let mut found = true; for segment in key.split('.') { if let Some(next) = current.get(segment) { current = next.clone(); } else { found = false; break; } } if found { if let Some(value) = current .as_str() .map(str::trim) .filter(|value| !value.is_empty()) { return Some(value.to_string()); } } } None } fn resolve_session_work_title( session: &CustomWorldAgentSession, draft_profile: Option<&JsonMap>, ) -> String { draft_profile .and_then(|profile| read_optional_text_field(profile, &["name", "title"])) .or_else(|| { let seed = session.seed_text.trim(); (!seed.is_empty()).then(|| seed.to_string()) }) .unwrap_or_else(|| "未命名草稿".to_string()) } fn resolve_session_work_summary( session: &CustomWorldAgentSession, draft_profile: Option<&JsonMap>, ) -> String { draft_profile .and_then(|profile| read_optional_text_field(profile, &["summary"])) .or_else(|| { let seed = session.seed_text.trim(); (!seed.is_empty()).then(|| seed.to_string()) }) .unwrap_or_else(|| "还在收集你的世界锚点。".to_string()) } fn resolve_session_work_subtitle( draft_profile: Option<&JsonMap>, stage_label: Option<&str>, ) -> String { draft_profile .and_then(|profile| read_optional_text_field(profile, &["subtitle"])) .or_else(|| stage_label.map(ToOwned::to_owned)) .unwrap_or_default() } fn resolve_session_work_cover_image_src( draft_profile: Option<&JsonMap>, ) -> Option { let profile = draft_profile?; if let Some(camp) = profile.get("camp").and_then(JsonValue::as_object) { if let Some(image_src) = read_optional_text_field(camp, &["imageSrc"]) { return Some(image_src); } } if let Some(landmarks) = profile.get("landmarks").and_then(JsonValue::as_array) { for landmark in landmarks { if let Some(object) = landmark.as_object() { if let Some(image_src) = read_optional_text_field(object, &["imageSrc"]) { return Some(image_src); } } } } None } fn resolve_session_work_counts( ctx: &ReducerContext, session: &CustomWorldAgentSession, draft_profile: Option<&JsonMap>, ) -> (u32, u32) { if let Some(profile) = draft_profile { let role_count = profile .get("playableNpcs") .and_then(JsonValue::as_array) .map(|entries| entries.len() as u32) .unwrap_or(0) + profile .get("storyNpcs") .and_then(JsonValue::as_array) .map(|entries| entries.len() as u32) .unwrap_or(0); let landmark_count = profile .get("landmarks") .and_then(JsonValue::as_array) .map(|entries| entries.len() as u32) .unwrap_or(0); return (role_count, landmark_count); } let mut role_count = 0u32; let mut landmark_count = 0u32; for card in ctx .db .custom_world_draft_card() .iter() .filter(|row| row.session_id == session.session_id) { match card.kind { RpgAgentDraftCardKind::Character => { role_count = role_count.saturating_add(1); } RpgAgentDraftCardKind::Landmark => { landmark_count = landmark_count.saturating_add(1); } _ => {} } } (role_count, landmark_count) } fn ensure_minimal_draft_profile( mut profile: JsonMap, seed_text: &str, ) -> JsonMap { if read_optional_text_field(&profile, &["name", "title"]).is_none() { profile.insert( "name".to_string(), JsonValue::String(seed_text.trim().to_string().if_empty("未命名草稿")), ); } if read_optional_text_field(&profile, &["summary"]).is_none() { profile.insert( "summary".to_string(), JsonValue::String( (!seed_text.trim().is_empty()) .then(|| seed_text.trim().to_string()) .unwrap_or_else(|| "还在收集你的世界锚点。".to_string()), ), ); } profile .entry("subtitle".to_string()) .or_insert_with(|| JsonValue::String(String::new())); profile .entry("worldHook".to_string()) .or_insert_with(|| JsonValue::String(String::new())); profile .entry("playerPremise".to_string()) .or_insert_with(|| JsonValue::String(String::new())); profile .entry("coreConflicts".to_string()) .or_insert_with(|| JsonValue::Array(Vec::new())); profile .entry("playableNpcs".to_string()) .or_insert_with(|| JsonValue::Array(Vec::new())); profile .entry("storyNpcs".to_string()) .or_insert_with(|| JsonValue::Array(Vec::new())); profile .entry("landmarks".to_string()) .or_insert_with(|| JsonValue::Array(Vec::new())); profile .entry("chapters".to_string()) .or_insert_with(|| JsonValue::Array(Vec::new())); profile .entry("sceneChapters".to_string()) .or_insert_with(|| JsonValue::Array(Vec::new())); profile .entry("sceneChapterBlueprints".to_string()) .or_insert_with(|| JsonValue::Array(Vec::new())); profile } fn build_minimal_draft_profile_from_seed(seed_text: &str) -> JsonMap { ensure_minimal_draft_profile(JsonMap::new(), seed_text) } fn build_session_checkpoint_value( checkpoint_id_suffix: &str, label: &str, session: &CustomWorldAgentSession, ) -> JsonValue { json!({ "checkpointId": format!("checkpoint-{}-{}", session.session_id, checkpoint_id_suffix), "createdAt": format_timestamp_micros(session.updated_at.to_micros_since_unix_epoch()), "label": label, "snapshot": { "stage": session.stage.as_str(), "progressPercent": session.progress_percent, "draftProfile": parse_optional_session_object(session.draft_profile_json.as_deref()).map(JsonValue::Object), "qualityFindings": parse_json_array_or_empty(&session.quality_findings_json), } }) } fn append_checkpoint_json(current: &str, checkpoint: &JsonValue) -> Result { let mut checkpoints = parse_json_array_or_empty(current); checkpoints.push(checkpoint.clone()); serialize_json_value(&JsonValue::Array(checkpoints)) } fn extract_detail_section_value(sections: &[JsonValue], target_id: &str) -> Option { sections.iter().find_map(|entry| { let object = entry.as_object()?; (object.get("id").and_then(JsonValue::as_str) == Some(target_id)).then(|| { object .get("value") .and_then(JsonValue::as_str) .unwrap_or_default() .to_string() }) }) } fn json_array_has_non_empty_text(value: Option<&JsonValue>) -> bool { value .and_then(JsonValue::as_array) .map(|entries| { entries.iter().any(|entry| { entry .as_str() .map(str::trim) .filter(|text| !text.is_empty()) .is_some() }) }) .unwrap_or(false) } trait IfEmptyString { fn if_empty(self, fallback: &str) -> String; } impl IfEmptyString for String { fn if_empty(self, fallback: &str) -> String { if self.trim().is_empty() { fallback.to_string() } else { self } } } fn mark_custom_world_agent_session_published( ctx: &ReducerContext, session_id: &str, owner_user_id: &str, updated_at_micros: i64, ) -> Result { let existing = ctx .db .custom_world_agent_session() .session_id() .find(&session_id.to_string()) .filter(|row| row.owner_user_id == owner_user_id) .ok_or_else(|| "custom_world_agent_session 不存在,无法推进到 published".to_string())?; ctx.db .custom_world_agent_session() .session_id() .delete(&existing.session_id); let next_row = CustomWorldAgentSession { session_id: existing.session_id.clone(), owner_user_id: existing.owner_user_id.clone(), seed_text: existing.seed_text.clone(), current_turn: existing.current_turn, progress_percent: existing.progress_percent, stage: RpgAgentStage::Published, focus_card_id: existing.focus_card_id.clone(), anchor_content_json: existing.anchor_content_json.clone(), creator_intent_json: existing.creator_intent_json.clone(), creator_intent_readiness_json: existing.creator_intent_readiness_json.clone(), anchor_pack_json: existing.anchor_pack_json.clone(), lock_state_json: existing.lock_state_json.clone(), draft_profile_json: existing.draft_profile_json.clone(), last_assistant_reply: existing.last_assistant_reply.clone(), publish_gate_json: existing.publish_gate_json.clone(), result_preview_json: existing.result_preview_json.clone(), pending_clarifications_json: existing.pending_clarifications_json.clone(), quality_findings_json: existing.quality_findings_json.clone(), suggested_actions_json: existing.suggested_actions_json.clone(), recommended_replies_json: existing.recommended_replies_json.clone(), asset_coverage_json: existing.asset_coverage_json.clone(), checkpoints_json: existing.checkpoints_json.clone(), created_at: existing.created_at, updated_at: Timestamp::from_micros_since_unix_epoch(updated_at_micros), }; ctx.db.custom_world_agent_session().insert(next_row); Ok(RpgAgentStage::Published) } fn sync_custom_world_gallery_entry_from_profile( ctx: &ReducerContext, profile: &CustomWorldProfile, ) -> Result { let published_at = profile .published_at .ok_or_else(|| "published profile 缺少 published_at,无法同步 gallery".to_string())?; ctx.db .custom_world_gallery_entry() .profile_id() .delete(&profile.profile_id); let row = CustomWorldGalleryEntry { profile_id: profile.profile_id.clone(), owner_user_id: profile.owner_user_id.clone(), author_display_name: profile.author_display_name.clone(), world_name: profile.world_name.clone(), subtitle: profile.subtitle.clone(), summary_text: profile.summary_text.clone(), cover_image_src: profile.cover_image_src.clone(), theme_mode: profile.theme_mode, playable_npc_count: profile.playable_npc_count, landmark_count: profile.landmark_count, published_at, updated_at: profile.updated_at, }; let inserted = ctx.db.custom_world_gallery_entry().insert(row); Ok(build_custom_world_gallery_entry_snapshot(&inserted)) } fn build_custom_world_profile_snapshot(row: &CustomWorldProfile) -> CustomWorldProfileSnapshot { CustomWorldProfileSnapshot { profile_id: row.profile_id.clone(), owner_user_id: row.owner_user_id.clone(), source_agent_session_id: row.source_agent_session_id.clone(), publication_status: row.publication_status, world_name: row.world_name.clone(), subtitle: row.subtitle.clone(), summary_text: row.summary_text.clone(), theme_mode: row.theme_mode, cover_image_src: row.cover_image_src.clone(), profile_payload_json: row.profile_payload_json.clone(), playable_npc_count: row.playable_npc_count, landmark_count: row.landmark_count, author_display_name: row.author_display_name.clone(), published_at_micros: row .published_at .map(|value| value.to_micros_since_unix_epoch()), deleted_at_micros: row .deleted_at .map(|value| value.to_micros_since_unix_epoch()), created_at_micros: row.created_at.to_micros_since_unix_epoch(), updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), } } fn build_custom_world_agent_session_snapshot( ctx: &ReducerContext, row: &CustomWorldAgentSession, ) -> CustomWorldAgentSessionSnapshot { let mut messages = ctx .db .custom_world_agent_message() .iter() .filter(|message| message.session_id == row.session_id) .map(|message| build_custom_world_agent_message_snapshot(&message)) .collect::>(); messages.sort_by_key(|message| (message.created_at_micros, message.message_id.clone())); let mut draft_cards = ctx .db .custom_world_draft_card() .iter() .filter(|card| card.session_id == row.session_id) .map(|card| build_custom_world_draft_card_snapshot(&card)) .collect::>(); draft_cards.sort_by_key(|card| (card.created_at_micros, card.card_id.clone())); let mut operations = ctx .db .custom_world_agent_operation() .iter() .filter(|operation| operation.session_id == row.session_id) .map(|operation| build_custom_world_agent_operation_snapshot(&operation)) .collect::>(); operations .sort_by_key(|operation| (operation.created_at_micros, operation.operation_id.clone())); CustomWorldAgentSessionSnapshot { session_id: row.session_id.clone(), owner_user_id: row.owner_user_id.clone(), seed_text: row.seed_text.clone(), current_turn: row.current_turn, progress_percent: row.progress_percent, stage: row.stage, focus_card_id: row.focus_card_id.clone(), anchor_content_json: row.anchor_content_json.clone(), creator_intent_json: row.creator_intent_json.clone(), creator_intent_readiness_json: row.creator_intent_readiness_json.clone(), anchor_pack_json: row.anchor_pack_json.clone(), lock_state_json: row.lock_state_json.clone(), draft_profile_json: row.draft_profile_json.clone(), last_assistant_reply: row.last_assistant_reply.clone(), publish_gate_json: row.publish_gate_json.clone(), result_preview_json: row.result_preview_json.clone(), pending_clarifications_json: row.pending_clarifications_json.clone(), quality_findings_json: row.quality_findings_json.clone(), suggested_actions_json: row.suggested_actions_json.clone(), recommended_replies_json: row.recommended_replies_json.clone(), asset_coverage_json: row.asset_coverage_json.clone(), checkpoints_json: row.checkpoints_json.clone(), supported_actions_json: serialize_json_value(&JsonValue::Array( build_supported_actions_json( row.stage, row.progress_percent, &build_custom_world_publish_gate_from_session(row), &parse_json_array_or_empty(&row.checkpoints_json), ), )) .unwrap_or_else(|_| "[]".to_string()), messages, draft_cards, operations, created_at_micros: row.created_at.to_micros_since_unix_epoch(), updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), } } fn build_custom_world_agent_message_snapshot( row: &CustomWorldAgentMessage, ) -> CustomWorldAgentMessageSnapshot { CustomWorldAgentMessageSnapshot { message_id: row.message_id.clone(), session_id: row.session_id.clone(), role: row.role, kind: row.kind, text: row.text.clone(), related_operation_id: row.related_operation_id.clone(), created_at_micros: row.created_at.to_micros_since_unix_epoch(), } } fn build_custom_world_agent_operation_snapshot( row: &CustomWorldAgentOperation, ) -> CustomWorldAgentOperationSnapshot { CustomWorldAgentOperationSnapshot { operation_id: row.operation_id.clone(), session_id: row.session_id.clone(), operation_type: row.operation_type, status: row.status, phase_label: row.phase_label.clone(), phase_detail: row.phase_detail.clone(), progress: row.progress, error_message: row.error_message.clone(), created_at_micros: row.created_at.to_micros_since_unix_epoch(), updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), } } fn build_custom_world_draft_card_snapshot( row: &CustomWorldDraftCard, ) -> CustomWorldDraftCardSnapshot { CustomWorldDraftCardSnapshot { card_id: row.card_id.clone(), session_id: row.session_id.clone(), kind: row.kind, status: row.status, title: row.title.clone(), subtitle: row.subtitle.clone(), summary: row.summary.clone(), linked_ids_json: row.linked_ids_json.clone(), warning_count: row.warning_count, asset_status: row.asset_status, asset_status_label: row.asset_status_label.clone(), detail_payload_json: row.detail_payload_json.clone(), created_at_micros: row.created_at.to_micros_since_unix_epoch(), updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), } } fn build_custom_world_gallery_entry_snapshot( row: &CustomWorldGalleryEntry, ) -> CustomWorldGalleryEntrySnapshot { CustomWorldGalleryEntrySnapshot { profile_id: row.profile_id.clone(), owner_user_id: row.owner_user_id.clone(), author_display_name: row.author_display_name.clone(), world_name: row.world_name.clone(), subtitle: row.subtitle.clone(), summary_text: row.summary_text.clone(), cover_image_src: row.cover_image_src.clone(), theme_mode: row.theme_mode, playable_npc_count: row.playable_npc_count, landmark_count: row.landmark_count, published_at_micros: row.published_at.to_micros_since_unix_epoch(), updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), } }