use crate::*; use serde::Serialize; use serde::de::DeserializeOwned; pub const VISUAL_NOVEL_SOURCE_IDEA: &str = "idea"; pub const VISUAL_NOVEL_SOURCE_DOCUMENT: &str = "document"; pub const VISUAL_NOVEL_SOURCE_BLANK: &str = "blank"; pub const VISUAL_NOVEL_AGENT_STATUS_COLLECTING: &str = "collecting"; pub const VISUAL_NOVEL_AGENT_STATUS_DRAFTING: &str = "drafting"; pub const VISUAL_NOVEL_AGENT_STATUS_READY: &str = "ready"; pub const VISUAL_NOVEL_AGENT_STATUS_FAILED: &str = "failed"; pub const VISUAL_NOVEL_ROLE_USER: &str = "user"; pub const VISUAL_NOVEL_ROLE_ASSISTANT: &str = "assistant"; pub const VISUAL_NOVEL_KIND_CHAT: &str = "chat"; pub const VISUAL_NOVEL_PUBLICATION_DRAFT: &str = "draft"; pub const VISUAL_NOVEL_PUBLICATION_PUBLISHED: &str = "published"; pub const VISUAL_NOVEL_RUN_MODE_TEST: &str = "test"; pub const VISUAL_NOVEL_RUN_MODE_PLAY: &str = "play"; pub const VISUAL_NOVEL_RUN_STATUS_ACTIVE: &str = "active"; pub const VISUAL_NOVEL_RUN_STATUS_COMPLETED: &str = "completed"; pub const VISUAL_NOVEL_RUN_STATUS_FAILED: &str = "failed"; pub const VISUAL_NOVEL_HISTORY_SOURCE_PLAYER: &str = "player"; pub const VISUAL_NOVEL_HISTORY_SOURCE_ASSISTANT: &str = "assistant"; pub const VISUAL_NOVEL_HISTORY_SOURCE_SYSTEM: &str = "system"; /// 视觉小说创作 session 主表。 /// /// 中文注释:复杂底稿使用 JSON 保存,表字段只保留查询、归属和阶段状态。 #[spacetimedb::table( accessor = visual_novel_agent_session, index(accessor = by_visual_novel_agent_session_owner_user_id, btree(columns = [owner_user_id])) )] pub struct VisualNovelAgentSessionRow { #[primary_key] pub(crate) session_id: String, pub(crate) owner_user_id: String, pub(crate) source_mode: String, pub(crate) status: String, pub(crate) seed_text: String, pub(crate) source_asset_ids_json: String, pub(crate) current_turn: u32, pub(crate) progress_percent: u32, pub(crate) draft_json: String, pub(crate) pending_action_json: String, pub(crate) last_assistant_reply: String, pub(crate) published_profile_id: String, pub(crate) created_at: Timestamp, pub(crate) updated_at: Timestamp, } /// 视觉小说创作消息表。 #[spacetimedb::table( accessor = visual_novel_agent_message, index(accessor = by_visual_novel_agent_message_session_id, btree(columns = [session_id])) )] pub struct VisualNovelAgentMessageRow { #[primary_key] pub(crate) message_id: String, pub(crate) session_id: String, pub(crate) role: String, pub(crate) kind: String, pub(crate) text: String, pub(crate) created_at: Timestamp, } /// 视觉小说作品草稿 / 发布 profile 表。 #[spacetimedb::table( accessor = visual_novel_work_profile, index(accessor = by_visual_novel_work_owner_user_id, btree(columns = [owner_user_id])), index(accessor = by_visual_novel_work_publication_status, btree(columns = [publication_status])) )] pub struct VisualNovelWorkProfileRow { #[primary_key] pub(crate) profile_id: String, pub(crate) work_id: String, pub(crate) owner_user_id: String, pub(crate) source_session_id: String, pub(crate) author_display_name: String, pub(crate) work_title: String, pub(crate) work_description: String, pub(crate) tags_json: String, pub(crate) cover_image_src: String, pub(crate) source_asset_ids_json: String, pub(crate) draft_json: String, pub(crate) publication_status: String, pub(crate) publish_ready: bool, pub(crate) play_count: u32, pub(crate) created_at: Timestamp, pub(crate) updated_at: Timestamp, pub(crate) published_at: Option, } /// 视觉小说运行态 run 表。 #[spacetimedb::table( accessor = visual_novel_runtime_run, index(accessor = by_visual_novel_run_owner_user_id, btree(columns = [owner_user_id])), index(accessor = by_visual_novel_run_profile_id, btree(columns = [profile_id])) )] pub struct VisualNovelRuntimeRunRow { #[primary_key] pub(crate) run_id: String, pub(crate) owner_user_id: String, pub(crate) profile_id: String, pub(crate) mode: String, pub(crate) status: String, pub(crate) current_scene_id: String, pub(crate) current_phase_id: String, pub(crate) visible_character_ids_json: String, pub(crate) flags_json: String, pub(crate) metrics_json: String, pub(crate) available_choices_json: String, pub(crate) text_mode_enabled: bool, pub(crate) snapshot_json: String, pub(crate) created_at: Timestamp, pub(crate) updated_at: Timestamp, } /// 视觉小说运行历史表。 /// /// 中文注释:历史只保存可重生成所需的 step 与快照哈希,不承担外部 TXT 播放片段能力。 #[spacetimedb::table( accessor = visual_novel_runtime_history_entry, index(accessor = by_visual_novel_history_run_id, btree(columns = [run_id])), index(accessor = by_visual_novel_history_owner_user_id, btree(columns = [owner_user_id])) )] pub struct VisualNovelRuntimeHistoryEntryRow { #[primary_key] pub(crate) entry_id: String, pub(crate) run_id: String, pub(crate) owner_user_id: String, pub(crate) profile_id: String, pub(crate) turn_index: u32, pub(crate) source: String, pub(crate) action_text: String, pub(crate) steps_json: String, pub(crate) snapshot_before_hash: String, pub(crate) snapshot_after_hash: String, pub(crate) created_at: Timestamp, } /// 视觉小说运行时审计事件表。 /// /// 中文注释:该表只记录 run 过程事实,不能作为旧 TXT 播放包或分享片段数据源使用。 #[spacetimedb::table( accessor = visual_novel_runtime_event, public, event, index(accessor = by_visual_novel_runtime_event_run_id, btree(columns = [run_id])), index(accessor = by_visual_novel_runtime_event_owner_user_id, btree(columns = [owner_user_id])) )] pub struct VisualNovelRuntimeEvent { #[primary_key] pub(crate) event_id: String, pub(crate) run_id: String, pub(crate) owner_user_id: String, pub(crate) profile_id: String, pub(crate) event_kind: String, pub(crate) client_event_id: String, pub(crate) history_entry_id: String, pub(crate) payload_json: String, pub(crate) occurred_at: Timestamp, } #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct VisualNovelAgentSessionCreateInput { pub session_id: String, pub owner_user_id: String, pub source_mode: String, pub seed_text: String, pub source_asset_ids_json: String, pub welcome_message_id: String, pub welcome_message_text: String, pub draft_json: Option, pub created_at_micros: i64, } #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct VisualNovelAgentSessionGetInput { pub session_id: String, pub owner_user_id: String, } #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct VisualNovelAgentMessageSubmitInput { pub session_id: String, pub owner_user_id: String, pub user_message_id: String, pub user_message_text: String, pub submitted_at_micros: i64, } #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct VisualNovelAgentMessageFinalizeInput { pub session_id: String, pub owner_user_id: String, pub assistant_message_id: Option, pub assistant_reply_text: Option, pub draft_json: Option, pub pending_action_json: Option, pub status: String, pub progress_percent: u32, pub updated_at_micros: i64, pub error_message: Option, } #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct VisualNovelWorkCompileInput { pub session_id: String, pub owner_user_id: String, pub profile_id: String, pub work_id: Option, pub author_display_name: String, pub work_title: Option, pub work_description: Option, pub tags_json: Option, pub cover_image_src: Option, pub compiled_at_micros: i64, } #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct VisualNovelWorkUpdateInput { pub profile_id: String, pub owner_user_id: String, pub work_title: String, pub work_description: String, pub tags_json: String, pub cover_image_src: Option, pub source_asset_ids_json: String, pub draft_json: String, pub publish_ready: bool, pub updated_at_micros: i64, } #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct VisualNovelWorkPublishInput { pub profile_id: String, pub owner_user_id: String, pub published_at_micros: i64, } #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct VisualNovelWorksListInput { pub owner_user_id: String, pub published_only: bool, } #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct VisualNovelWorkGetInput { pub profile_id: String, pub owner_user_id: String, } #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct VisualNovelWorkDeleteInput { pub profile_id: String, pub owner_user_id: String, } #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct VisualNovelRunStartInput { pub run_id: String, pub owner_user_id: String, pub profile_id: String, pub mode: String, pub snapshot_json: Option, pub started_at_micros: i64, } #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct VisualNovelRunGetInput { pub run_id: String, pub owner_user_id: String, } #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct VisualNovelRunSnapshotUpsertInput { pub run_id: String, pub owner_user_id: String, pub status: String, pub current_scene_id: Option, pub current_phase_id: Option, pub visible_character_ids_json: String, pub flags_json: String, pub metrics_json: String, pub available_choices_json: String, pub text_mode_enabled: bool, pub snapshot_json: Option, pub updated_at_micros: i64, } #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct VisualNovelRuntimeHistoryAppendInput { pub entry_id: String, pub run_id: String, pub owner_user_id: String, pub turn_index: u32, pub source: String, pub action_text: Option, pub steps_json: String, pub snapshot_before_hash: Option, pub snapshot_after_hash: Option, pub created_at_micros: i64, } #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct VisualNovelRuntimeHistoryListInput { pub run_id: String, pub owner_user_id: String, } #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct VisualNovelRuntimeEventRecordInput { pub event_id: String, pub run_id: String, pub owner_user_id: String, pub profile_id: Option, pub event_kind: String, pub client_event_id: Option, pub history_entry_id: Option, pub payload_json: String, pub occurred_at_micros: i64, } #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct VisualNovelAgentSessionProcedureResult { pub ok: bool, pub session_json: Option, pub error_message: Option, } #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct VisualNovelWorkProcedureResult { pub ok: bool, pub work_json: Option, pub error_message: Option, } #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct VisualNovelWorksProcedureResult { pub ok: bool, pub items_json: Option, pub error_message: Option, } #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct VisualNovelRunProcedureResult { pub ok: bool, pub run_json: Option, pub error_message: Option, } #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct VisualNovelHistoryProcedureResult { pub ok: bool, pub items_json: Option, pub error_message: Option, } #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct VisualNovelRuntimeEventProcedureResult { pub ok: bool, pub event_json: Option, pub error_message: Option, } #[derive(Clone, Debug, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub struct VisualNovelAgentMessageSnapshot { pub message_id: String, pub session_id: String, pub role: String, pub kind: String, pub text: String, pub created_at_micros: i64, } #[derive(Clone, Debug, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub struct VisualNovelAgentSessionSnapshot { pub session_id: String, pub owner_user_id: String, pub source_mode: String, pub status: String, pub seed_text: String, pub source_asset_ids: Vec, pub current_turn: u32, pub progress_percent: u32, pub messages: Vec, pub draft: Option, pub pending_action: Option, pub last_assistant_reply: Option, pub published_profile_id: Option, pub created_at_micros: i64, pub updated_at_micros: i64, } #[derive(Clone, Debug, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub struct VisualNovelWorkSnapshot { pub work_id: String, pub profile_id: String, pub owner_user_id: String, pub source_session_id: Option, pub author_display_name: String, pub work_title: String, pub work_description: String, pub tags: Vec, pub cover_image_src: Option, pub source_asset_ids: Vec, pub draft: JsonValue, pub publication_status: String, pub publish_ready: bool, pub play_count: u32, pub created_at_micros: i64, pub updated_at_micros: i64, pub published_at_micros: Option, } #[derive(Clone, Debug, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub struct VisualNovelRuntimeHistoryEntrySnapshot { pub entry_id: String, pub run_id: String, pub owner_user_id: String, pub profile_id: String, pub turn_index: u32, pub source: String, pub action_text: Option, pub steps: JsonValue, pub snapshot_before_hash: Option, pub snapshot_after_hash: Option, pub created_at_micros: i64, } #[derive(Clone, Debug, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub struct VisualNovelRunSnapshot { pub run_id: String, pub owner_user_id: String, pub profile_id: String, pub mode: String, pub status: String, pub current_scene_id: Option, pub current_phase_id: Option, pub visible_character_ids: Vec, pub flags: JsonValue, pub metrics: JsonValue, pub history: Vec, pub available_choices: JsonValue, pub text_mode_enabled: bool, pub created_at_micros: i64, pub updated_at_micros: i64, } #[derive(Clone, Debug, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub struct VisualNovelRuntimeEventSnapshot { pub event_id: String, pub run_id: Option, pub owner_user_id: String, pub profile_id: Option, pub event_kind: String, pub client_event_id: Option, pub history_entry_id: Option, pub payload: JsonValue, pub occurred_at_micros: i64, } #[spacetimedb::procedure] pub fn create_visual_novel_agent_session( ctx: &mut ProcedureContext, input: VisualNovelAgentSessionCreateInput, ) -> VisualNovelAgentSessionProcedureResult { match ctx.try_with_tx(|tx| create_visual_novel_agent_session_tx(tx, input.clone())) { Ok(session) => session_result(session), Err(message) => session_error(message), } } #[spacetimedb::procedure] pub fn get_visual_novel_agent_session( ctx: &mut ProcedureContext, input: VisualNovelAgentSessionGetInput, ) -> VisualNovelAgentSessionProcedureResult { match ctx.try_with_tx(|tx| get_visual_novel_agent_session_tx(tx, input.clone())) { Ok(session) => session_result(session), Err(message) => session_error(message), } } #[spacetimedb::procedure] pub fn submit_visual_novel_agent_message( ctx: &mut ProcedureContext, input: VisualNovelAgentMessageSubmitInput, ) -> VisualNovelAgentSessionProcedureResult { match ctx.try_with_tx(|tx| submit_visual_novel_agent_message_tx(tx, input.clone())) { Ok(session) => session_result(session), Err(message) => session_error(message), } } #[spacetimedb::procedure] pub fn finalize_visual_novel_agent_message_turn( ctx: &mut ProcedureContext, input: VisualNovelAgentMessageFinalizeInput, ) -> VisualNovelAgentSessionProcedureResult { match ctx.try_with_tx(|tx| finalize_visual_novel_agent_message_turn_tx(tx, input.clone())) { Ok(session) => session_result(session), Err(message) => session_error(message), } } #[spacetimedb::procedure] pub fn compile_visual_novel_work_profile( ctx: &mut ProcedureContext, input: VisualNovelWorkCompileInput, ) -> VisualNovelAgentSessionProcedureResult { match ctx.try_with_tx(|tx| compile_visual_novel_work_profile_tx(tx, input.clone())) { Ok(session) => session_result(session), Err(message) => session_error(message), } } #[spacetimedb::procedure] pub fn update_visual_novel_work( ctx: &mut ProcedureContext, input: VisualNovelWorkUpdateInput, ) -> VisualNovelWorkProcedureResult { match ctx.try_with_tx(|tx| update_visual_novel_work_tx(tx, input.clone())) { Ok(work) => work_result(work), Err(message) => work_error(message), } } #[spacetimedb::procedure] pub fn publish_visual_novel_work( ctx: &mut ProcedureContext, input: VisualNovelWorkPublishInput, ) -> VisualNovelWorkProcedureResult { match ctx.try_with_tx(|tx| publish_visual_novel_work_tx(tx, input.clone())) { Ok(work) => work_result(work), Err(message) => work_error(message), } } #[spacetimedb::procedure] pub fn list_visual_novel_works( ctx: &mut ProcedureContext, input: VisualNovelWorksListInput, ) -> VisualNovelWorksProcedureResult { match ctx.try_with_tx(|tx| list_visual_novel_works_tx(tx, input.clone())) { Ok(items) => VisualNovelWorksProcedureResult { ok: true, items_json: Some(to_json_string(&items)), error_message: None, }, Err(message) => VisualNovelWorksProcedureResult { ok: false, items_json: None, error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn get_visual_novel_work_detail( ctx: &mut ProcedureContext, input: VisualNovelWorkGetInput, ) -> VisualNovelWorkProcedureResult { match ctx.try_with_tx(|tx| get_visual_novel_work_detail_tx(tx, input.clone())) { Ok(work) => work_result(work), Err(message) => work_error(message), } } #[spacetimedb::procedure] pub fn delete_visual_novel_work( ctx: &mut ProcedureContext, input: VisualNovelWorkDeleteInput, ) -> VisualNovelWorksProcedureResult { match ctx.try_with_tx(|tx| delete_visual_novel_work_tx(tx, input.clone())) { Ok(items) => VisualNovelWorksProcedureResult { ok: true, items_json: Some(to_json_string(&items)), error_message: None, }, Err(message) => VisualNovelWorksProcedureResult { ok: false, items_json: None, error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn start_visual_novel_run( ctx: &mut ProcedureContext, input: VisualNovelRunStartInput, ) -> VisualNovelRunProcedureResult { match ctx.try_with_tx(|tx| start_visual_novel_run_tx(tx, input.clone())) { Ok(run) => run_result(run), Err(message) => run_error(message), } } #[spacetimedb::procedure] pub fn get_visual_novel_run( ctx: &mut ProcedureContext, input: VisualNovelRunGetInput, ) -> VisualNovelRunProcedureResult { match ctx.try_with_tx(|tx| get_visual_novel_run_tx(tx, input.clone())) { Ok(run) => run_result(run), Err(message) => run_error(message), } } #[spacetimedb::procedure] pub fn upsert_visual_novel_run_snapshot( ctx: &mut ProcedureContext, input: VisualNovelRunSnapshotUpsertInput, ) -> VisualNovelRunProcedureResult { match ctx.try_with_tx(|tx| upsert_visual_novel_run_snapshot_tx(tx, input.clone())) { Ok(run) => run_result(run), Err(message) => run_error(message), } } #[spacetimedb::procedure] pub fn append_visual_novel_runtime_history_entry( ctx: &mut ProcedureContext, input: VisualNovelRuntimeHistoryAppendInput, ) -> VisualNovelHistoryProcedureResult { match ctx.try_with_tx(|tx| append_visual_novel_runtime_history_entry_tx(tx, input.clone())) { Ok(items) => VisualNovelHistoryProcedureResult { ok: true, items_json: Some(to_json_string(&items)), error_message: None, }, Err(message) => VisualNovelHistoryProcedureResult { ok: false, items_json: None, error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn list_visual_novel_runtime_history( ctx: &mut ProcedureContext, input: VisualNovelRuntimeHistoryListInput, ) -> VisualNovelHistoryProcedureResult { match ctx.try_with_tx(|tx| list_visual_novel_runtime_history_tx(tx, input.clone())) { Ok(items) => VisualNovelHistoryProcedureResult { ok: true, items_json: Some(to_json_string(&items)), error_message: None, }, Err(message) => VisualNovelHistoryProcedureResult { ok: false, items_json: None, error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn record_visual_novel_runtime_event( ctx: &mut ProcedureContext, input: VisualNovelRuntimeEventRecordInput, ) -> VisualNovelRuntimeEventProcedureResult { match ctx.try_with_tx(|tx| record_visual_novel_runtime_event_tx(tx, input.clone())) { Ok(event) => VisualNovelRuntimeEventProcedureResult { ok: true, event_json: Some(to_json_string(&event)), error_message: None, }, Err(message) => VisualNovelRuntimeEventProcedureResult { ok: false, event_json: None, error_message: Some(message), }, } } fn create_visual_novel_agent_session_tx( ctx: &ReducerContext, input: VisualNovelAgentSessionCreateInput, ) -> Result { require_non_empty(&input.session_id, "visual_novel session_id")?; require_non_empty(&input.owner_user_id, "visual_novel owner_user_id")?; require_non_empty(&input.welcome_message_id, "visual_novel welcome_message_id")?; if ctx .db .visual_novel_agent_session() .session_id() .find(&input.session_id) .is_some() { return Err("visual_novel_agent_session.session_id 已存在".to_string()); } if ctx .db .visual_novel_agent_message() .message_id() .find(&input.welcome_message_id) .is_some() { return Err("visual_novel_agent_message.message_id 已存在".to_string()); } let source_asset_ids = parse_string_vec_or_empty(&input.source_asset_ids_json)?; let draft_json = input .draft_json .as_deref() .map(parse_json_value) .transpose()? .map(|value| to_json_string(&value)) .unwrap_or_default(); let created_at = Timestamp::from_micros_since_unix_epoch(input.created_at_micros); let welcome = input.welcome_message_text.trim().to_string(); ctx.db .visual_novel_agent_session() .insert(VisualNovelAgentSessionRow { session_id: input.session_id.clone(), owner_user_id: input.owner_user_id.clone(), source_mode: normalize_source_mode(&input.source_mode).to_string(), status: VISUAL_NOVEL_AGENT_STATUS_COLLECTING.to_string(), seed_text: input.seed_text.trim().to_string(), source_asset_ids_json: to_json_string(&source_asset_ids), current_turn: 0, progress_percent: 0, draft_json, pending_action_json: String::new(), last_assistant_reply: welcome.clone(), published_profile_id: String::new(), created_at, updated_at: created_at, }); ctx.db .visual_novel_agent_message() .insert(VisualNovelAgentMessageRow { message_id: input.welcome_message_id, session_id: input.session_id.clone(), role: VISUAL_NOVEL_ROLE_ASSISTANT.to_string(), kind: VISUAL_NOVEL_KIND_CHAT.to_string(), text: welcome, created_at, }); get_visual_novel_agent_session_tx( ctx, VisualNovelAgentSessionGetInput { session_id: input.session_id, owner_user_id: input.owner_user_id, }, ) } fn get_visual_novel_agent_session_tx( ctx: &ReducerContext, input: VisualNovelAgentSessionGetInput, ) -> Result { let row = find_owned_session(ctx, &input.session_id, &input.owner_user_id)?; build_session_snapshot(ctx, &row) } fn submit_visual_novel_agent_message_tx( ctx: &ReducerContext, input: VisualNovelAgentMessageSubmitInput, ) -> Result { require_non_empty(&input.user_message_id, "visual_novel user_message_id")?; require_non_empty(&input.user_message_text, "visual_novel user_message_text")?; let session = find_owned_session(ctx, &input.session_id, &input.owner_user_id)?; if ctx .db .visual_novel_agent_message() .message_id() .find(&input.user_message_id) .is_some() { return Err("visual_novel_agent_message.user_message_id 已存在".to_string()); } let submitted_at = Timestamp::from_micros_since_unix_epoch(input.submitted_at_micros); ctx.db .visual_novel_agent_message() .insert(VisualNovelAgentMessageRow { message_id: input.user_message_id, session_id: input.session_id.clone(), role: VISUAL_NOVEL_ROLE_USER.to_string(), kind: VISUAL_NOVEL_KIND_CHAT.to_string(), text: input.user_message_text.trim().to_string(), created_at: submitted_at, }); replace_session( ctx, &session, VisualNovelAgentSessionRow { status: VISUAL_NOVEL_AGENT_STATUS_DRAFTING.to_string(), updated_at: submitted_at, ..clone_session(&session) }, ); get_visual_novel_agent_session_tx( ctx, VisualNovelAgentSessionGetInput { session_id: input.session_id, owner_user_id: input.owner_user_id, }, ) } fn finalize_visual_novel_agent_message_turn_tx( ctx: &ReducerContext, input: VisualNovelAgentMessageFinalizeInput, ) -> Result { let session = find_owned_session(ctx, &input.session_id, &input.owner_user_id)?; let updated_at = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros); if let Some(message) = input .error_message .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) { replace_session( ctx, &session, VisualNovelAgentSessionRow { status: VISUAL_NOVEL_AGENT_STATUS_FAILED.to_string(), updated_at, ..clone_session(&session) }, ); return Err(message.to_string()); } let draft_json = input .draft_json .as_deref() .map(parse_json_value) .transpose()? .map(|value| to_json_string(&value)) .unwrap_or_else(|| session.draft_json.clone()); let pending_action_json = input .pending_action_json .as_deref() .map(parse_json_value) .transpose()? .map(|value| to_json_string(&value)) .unwrap_or_else(|| session.pending_action_json.clone()); let assistant_text = input .assistant_reply_text .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or(&session.last_assistant_reply) .to_string(); if let Some(message_id) = input .assistant_message_id .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) { if ctx .db .visual_novel_agent_message() .message_id() .find(&message_id.to_string()) .is_some() { return Err("visual_novel_agent_message.assistant_message_id 已存在".to_string()); } ctx.db .visual_novel_agent_message() .insert(VisualNovelAgentMessageRow { message_id: message_id.to_string(), session_id: input.session_id.clone(), role: VISUAL_NOVEL_ROLE_ASSISTANT.to_string(), kind: VISUAL_NOVEL_KIND_CHAT.to_string(), text: assistant_text.clone(), created_at: updated_at, }); } replace_session( ctx, &session, VisualNovelAgentSessionRow { current_turn: session.current_turn.saturating_add(1), progress_percent: input.progress_percent.min(100), status: normalize_agent_status(&input.status).to_string(), draft_json, pending_action_json, last_assistant_reply: assistant_text, updated_at, ..clone_session(&session) }, ); get_visual_novel_agent_session_tx( ctx, VisualNovelAgentSessionGetInput { session_id: input.session_id, owner_user_id: input.owner_user_id, }, ) } fn compile_visual_novel_work_profile_tx( ctx: &ReducerContext, input: VisualNovelWorkCompileInput, ) -> Result { require_non_empty(&input.profile_id, "visual_novel profile_id")?; let session = find_owned_session(ctx, &input.session_id, &input.owner_user_id)?; let draft = parse_optional_json_value(&session.draft_json)? .ok_or_else(|| "visual_novel session 尚未生成底稿".to_string())?; let compiled_at = Timestamp::from_micros_since_unix_epoch(input.compiled_at_micros); let tags = input .tags_json .as_deref() .map(parse_string_vec) .transpose()? .filter(|items| !items.is_empty()) .unwrap_or_else(|| draft_string_array(&draft, "workTags")); let source_asset_ids = parse_string_vec_or_empty(&session.source_asset_ids_json)?; let work_title = clean_optional(&input.work_title) .or_else(|| draft_string_field(&draft, "workTitle")) .unwrap_or_else(|| "未命名视觉小说".to_string()); let work_description = clean_optional(&input.work_description) .or_else(|| draft_string_field(&draft, "workDescription")) .unwrap_or_else(|| "视觉小说草稿".to_string()); let cover_image_src = clean_optional(&input.cover_image_src) .or_else(|| draft_string_field(&draft, "coverImageSrc")) .unwrap_or_default(); let publish_ready = draft_bool_field(&draft, "publishReady"); let work = VisualNovelWorkProfileRow { profile_id: input.profile_id.clone(), work_id: clean_optional(&input.work_id).unwrap_or_else(|| input.profile_id.clone()), owner_user_id: input.owner_user_id.clone(), source_session_id: input.session_id.clone(), author_display_name: clean_string(&input.author_display_name, "陶泥儿主"), work_title, work_description, tags_json: to_json_string(&normalize_tags(tags)), cover_image_src, source_asset_ids_json: to_json_string(&source_asset_ids), draft_json: to_json_string(&draft), publication_status: VISUAL_NOVEL_PUBLICATION_DRAFT.to_string(), publish_ready, play_count: 0, created_at: compiled_at, updated_at: compiled_at, published_at: None, }; upsert_work(ctx, work); replace_session( ctx, &session, VisualNovelAgentSessionRow { progress_percent: 80, status: VISUAL_NOVEL_AGENT_STATUS_READY.to_string(), published_profile_id: input.profile_id, last_assistant_reply: "视觉小说底稿已编译为作品草稿。".to_string(), updated_at: compiled_at, ..clone_session(&session) }, ); get_visual_novel_agent_session_tx( ctx, VisualNovelAgentSessionGetInput { session_id: input.session_id, owner_user_id: input.owner_user_id, }, ) } fn update_visual_novel_work_tx( ctx: &ReducerContext, input: VisualNovelWorkUpdateInput, ) -> Result { let current = find_owned_work(ctx, &input.profile_id, &input.owner_user_id)?; let tags = parse_string_vec(&input.tags_json)?; let source_asset_ids = parse_string_vec_or_empty(&input.source_asset_ids_json)?; let draft = parse_json_value(&input.draft_json)?; let updated_at = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros); let next = VisualNovelWorkProfileRow { work_title: clean_string(&input.work_title, "未命名视觉小说"), work_description: clean_string(&input.work_description, "视觉小说草稿"), tags_json: to_json_string(&normalize_tags(tags)), cover_image_src: clean_optional(&input.cover_image_src).unwrap_or_default(), source_asset_ids_json: to_json_string(&source_asset_ids), draft_json: to_json_string(&draft), publish_ready: input.publish_ready, updated_at, ..clone_work(¤t) }; let snapshot = build_work_snapshot(&next)?; replace_work(ctx, ¤t, next); Ok(snapshot) } fn publish_visual_novel_work_tx( ctx: &ReducerContext, input: VisualNovelWorkPublishInput, ) -> Result { let current = find_owned_work(ctx, &input.profile_id, &input.owner_user_id)?; validate_publishable_work(¤t)?; let published_at = Timestamp::from_micros_since_unix_epoch(input.published_at_micros); let next = VisualNovelWorkProfileRow { publication_status: VISUAL_NOVEL_PUBLICATION_PUBLISHED.to_string(), updated_at: published_at, published_at: Some(published_at), ..clone_work(¤t) }; if !next.source_session_id.is_empty() { if let Some(session) = ctx .db .visual_novel_agent_session() .session_id() .find(&next.source_session_id) .filter(|row| row.owner_user_id == input.owner_user_id) { replace_session( ctx, &session, VisualNovelAgentSessionRow { progress_percent: 100, status: VISUAL_NOVEL_AGENT_STATUS_READY.to_string(), published_profile_id: next.profile_id.clone(), updated_at: published_at, ..clone_session(&session) }, ); } } let snapshot = build_work_snapshot(&next)?; replace_work(ctx, ¤t, next); Ok(snapshot) } fn list_visual_novel_works_tx( ctx: &ReducerContext, input: VisualNovelWorksListInput, ) -> Result, String> { let mut items = ctx .db .visual_novel_work_profile() .iter() .filter(|row| { if input.published_only { row.publication_status == VISUAL_NOVEL_PUBLICATION_PUBLISHED } else { row.owner_user_id == input.owner_user_id } }) .map(|row| build_work_snapshot(&row)) .collect::, _>>()?; items.sort_by(|left, right| { right .updated_at_micros .cmp(&left.updated_at_micros) .then_with(|| left.profile_id.cmp(&right.profile_id)) }); Ok(items) } fn get_visual_novel_work_detail_tx( ctx: &ReducerContext, input: VisualNovelWorkGetInput, ) -> Result { let row = ctx .db .visual_novel_work_profile() .profile_id() .find(&input.profile_id) .filter(|row| { row.owner_user_id == input.owner_user_id || row.publication_status == VISUAL_NOVEL_PUBLICATION_PUBLISHED }) .ok_or_else(|| "visual_novel_work_profile 不存在".to_string())?; build_work_snapshot(&row) } fn delete_visual_novel_work_tx( ctx: &ReducerContext, input: VisualNovelWorkDeleteInput, ) -> Result, String> { let work = find_owned_work(ctx, &input.profile_id, &input.owner_user_id)?; ctx.db .visual_novel_work_profile() .profile_id() .delete(&work.profile_id); for run in ctx .db .visual_novel_runtime_run() .iter() .filter(|row| { row.profile_id == input.profile_id && row.owner_user_id == input.owner_user_id }) .collect::>() { delete_run_children(ctx, &run.run_id, &input.owner_user_id); ctx.db .visual_novel_runtime_run() .run_id() .delete(&run.run_id); } list_visual_novel_works_tx( ctx, VisualNovelWorksListInput { owner_user_id: input.owner_user_id, published_only: false, }, ) } fn start_visual_novel_run_tx( ctx: &ReducerContext, input: VisualNovelRunStartInput, ) -> Result { require_non_empty(&input.run_id, "visual_novel run_id")?; if ctx .db .visual_novel_runtime_run() .run_id() .find(&input.run_id) .is_some() { return Err("visual_novel_runtime_run.run_id 已存在".to_string()); } let work = find_readable_work(ctx, &input.profile_id, &input.owner_user_id)?; let started_at = Timestamp::from_micros_since_unix_epoch(input.started_at_micros); let draft = parse_json_value(&work.draft_json)?; let current_scene_id = draft_path_string(&draft, &["opening", "sceneId"]).unwrap_or_default(); let current_phase_id = draft .get("storyPhases") .and_then(JsonValue::as_array) .and_then(|items| items.first()) .and_then(|phase| draft_path_string(phase, &["phaseId"])) .unwrap_or_default(); let available_choices = draft .get("opening") .and_then(|opening| opening.get("initialChoices")) .cloned() .unwrap_or_else(|| JsonValue::Array(Vec::new())); let text_mode_enabled = draft_path_bool(&draft, &["runtimeConfig", "textModeEnabled"]) .unwrap_or(true) && draft_path_bool(&draft, &["runtimeConfig", "defaultTextMode"]).unwrap_or(false); let row = VisualNovelRuntimeRunRow { run_id: input.run_id.clone(), owner_user_id: input.owner_user_id.clone(), profile_id: input.profile_id.clone(), mode: normalize_run_mode(&input.mode).to_string(), status: VISUAL_NOVEL_RUN_STATUS_ACTIVE.to_string(), current_scene_id, current_phase_id, visible_character_ids_json: "[]".to_string(), flags_json: "{}".to_string(), metrics_json: "{}".to_string(), available_choices_json: to_json_string(&available_choices), text_mode_enabled, snapshot_json: input.snapshot_json.unwrap_or_default(), created_at: started_at, updated_at: started_at, }; let snapshot = build_run_snapshot(ctx, &row)?; let row = VisualNovelRuntimeRunRow { snapshot_json: to_json_string(&snapshot), ..row }; ctx.db.visual_novel_runtime_run().insert(row); Ok(snapshot) } fn get_visual_novel_run_tx( ctx: &ReducerContext, input: VisualNovelRunGetInput, ) -> Result { let row = find_owned_run(ctx, &input.run_id, &input.owner_user_id)?; build_run_snapshot(ctx, &row) } fn upsert_visual_novel_run_snapshot_tx( ctx: &ReducerContext, input: VisualNovelRunSnapshotUpsertInput, ) -> Result { let current = find_owned_run(ctx, &input.run_id, &input.owner_user_id)?; parse_string_vec_or_empty(&input.visible_character_ids_json)?; parse_json_value(&input.flags_json)?; parse_json_value(&input.metrics_json)?; parse_json_value(&input.available_choices_json)?; if let Some(snapshot_json) = &input.snapshot_json { parse_json_value(snapshot_json)?; } let updated_at = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros); let next = VisualNovelRuntimeRunRow { status: normalize_run_status(&input.status).to_string(), current_scene_id: clean_optional(&input.current_scene_id).unwrap_or_default(), current_phase_id: clean_optional(&input.current_phase_id).unwrap_or_default(), visible_character_ids_json: input.visible_character_ids_json, flags_json: input.flags_json, metrics_json: input.metrics_json, available_choices_json: input.available_choices_json, text_mode_enabled: input.text_mode_enabled, snapshot_json: input.snapshot_json.unwrap_or_default(), updated_at, ..clone_run(¤t) }; let snapshot = build_run_snapshot(ctx, &next)?; replace_run(ctx, ¤t, next); Ok(snapshot) } fn append_visual_novel_runtime_history_entry_tx( ctx: &ReducerContext, input: VisualNovelRuntimeHistoryAppendInput, ) -> Result, String> { require_non_empty(&input.entry_id, "visual_novel history entry_id")?; let run = find_owned_run(ctx, &input.run_id, &input.owner_user_id)?; if ctx .db .visual_novel_runtime_history_entry() .entry_id() .find(&input.entry_id) .is_some() { return Err("visual_novel_runtime_history_entry.entry_id 已存在".to_string()); } parse_json_value(&input.steps_json)?; let created_at = Timestamp::from_micros_since_unix_epoch(input.created_at_micros); ctx.db .visual_novel_runtime_history_entry() .insert(VisualNovelRuntimeHistoryEntryRow { entry_id: input.entry_id, run_id: input.run_id.clone(), owner_user_id: input.owner_user_id.clone(), profile_id: run.profile_id, turn_index: input.turn_index, source: normalize_history_source(&input.source).to_string(), action_text: clean_optional(&input.action_text).unwrap_or_default(), steps_json: input.steps_json, snapshot_before_hash: clean_optional(&input.snapshot_before_hash).unwrap_or_default(), snapshot_after_hash: clean_optional(&input.snapshot_after_hash).unwrap_or_default(), created_at, }); list_visual_novel_runtime_history_tx( ctx, VisualNovelRuntimeHistoryListInput { run_id: input.run_id, owner_user_id: input.owner_user_id, }, ) } fn list_visual_novel_runtime_history_tx( ctx: &ReducerContext, input: VisualNovelRuntimeHistoryListInput, ) -> Result, String> { find_owned_run(ctx, &input.run_id, &input.owner_user_id)?; build_history_snapshots(ctx, &input.run_id, &input.owner_user_id) } fn record_visual_novel_runtime_event_tx( ctx: &ReducerContext, input: VisualNovelRuntimeEventRecordInput, ) -> Result { require_non_empty(&input.event_id, "visual_novel event_id")?; require_non_empty(&input.owner_user_id, "visual_novel owner_user_id")?; parse_json_value(&input.payload_json)?; if ctx .db .visual_novel_runtime_event() .event_id() .find(&input.event_id) .is_some() { return Err("visual_novel_runtime_event.event_id 已存在".to_string()); } let run = clean_string(&input.run_id, ""); let profile_id = clean_optional(&input.profile_id) .or_else(|| { ctx.db .visual_novel_runtime_run() .run_id() .find(&run) .filter(|row| row.owner_user_id == input.owner_user_id) .map(|row| row.profile_id) }) .unwrap_or_default(); let occurred_at = Timestamp::from_micros_since_unix_epoch(input.occurred_at_micros); let row = VisualNovelRuntimeEvent { event_id: input.event_id, run_id: run, owner_user_id: input.owner_user_id, profile_id, event_kind: clean_string(&input.event_kind, "runtime_event"), client_event_id: clean_optional(&input.client_event_id).unwrap_or_default(), history_entry_id: clean_optional(&input.history_entry_id).unwrap_or_default(), payload_json: input.payload_json, occurred_at, }; let snapshot = build_event_snapshot(&row)?; ctx.db.visual_novel_runtime_event().insert(row); Ok(snapshot) } fn find_owned_session( ctx: &ReducerContext, session_id: &str, owner_user_id: &str, ) -> Result { require_non_empty(session_id, "visual_novel session_id")?; require_non_empty(owner_user_id, "visual_novel owner_user_id")?; ctx.db .visual_novel_agent_session() .session_id() .find(&session_id.to_string()) .filter(|row| row.owner_user_id == owner_user_id) .ok_or_else(|| "visual_novel_agent_session 不存在".to_string()) } fn find_owned_work( ctx: &ReducerContext, profile_id: &str, owner_user_id: &str, ) -> Result { require_non_empty(profile_id, "visual_novel profile_id")?; require_non_empty(owner_user_id, "visual_novel owner_user_id")?; ctx.db .visual_novel_work_profile() .profile_id() .find(&profile_id.to_string()) .filter(|row| row.owner_user_id == owner_user_id) .ok_or_else(|| "visual_novel_work_profile 不存在".to_string()) } fn find_readable_work( ctx: &ReducerContext, profile_id: &str, owner_user_id: &str, ) -> Result { require_non_empty(profile_id, "visual_novel profile_id")?; ctx.db .visual_novel_work_profile() .profile_id() .find(&profile_id.to_string()) .filter(|row| { row.owner_user_id == owner_user_id || row.publication_status == VISUAL_NOVEL_PUBLICATION_PUBLISHED }) .ok_or_else(|| "visual_novel_work_profile 不存在".to_string()) } fn find_owned_run( ctx: &ReducerContext, run_id: &str, owner_user_id: &str, ) -> Result { require_non_empty(run_id, "visual_novel run_id")?; require_non_empty(owner_user_id, "visual_novel owner_user_id")?; ctx.db .visual_novel_runtime_run() .run_id() .find(&run_id.to_string()) .filter(|row| row.owner_user_id == owner_user_id) .ok_or_else(|| "visual_novel_runtime_run 不存在".to_string()) } fn build_session_snapshot( ctx: &ReducerContext, row: &VisualNovelAgentSessionRow, ) -> Result { let mut messages = ctx .db .visual_novel_agent_message() .iter() .filter(|message| message.session_id == row.session_id) .map(|message| VisualNovelAgentMessageSnapshot { message_id: message.message_id, session_id: message.session_id, role: message.role, kind: message.kind, text: message.text, created_at_micros: message.created_at.to_micros_since_unix_epoch(), }) .collect::>(); messages.sort_by(|left, right| { left.created_at_micros .cmp(&right.created_at_micros) .then_with(|| left.message_id.cmp(&right.message_id)) }); Ok(VisualNovelAgentSessionSnapshot { session_id: row.session_id.clone(), owner_user_id: row.owner_user_id.clone(), source_mode: row.source_mode.clone(), status: row.status.clone(), seed_text: row.seed_text.clone(), source_asset_ids: parse_string_vec_or_empty(&row.source_asset_ids_json)?, current_turn: row.current_turn, progress_percent: row.progress_percent, messages, draft: parse_optional_json_value(&row.draft_json)?, pending_action: parse_optional_json_value(&row.pending_action_json)?, last_assistant_reply: empty_to_none(&row.last_assistant_reply), published_profile_id: empty_to_none(&row.published_profile_id), created_at_micros: row.created_at.to_micros_since_unix_epoch(), updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), }) } fn build_work_snapshot(row: &VisualNovelWorkProfileRow) -> Result { Ok(VisualNovelWorkSnapshot { work_id: row.work_id.clone(), profile_id: row.profile_id.clone(), owner_user_id: row.owner_user_id.clone(), source_session_id: empty_to_none(&row.source_session_id), author_display_name: row.author_display_name.clone(), work_title: row.work_title.clone(), work_description: row.work_description.clone(), tags: parse_string_vec_or_empty(&row.tags_json)?, cover_image_src: empty_to_none(&row.cover_image_src), source_asset_ids: parse_string_vec_or_empty(&row.source_asset_ids_json)?, draft: parse_json_value(&row.draft_json)?, publication_status: row.publication_status.clone(), publish_ready: row.publish_ready, play_count: row.play_count, created_at_micros: row.created_at.to_micros_since_unix_epoch(), updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), published_at_micros: row .published_at .map(|value| value.to_micros_since_unix_epoch()), }) } fn build_run_snapshot( ctx: &ReducerContext, row: &VisualNovelRuntimeRunRow, ) -> Result { Ok(VisualNovelRunSnapshot { run_id: row.run_id.clone(), owner_user_id: row.owner_user_id.clone(), profile_id: row.profile_id.clone(), mode: row.mode.clone(), status: row.status.clone(), current_scene_id: empty_to_none(&row.current_scene_id), current_phase_id: empty_to_none(&row.current_phase_id), visible_character_ids: parse_string_vec_or_empty(&row.visible_character_ids_json)?, flags: parse_json_value_or_object(&row.flags_json)?, metrics: parse_json_value_or_object(&row.metrics_json)?, history: build_history_snapshots(ctx, &row.run_id, &row.owner_user_id)?, available_choices: parse_json_value_or_array(&row.available_choices_json)?, text_mode_enabled: row.text_mode_enabled, created_at_micros: row.created_at.to_micros_since_unix_epoch(), updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), }) } fn build_history_snapshots( ctx: &ReducerContext, run_id: &str, owner_user_id: &str, ) -> Result, String> { let mut items = ctx .db .visual_novel_runtime_history_entry() .iter() .filter(|row| row.run_id == run_id && row.owner_user_id == owner_user_id) .map(|row| build_history_snapshot(&row)) .collect::, _>>()?; items.sort_by(|left, right| { left.turn_index .cmp(&right.turn_index) .then_with(|| left.created_at_micros.cmp(&right.created_at_micros)) .then_with(|| left.entry_id.cmp(&right.entry_id)) }); Ok(items) } fn build_history_snapshot( row: &VisualNovelRuntimeHistoryEntryRow, ) -> Result { Ok(VisualNovelRuntimeHistoryEntrySnapshot { entry_id: row.entry_id.clone(), run_id: row.run_id.clone(), owner_user_id: row.owner_user_id.clone(), profile_id: row.profile_id.clone(), turn_index: row.turn_index, source: row.source.clone(), action_text: empty_to_none(&row.action_text), steps: parse_json_value_or_array(&row.steps_json)?, snapshot_before_hash: empty_to_none(&row.snapshot_before_hash), snapshot_after_hash: empty_to_none(&row.snapshot_after_hash), created_at_micros: row.created_at.to_micros_since_unix_epoch(), }) } fn build_event_snapshot( row: &VisualNovelRuntimeEvent, ) -> Result { Ok(VisualNovelRuntimeEventSnapshot { event_id: row.event_id.clone(), run_id: empty_to_none(&row.run_id), owner_user_id: row.owner_user_id.clone(), profile_id: empty_to_none(&row.profile_id), event_kind: row.event_kind.clone(), client_event_id: empty_to_none(&row.client_event_id), history_entry_id: empty_to_none(&row.history_entry_id), payload: parse_json_value_or_object(&row.payload_json)?, occurred_at_micros: row.occurred_at.to_micros_since_unix_epoch(), }) } fn upsert_work(ctx: &ReducerContext, work: VisualNovelWorkProfileRow) { if ctx .db .visual_novel_work_profile() .profile_id() .find(&work.profile_id) .is_some() { ctx.db .visual_novel_work_profile() .profile_id() .delete(&work.profile_id); } ctx.db.visual_novel_work_profile().insert(work); } fn replace_session( ctx: &ReducerContext, current: &VisualNovelAgentSessionRow, next: VisualNovelAgentSessionRow, ) { ctx.db .visual_novel_agent_session() .session_id() .delete(¤t.session_id); ctx.db.visual_novel_agent_session().insert(next); } fn replace_work( ctx: &ReducerContext, current: &VisualNovelWorkProfileRow, next: VisualNovelWorkProfileRow, ) { ctx.db .visual_novel_work_profile() .profile_id() .delete(¤t.profile_id); ctx.db.visual_novel_work_profile().insert(next); } fn replace_run( ctx: &ReducerContext, current: &VisualNovelRuntimeRunRow, next: VisualNovelRuntimeRunRow, ) { ctx.db .visual_novel_runtime_run() .run_id() .delete(¤t.run_id); ctx.db.visual_novel_runtime_run().insert(next); } fn delete_run_children(ctx: &ReducerContext, run_id: &str, owner_user_id: &str) { for history in ctx .db .visual_novel_runtime_history_entry() .iter() .filter(|row| row.run_id == run_id && row.owner_user_id == owner_user_id) .collect::>() { ctx.db .visual_novel_runtime_history_entry() .entry_id() .delete(&history.entry_id); } } fn clone_session(row: &VisualNovelAgentSessionRow) -> VisualNovelAgentSessionRow { VisualNovelAgentSessionRow { session_id: row.session_id.clone(), owner_user_id: row.owner_user_id.clone(), source_mode: row.source_mode.clone(), status: row.status.clone(), seed_text: row.seed_text.clone(), source_asset_ids_json: row.source_asset_ids_json.clone(), current_turn: row.current_turn, progress_percent: row.progress_percent, draft_json: row.draft_json.clone(), pending_action_json: row.pending_action_json.clone(), last_assistant_reply: row.last_assistant_reply.clone(), published_profile_id: row.published_profile_id.clone(), created_at: row.created_at, updated_at: row.updated_at, } } fn clone_work(row: &VisualNovelWorkProfileRow) -> VisualNovelWorkProfileRow { VisualNovelWorkProfileRow { profile_id: row.profile_id.clone(), work_id: row.work_id.clone(), owner_user_id: row.owner_user_id.clone(), source_session_id: row.source_session_id.clone(), author_display_name: row.author_display_name.clone(), work_title: row.work_title.clone(), work_description: row.work_description.clone(), tags_json: row.tags_json.clone(), cover_image_src: row.cover_image_src.clone(), source_asset_ids_json: row.source_asset_ids_json.clone(), draft_json: row.draft_json.clone(), publication_status: row.publication_status.clone(), publish_ready: row.publish_ready, play_count: row.play_count, created_at: row.created_at, updated_at: row.updated_at, published_at: row.published_at, } } fn clone_run(row: &VisualNovelRuntimeRunRow) -> VisualNovelRuntimeRunRow { VisualNovelRuntimeRunRow { run_id: row.run_id.clone(), owner_user_id: row.owner_user_id.clone(), profile_id: row.profile_id.clone(), mode: row.mode.clone(), status: row.status.clone(), current_scene_id: row.current_scene_id.clone(), current_phase_id: row.current_phase_id.clone(), visible_character_ids_json: row.visible_character_ids_json.clone(), flags_json: row.flags_json.clone(), metrics_json: row.metrics_json.clone(), available_choices_json: row.available_choices_json.clone(), text_mode_enabled: row.text_mode_enabled, snapshot_json: row.snapshot_json.clone(), created_at: row.created_at, updated_at: row.updated_at, } } fn validate_publishable_work(row: &VisualNovelWorkProfileRow) -> Result<(), String> { if row.work_title.trim().is_empty() { return Err("visual_novel 发布需要填写作品标题".to_string()); } if row.work_description.trim().is_empty() { return Err("visual_novel 发布需要填写作品简介".to_string()); } if parse_string_vec_or_empty(&row.tags_json)?.is_empty() { return Err("visual_novel 发布需要至少 1 个标签".to_string()); } parse_json_value(&row.draft_json)?; if !row.publish_ready { return Err("visual_novel 发布前需要通过校验".to_string()); } Ok(()) } fn normalize_source_mode(value: &str) -> &'static str { match value.trim() { VISUAL_NOVEL_SOURCE_DOCUMENT => VISUAL_NOVEL_SOURCE_DOCUMENT, VISUAL_NOVEL_SOURCE_BLANK => VISUAL_NOVEL_SOURCE_BLANK, _ => VISUAL_NOVEL_SOURCE_IDEA, } } fn normalize_agent_status(value: &str) -> &'static str { match value.trim() { VISUAL_NOVEL_AGENT_STATUS_DRAFTING => VISUAL_NOVEL_AGENT_STATUS_DRAFTING, VISUAL_NOVEL_AGENT_STATUS_READY => VISUAL_NOVEL_AGENT_STATUS_READY, VISUAL_NOVEL_AGENT_STATUS_FAILED => VISUAL_NOVEL_AGENT_STATUS_FAILED, _ => VISUAL_NOVEL_AGENT_STATUS_COLLECTING, } } fn normalize_run_mode(value: &str) -> &'static str { match value.trim() { VISUAL_NOVEL_RUN_MODE_PLAY => VISUAL_NOVEL_RUN_MODE_PLAY, _ => VISUAL_NOVEL_RUN_MODE_TEST, } } fn normalize_run_status(value: &str) -> &'static str { match value.trim() { VISUAL_NOVEL_RUN_STATUS_COMPLETED => VISUAL_NOVEL_RUN_STATUS_COMPLETED, VISUAL_NOVEL_RUN_STATUS_FAILED => VISUAL_NOVEL_RUN_STATUS_FAILED, _ => VISUAL_NOVEL_RUN_STATUS_ACTIVE, } } fn normalize_history_source(value: &str) -> &'static str { match value.trim() { VISUAL_NOVEL_HISTORY_SOURCE_ASSISTANT => VISUAL_NOVEL_HISTORY_SOURCE_ASSISTANT, VISUAL_NOVEL_HISTORY_SOURCE_SYSTEM => VISUAL_NOVEL_HISTORY_SOURCE_SYSTEM, _ => VISUAL_NOVEL_HISTORY_SOURCE_PLAYER, } } fn normalize_tags(tags: Vec) -> Vec { let mut result = Vec::new(); for tag in tags { let trimmed = tag.trim(); if !trimmed.is_empty() && !result.iter().any(|item: &String| item == trimmed) { result.push(trimmed.to_string()); } if result.len() >= 8 { break; } } result } fn parse_string_vec_or_empty(value: &str) -> Result, String> { if value.trim().is_empty() { return Ok(Vec::new()); } parse_string_vec(value) } fn parse_string_vec(value: &str) -> Result, String> { parse_json::>(value, "visual_novel string array").map(normalize_tags) } fn parse_optional_json_value(value: &str) -> Result, String> { if value.trim().is_empty() { return Ok(None); } parse_json_value(value).map(Some) } fn parse_json_value(value: &str) -> Result { parse_json(value, "visual_novel json") } fn parse_json_value_or_object(value: &str) -> Result { if value.trim().is_empty() { return Ok(JsonValue::Object(JsonMap::new())); } parse_json_value(value) } fn parse_json_value_or_array(value: &str) -> Result { if value.trim().is_empty() { return Ok(JsonValue::Array(Vec::new())); } parse_json_value(value) } fn draft_string_field(draft: &JsonValue, key: &str) -> Option { draft .get(key) .and_then(JsonValue::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string) } fn draft_string_array(draft: &JsonValue, key: &str) -> Vec { draft .get(key) .and_then(JsonValue::as_array) .map(|items| { items .iter() .filter_map(JsonValue::as_str) .map(str::to_string) .collect::>() }) .map(normalize_tags) .unwrap_or_default() } fn draft_bool_field(draft: &JsonValue, key: &str) -> bool { draft.get(key).and_then(JsonValue::as_bool).unwrap_or(false) } fn draft_path_string(value: &JsonValue, path: &[&str]) -> Option { let mut current = value; for key in path { current = current.get(*key)?; } current .as_str() .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string) } fn draft_path_bool(value: &JsonValue, path: &[&str]) -> Option { let mut current = value; for key in path { current = current.get(*key)?; } current.as_bool() } fn clean_optional(value: &Option) -> Option { value .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string) } fn clean_string(value: &str, fallback: &str) -> String { let trimmed = value.trim(); if trimmed.is_empty() { fallback.to_string() } else { trimmed.to_string() } } fn empty_to_none(value: &str) -> Option { let trimmed = value.trim(); if trimmed.is_empty() { None } else { Some(trimmed.to_string()) } } fn require_non_empty(value: &str, label: &str) -> Result<(), String> { if value.trim().is_empty() { Err(format!("{label} 不能为空")) } else { Ok(()) } } fn parse_json(value: &str, label: &str) -> Result { serde_json::from_str(value).map_err(|error| format!("{label} 非法: {error}")) } fn to_json_string(value: &T) -> String { serde_json::to_string(value).unwrap_or_else(|_| "{}".to_string()) } fn session_result( session: VisualNovelAgentSessionSnapshot, ) -> VisualNovelAgentSessionProcedureResult { VisualNovelAgentSessionProcedureResult { ok: true, session_json: Some(to_json_string(&session)), error_message: None, } } fn session_error(message: String) -> VisualNovelAgentSessionProcedureResult { VisualNovelAgentSessionProcedureResult { ok: false, session_json: None, error_message: Some(message), } } fn work_result(work: VisualNovelWorkSnapshot) -> VisualNovelWorkProcedureResult { VisualNovelWorkProcedureResult { ok: true, work_json: Some(to_json_string(&work)), error_message: None, } } fn work_error(message: String) -> VisualNovelWorkProcedureResult { VisualNovelWorkProcedureResult { ok: false, work_json: None, error_message: Some(message), } } fn run_result(run: VisualNovelRunSnapshot) -> VisualNovelRunProcedureResult { VisualNovelRunProcedureResult { ok: true, run_json: Some(to_json_string(&run)), error_message: None, } } fn run_error(message: String) -> VisualNovelRunProcedureResult { VisualNovelRunProcedureResult { ok: false, run_json: None, error_message: Some(message), } }