1900 lines
64 KiB
Rust
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(¤t)
|
|
};
|
|
let snapshot = build_work_snapshot(&next)?;
|
|
replace_work(ctx, ¤t, next);
|
|
Ok(snapshot)
|
|
}
|
|
|
|
fn publish_visual_novel_work_tx(
|
|
ctx: &ReducerContext,
|
|
input: VisualNovelWorkPublishInput,
|
|
) -> Result<VisualNovelWorkSnapshot, String> {
|
|
let current = find_owned_work(ctx, &input.profile_id, &input.owner_user_id)?;
|
|
validate_publishable_work(¤t)?;
|
|
let published_at = Timestamp::from_micros_since_unix_epoch(input.published_at_micros);
|
|
let next = VisualNovelWorkProfileRow {
|
|
publication_status: VISUAL_NOVEL_PUBLICATION_PUBLISHED.to_string(),
|
|
updated_at: published_at,
|
|
published_at: Some(published_at),
|
|
..clone_work(¤t)
|
|
};
|
|
if !next.source_session_id.is_empty() {
|
|
if let Some(session) = ctx
|
|
.db
|
|
.visual_novel_agent_session()
|
|
.session_id()
|
|
.find(&next.source_session_id)
|
|
.filter(|row| row.owner_user_id == input.owner_user_id)
|
|
{
|
|
replace_session(
|
|
ctx,
|
|
&session,
|
|
VisualNovelAgentSessionRow {
|
|
progress_percent: 100,
|
|
status: VISUAL_NOVEL_AGENT_STATUS_READY.to_string(),
|
|
published_profile_id: next.profile_id.clone(),
|
|
updated_at: published_at,
|
|
..clone_session(&session)
|
|
},
|
|
);
|
|
}
|
|
}
|
|
let snapshot = build_work_snapshot(&next)?;
|
|
replace_work(ctx, ¤t, next);
|
|
Ok(snapshot)
|
|
}
|
|
|
|
fn list_visual_novel_works_tx(
|
|
ctx: &ReducerContext,
|
|
input: VisualNovelWorksListInput,
|
|
) -> Result<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(¤t)
|
|
};
|
|
let snapshot = build_run_snapshot(ctx, &next)?;
|
|
replace_run(ctx, ¤t, next);
|
|
Ok(snapshot)
|
|
}
|
|
|
|
fn append_visual_novel_runtime_history_entry_tx(
|
|
ctx: &ReducerContext,
|
|
input: VisualNovelRuntimeHistoryAppendInput,
|
|
) -> Result<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(¤t.session_id);
|
|
ctx.db.visual_novel_agent_session().insert(next);
|
|
}
|
|
|
|
fn replace_work(
|
|
ctx: &ReducerContext,
|
|
current: &VisualNovelWorkProfileRow,
|
|
next: VisualNovelWorkProfileRow,
|
|
) {
|
|
ctx.db
|
|
.visual_novel_work_profile()
|
|
.profile_id()
|
|
.delete(¤t.profile_id);
|
|
ctx.db.visual_novel_work_profile().insert(next);
|
|
}
|
|
|
|
fn replace_run(
|
|
ctx: &ReducerContext,
|
|
current: &VisualNovelRuntimeRunRow,
|
|
next: VisualNovelRuntimeRunRow,
|
|
) {
|
|
ctx.db
|
|
.visual_novel_runtime_run()
|
|
.run_id()
|
|
.delete(¤t.run_id);
|
|
ctx.db.visual_novel_runtime_run().insert(next);
|
|
}
|
|
|
|
fn delete_run_children(ctx: &ReducerContext, run_id: &str, owner_user_id: &str) {
|
|
for history in ctx
|
|
.db
|
|
.visual_novel_runtime_history_entry()
|
|
.iter()
|
|
.filter(|row| row.run_id == run_id && row.owner_user_id == owner_user_id)
|
|
.collect::<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),
|
|
}
|
|
}
|