Files
Genarrative/server-rs/crates/spacetime-module/src/visual_novel.rs
2026-05-14 14:21:17 +08:00

1900 lines
64 KiB
Rust

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<Timestamp>,
}
/// 视觉小说运行态 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<String>,
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<String>,
pub assistant_reply_text: Option<String>,
pub draft_json: Option<String>,
pub pending_action_json: Option<String>,
pub status: String,
pub progress_percent: u32,
pub updated_at_micros: i64,
pub error_message: Option<String>,
}
#[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<String>,
pub author_display_name: String,
pub work_title: Option<String>,
pub work_description: Option<String>,
pub tags_json: Option<String>,
pub cover_image_src: Option<String>,
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<String>,
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<String>,
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<String>,
pub current_phase_id: Option<String>,
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<String>,
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<String>,
pub steps_json: String,
pub snapshot_before_hash: Option<String>,
pub snapshot_after_hash: Option<String>,
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<String>,
pub event_kind: String,
pub client_event_id: Option<String>,
pub history_entry_id: Option<String>,
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<String>,
pub error_message: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct VisualNovelWorkProcedureResult {
pub ok: bool,
pub work_json: Option<String>,
pub error_message: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct VisualNovelWorksProcedureResult {
pub ok: bool,
pub items_json: Option<String>,
pub error_message: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct VisualNovelRunProcedureResult {
pub ok: bool,
pub run_json: Option<String>,
pub error_message: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct VisualNovelHistoryProcedureResult {
pub ok: bool,
pub items_json: Option<String>,
pub error_message: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct VisualNovelRuntimeEventProcedureResult {
pub ok: bool,
pub event_json: Option<String>,
pub error_message: Option<String>,
}
#[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<String>,
pub current_turn: u32,
pub progress_percent: u32,
pub messages: Vec<VisualNovelAgentMessageSnapshot>,
pub draft: Option<JsonValue>,
pub pending_action: Option<JsonValue>,
pub last_assistant_reply: Option<String>,
pub published_profile_id: Option<String>,
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<String>,
pub author_display_name: String,
pub work_title: String,
pub work_description: String,
pub tags: Vec<String>,
pub cover_image_src: Option<String>,
pub source_asset_ids: Vec<String>,
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<i64>,
}
#[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<String>,
pub steps: JsonValue,
pub snapshot_before_hash: Option<String>,
pub snapshot_after_hash: Option<String>,
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<String>,
pub current_phase_id: Option<String>,
pub visible_character_ids: Vec<String>,
pub flags: JsonValue,
pub metrics: JsonValue,
pub history: Vec<VisualNovelRuntimeHistoryEntrySnapshot>,
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<String>,
pub owner_user_id: String,
pub profile_id: Option<String>,
pub event_kind: String,
pub client_event_id: Option<String>,
pub history_entry_id: Option<String>,
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<VisualNovelAgentSessionSnapshot, String> {
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<VisualNovelAgentSessionSnapshot, String> {
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<VisualNovelAgentSessionSnapshot, String> {
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<VisualNovelAgentSessionSnapshot, String> {
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<VisualNovelAgentSessionSnapshot, String> {
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<VisualNovelWorkSnapshot, String> {
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(&current)
};
let snapshot = build_work_snapshot(&next)?;
replace_work(ctx, &current, next);
Ok(snapshot)
}
fn publish_visual_novel_work_tx(
ctx: &ReducerContext,
input: VisualNovelWorkPublishInput,
) -> Result<VisualNovelWorkSnapshot, String> {
let current = find_owned_work(ctx, &input.profile_id, &input.owner_user_id)?;
validate_publishable_work(&current)?;
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(&current)
};
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, &current, next);
Ok(snapshot)
}
fn list_visual_novel_works_tx(
ctx: &ReducerContext,
input: VisualNovelWorksListInput,
) -> Result<Vec<VisualNovelWorkSnapshot>, 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::<Result<Vec<_>, _>>()?;
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<VisualNovelWorkSnapshot, String> {
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<Vec<VisualNovelWorkSnapshot>, 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::<Vec<_>>()
{
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<VisualNovelRunSnapshot, String> {
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<VisualNovelRunSnapshot, String> {
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<VisualNovelRunSnapshot, String> {
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(&current)
};
let snapshot = build_run_snapshot(ctx, &next)?;
replace_run(ctx, &current, next);
Ok(snapshot)
}
fn append_visual_novel_runtime_history_entry_tx(
ctx: &ReducerContext,
input: VisualNovelRuntimeHistoryAppendInput,
) -> Result<Vec<VisualNovelRuntimeHistoryEntrySnapshot>, 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<Vec<VisualNovelRuntimeHistoryEntrySnapshot>, 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<VisualNovelRuntimeEventSnapshot, String> {
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<VisualNovelAgentSessionRow, String> {
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<VisualNovelWorkProfileRow, String> {
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<VisualNovelWorkProfileRow, String> {
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<VisualNovelRuntimeRunRow, String> {
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<VisualNovelAgentSessionSnapshot, String> {
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::<Vec<_>>();
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<VisualNovelWorkSnapshot, String> {
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<VisualNovelRunSnapshot, String> {
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<Vec<VisualNovelRuntimeHistoryEntrySnapshot>, 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::<Result<Vec<_>, _>>()?;
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<VisualNovelRuntimeHistoryEntrySnapshot, String> {
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<VisualNovelRuntimeEventSnapshot, String> {
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(&current.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(&current.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(&current.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::<Vec<_>>()
{
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<String>) -> Vec<String> {
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<Vec<String>, String> {
if value.trim().is_empty() {
return Ok(Vec::new());
}
parse_string_vec(value)
}
fn parse_string_vec(value: &str) -> Result<Vec<String>, String> {
parse_json::<Vec<String>>(value, "visual_novel string array").map(normalize_tags)
}
fn parse_optional_json_value(value: &str) -> Result<Option<JsonValue>, String> {
if value.trim().is_empty() {
return Ok(None);
}
parse_json_value(value).map(Some)
}
fn parse_json_value(value: &str) -> Result<JsonValue, String> {
parse_json(value, "visual_novel json")
}
fn parse_json_value_or_object(value: &str) -> Result<JsonValue, String> {
if value.trim().is_empty() {
return Ok(JsonValue::Object(JsonMap::new()));
}
parse_json_value(value)
}
fn parse_json_value_or_array(value: &str) -> Result<JsonValue, String> {
if value.trim().is_empty() {
return Ok(JsonValue::Array(Vec::new()));
}
parse_json_value(value)
}
fn draft_string_field(draft: &JsonValue, key: &str) -> Option<String> {
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<String> {
draft
.get(key)
.and_then(JsonValue::as_array)
.map(|items| {
items
.iter()
.filter_map(JsonValue::as_str)
.map(str::to_string)
.collect::<Vec<_>>()
})
.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<String> {
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<bool> {
let mut current = value;
for key in path {
current = current.get(*key)?;
}
current.as_bool()
}
fn clean_optional(value: &Option<String>) -> Option<String> {
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<String> {
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<T: DeserializeOwned>(value: &str, label: &str) -> Result<T, String> {
serde_json::from_str(value).map_err(|error| format!("{label} 非法: {error}"))
}
fn to_json_string<T: Serialize>(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),
}
}