Files
Genarrative/server-rs/crates/spacetime-module/src/visual_novel.rs
2026-05-17 01:19:12 +08:00

1978 lines
66 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use crate::*;
use serde::Serialize;
use serde::de::DeserializeOwned;
use spacetimedb::AnonymousViewContext;
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,
}
/// 视觉小说公开广场列表投影。
///
/// 该 view 只暴露已发布作品卡片需要的公开字段HTTP gallery 订阅后
/// 从本地 cache 读取,避免每个列表请求调用 `list_visual_novel_works` procedure。
#[spacetimedb::view(accessor = visual_novel_gallery_view, public)]
pub fn visual_novel_gallery_view(ctx: &AnonymousViewContext) -> Vec<VisualNovelGalleryViewRow> {
let mut items = ctx
.db
.visual_novel_work_profile()
.by_visual_novel_work_publication_status()
.filter(VISUAL_NOVEL_PUBLICATION_PUBLISHED)
.filter_map(|row| match build_gallery_view_row(&row) {
Ok(item) => Some(item),
Err(error) => {
log::warn!(
"视觉小说公开广场 view 跳过损坏的作品投影 profile_id={}: {}",
row.profile_id,
error
);
None
}
})
.collect::<Vec<_>>();
items.sort_by(|left, right| {
right
.updated_at_micros
.cmp(&left.updated_at_micros)
.then_with(|| left.profile_id.cmp(&right.profile_id))
});
items
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct VisualNovelGalleryViewRow {
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 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, 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_gallery_view_row(
row: &VisualNovelWorkProfileRow,
) -> Result<VisualNovelGalleryViewRow, String> {
Ok(VisualNovelGalleryViewRow {
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)?,
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),
}
}