use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::BTreeMap; #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] pub enum VisualNovelSourceMode { Idea, Document, Blank, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum VisualNovelCharacterRole { Protagonist, Main, Supporting, Antagonist, Background, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum VisualNovelAssetSource { PlatformAsset, Generated, External, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum VisualNovelAudioGenerationKind { BackgroundMusic, SoundEffect, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum VisualNovelSceneAvailability { Opening, Always, PhaseLocked, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum VisualNovelAttributePanelMode { Off, PlatformWhitelist, TemplateConfig, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] pub enum VisualNovelValidationSeverity { Error, Warning, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] pub enum VisualNovelAgentStatus { Collecting, Drafting, Ready, Failed, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] pub enum VisualNovelAgentMessageRole { User, Assistant, System, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum VisualNovelAgentMessageKind { Chat, Summary, ActionResult, Warning, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum VisualNovelAgentActionKind { GenerateDraft, PatchWorld, PatchCharacter, PatchScene, PatchStoryPhase, GenerateSceneImage, GenerateCharacterImage, CompileWorkProfile, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] pub enum VisualNovelAgentPhase { Perception, Reasoning, Drafting, Reflection, Finalizing, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] pub enum VisualNovelRunMode { Test, Play, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] pub enum VisualNovelRunStatus { Active, Completed, Failed, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum VisualNovelRuntimeActionKind { Choice, FreeText, Continue, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] pub enum VisualNovelTransitionKind { Fade, Cut, Flash, None, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] pub enum VisualNovelHistorySource { Player, Assistant, System, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(untagged)] pub enum VisualNovelFlagValue { String(String), Number(f64), Bool(bool), } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct VisualNovelDraftPatch { pub path: String, pub op: String, #[serde(default)] pub value: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct VisualNovelValidationIssue { pub issue_id: String, pub code: String, pub severity: VisualNovelValidationSeverity, pub path: String, pub message: String, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct VisualNovelChoiceDraft { pub choice_id: String, pub text: String, #[serde(default)] pub action_hint: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct VisualNovelCharacterImageAsset { pub asset_id: String, pub image_src: String, #[serde(default)] pub expression: Option, pub source: VisualNovelAssetSource, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct VisualNovelWorldDraft { pub title: String, pub summary: String, pub background: String, pub premise: String, pub literary_style: String, pub player_role: String, pub default_tone: String, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct VisualNovelCharacterDraft { pub character_id: String, pub name: String, #[serde(default)] pub gender: Option, pub role: VisualNovelCharacterRole, pub appearance: String, pub personality: String, pub tone: String, pub background: String, pub relationship_to_player: String, pub image_assets: Vec, #[serde(default)] pub default_expression: Option, pub is_player_visible: bool, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct VisualNovelSceneDraft { pub scene_id: String, pub name: String, pub description: String, #[serde(default)] pub background_image_src: Option, #[serde(default)] pub music_src: Option, #[serde(default)] pub ambient_sound_src: Option, pub availability: VisualNovelSceneAvailability, pub phase_ids: Vec, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct VisualNovelStoryPhaseDraft { pub phase_id: String, pub title: String, pub goal: String, pub summary: String, pub entry_condition: String, pub exit_condition: String, pub scene_ids: Vec, pub character_ids: Vec, pub suggested_choices: Vec, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct VisualNovelOpeningDraft { #[serde(default)] pub scene_id: Option, pub narration: String, #[serde(default)] pub speaker_character_id: Option, #[serde(default)] pub first_dialogue: Option, pub initial_choices: Vec, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct VisualNovelRuntimeConfigDraft { pub text_mode_enabled: bool, pub default_text_mode: bool, pub max_history_entries: u32, pub max_assistant_step_count_per_turn: u32, pub allow_free_text_action: bool, pub allow_history_regeneration: bool, pub attribute_panel_mode: VisualNovelAttributePanelMode, pub save_archive_enabled: bool, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct VisualNovelResultDraft { #[serde(default)] pub profile_id: Option, pub work_title: String, pub work_description: String, pub work_tags: Vec, #[serde(default)] pub cover_image_src: Option, pub source_mode: VisualNovelSourceMode, pub source_asset_ids: Vec, pub world: VisualNovelWorldDraft, pub characters: Vec, pub scenes: Vec, pub story_phases: Vec, pub opening: VisualNovelOpeningDraft, pub runtime_config: VisualNovelRuntimeConfigDraft, pub publish_ready: bool, pub validation_issues: Vec, pub updated_at: String, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct VisualNovelAgentMessage { pub id: String, pub role: VisualNovelAgentMessageRole, pub kind: VisualNovelAgentMessageKind, pub text: String, pub created_at: String, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct VisualNovelAgentPendingAction { pub action_id: String, pub kind: VisualNovelAgentActionKind, pub label: String, #[serde(default)] pub target_id: Option, #[serde(default)] pub payload: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct VisualNovelAgentSessionSnapshot { pub session_id: String, pub owner_user_id: String, pub source_mode: VisualNovelSourceMode, pub status: VisualNovelAgentStatus, pub messages: Vec, #[serde(default)] pub draft: Option, #[serde(default)] pub pending_action: Option, pub created_at: String, pub updated_at: String, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct CreateVisualNovelSessionRequest { pub source_mode: VisualNovelSourceMode, #[serde(default)] pub seed_text: Option, #[serde(default)] pub source_asset_ids: Vec, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct VisualNovelSessionResponse { pub session: VisualNovelAgentSessionSnapshot, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct VisualNovelWorkSummary { pub runtime_kind: String, pub profile_id: String, pub owner_user_id: String, pub title: String, pub description: String, #[serde(default)] pub cover_image_src: Option, pub tags: Vec, pub publish_status: String, pub publish_ready: bool, pub play_count: u32, pub updated_at: String, #[serde(default)] pub published_at: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct VisualNovelWorkDetail { pub work_id: String, pub summary: VisualNovelWorkSummary, #[serde(default)] pub source_session_id: Option, pub author_display_name: String, pub source_asset_ids: Vec, pub draft: VisualNovelResultDraft, pub created_at: String, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct VisualNovelWorksResponse { pub works: Vec, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct VisualNovelWorkResponse { pub work: VisualNovelWorkDetail, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct UpdateVisualNovelWorkRequest { pub draft: VisualNovelResultDraft, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct VisualNovelCompileResponse { pub session: VisualNovelAgentSessionSnapshot, pub work: VisualNovelWorkDetail, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct CreateVisualNovelBackgroundMusicRequest { pub prompt: String, pub title: String, #[serde(default)] pub tags: Option, #[serde(default)] pub model: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct CreateVisualNovelSoundEffectRequest { pub prompt: String, #[serde(default)] pub duration: Option, #[serde(default)] pub seed: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct VisualNovelAudioGenerationTaskResponse { pub kind: VisualNovelAudioGenerationKind, pub task_id: String, pub provider: String, pub status: String, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct PublishVisualNovelGeneratedAudioAssetRequest { pub scene_id: String, #[serde(default)] pub profile_id: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct VisualNovelGeneratedAudioAssetResponse { pub kind: VisualNovelAudioGenerationKind, pub task_id: String, pub provider: String, pub status: String, #[serde(default)] pub asset_object_id: Option, #[serde(default)] pub asset_kind: Option, #[serde(default)] pub audio_src: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct SendVisualNovelMessageRequest { pub client_message_id: String, pub text: String, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct ExecuteVisualNovelAgentActionRequest { #[serde(default)] pub action_id: Option, pub kind: VisualNovelAgentActionKind, #[serde(default)] pub target_id: Option, #[serde(default)] pub payload: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde( tag = "type", rename_all = "snake_case", rename_all_fields = "camelCase" )] pub enum VisualNovelAgentStreamEvent { Start { session_id: String, }, Phase { phase: VisualNovelAgentPhase, }, TextDelta { text: String, }, DraftPatch { patch: VisualNovelDraftPatch, }, ActionRequired { action: VisualNovelAgentPendingAction, }, Complete { session: VisualNovelAgentSessionSnapshot, }, Error { message: String, retryable: bool, }, Done {}, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde( tag = "type", rename_all = "snake_case", rename_all_fields = "camelCase" )] pub enum VisualNovelRuntimeStep { SceneChange { scene_id: String, #[serde(default)] background_image_src: Option, #[serde(default)] music_src: Option, }, Narration { text: String, }, Dialogue { character_id: String, character_name: String, #[serde(default)] expression: Option, text: String, }, Transition { transition_kind: VisualNovelTransitionKind, #[serde(default)] text: Option, }, Choice { choices: Vec, }, Flag { key: String, value: VisualNovelFlagValue, }, Metric { key: String, delta: f64, }, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct VisualNovelHistoryEntry { pub entry_id: String, pub run_id: String, pub turn_index: u32, pub source: VisualNovelHistorySource, #[serde(default)] pub action_text: Option, pub steps: Vec, #[serde(default)] pub snapshot_before_hash: Option, #[serde(default)] pub snapshot_after_hash: Option, pub created_at: String, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct VisualNovelRunSnapshot { pub run_id: String, pub owner_user_id: String, pub profile_id: String, pub mode: VisualNovelRunMode, pub status: VisualNovelRunStatus, #[serde(default)] pub current_scene_id: Option, #[serde(default)] pub current_phase_id: Option, pub visible_character_ids: Vec, pub flags: BTreeMap, pub metrics: BTreeMap, pub history: Vec, pub available_choices: Vec, pub text_mode_enabled: bool, pub created_at: String, pub updated_at: String, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct VisualNovelRuntimeActionRequest { pub action_kind: VisualNovelRuntimeActionKind, #[serde(default)] pub choice_id: Option, #[serde(default)] pub text: Option, pub client_event_id: String, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct VisualNovelStartRunRequest { pub profile_id: String, pub mode: VisualNovelRunMode, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct VisualNovelRunResponse { pub run: VisualNovelRunSnapshot, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct VisualNovelHistoryResponse { pub history: Vec, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct VisualNovelRegenerateRequest { pub history_entry_id: String, pub client_event_id: String, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct VisualNovelSaveArchiveState { pub runtime_kind: String, pub profile_id: String, pub run_id: String, #[serde(default)] pub current_scene_id: Option, #[serde(default)] pub current_phase_id: Option, pub history_cursor: u32, #[serde(default)] pub snapshot_hash: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde( tag = "type", rename_all = "snake_case", rename_all_fields = "camelCase" )] pub enum VisualNovelRuntimeStreamEvent { Start { run_id: String }, RawText { text: String }, Step { step: VisualNovelRuntimeStep }, Snapshot { run: VisualNovelRunSnapshot }, Complete { run: VisualNovelRunSnapshot }, Error { message: String, retryable: bool }, Done {}, } #[cfg(test)] mod tests { use super::*; use serde_json::json; #[test] fn result_draft_and_runtime_step_use_contract_case() { let draft = VisualNovelResultDraft { profile_id: Some("vn-profile-1".to_string()), work_title: "雨夜书店".to_string(), work_description: "一段视觉小说测试底稿".to_string(), work_tags: vec!["悬疑".to_string()], cover_image_src: None, source_mode: VisualNovelSourceMode::Idea, source_asset_ids: Vec::new(), world: VisualNovelWorldDraft { title: "雨夜书店".to_string(), summary: "主角在雨夜进入一间只在午夜出现的书店。".to_string(), background: "城市边缘的旧街区。".to_string(), premise: "找回遗失的名字。".to_string(), literary_style: "细腻、轻悬疑".to_string(), player_role: "误入书店的读者".to_string(), default_tone: "克制而温柔".to_string(), }, characters: Vec::new(), scenes: Vec::new(), story_phases: Vec::new(), opening: VisualNovelOpeningDraft { scene_id: None, narration: "雨声落下。".to_string(), speaker_character_id: None, first_dialogue: None, initial_choices: Vec::new(), }, runtime_config: VisualNovelRuntimeConfigDraft { text_mode_enabled: true, default_text_mode: false, max_history_entries: 80, max_assistant_step_count_per_turn: 8, allow_free_text_action: true, allow_history_regeneration: true, attribute_panel_mode: VisualNovelAttributePanelMode::Off, save_archive_enabled: true, }, publish_ready: false, validation_issues: Vec::new(), updated_at: "2026-05-05T00:00:00Z".to_string(), }; let payload = serde_json::to_value(draft).expect("draft should serialize"); assert_eq!(payload["profileId"], json!("vn-profile-1")); assert_eq!(payload["sourceMode"], json!("idea")); assert_eq!(payload["runtimeConfig"]["attributePanelMode"], json!("off")); let step = VisualNovelRuntimeStep::SceneChange { scene_id: "scene-1".to_string(), background_image_src: None, music_src: None, }; let step_payload = serde_json::to_value(step).expect("step should serialize"); assert_eq!(step_payload["type"], json!("scene_change")); assert_eq!(step_payload["sceneId"], json!("scene-1")); } #[test] fn audio_generation_contracts_use_camel_case_fields() { let request = CreateVisualNovelSoundEffectRequest { prompt: "雨声".to_string(), duration: Some(5), seed: Some(12), }; let payload = serde_json::to_value(request).expect("request should serialize"); assert_eq!(payload["duration"], json!(5)); assert_eq!(payload["seed"], json!(12)); let response = VisualNovelGeneratedAudioAssetResponse { kind: VisualNovelAudioGenerationKind::SoundEffect, task_id: "task-1".to_string(), provider: "vector-engine-vidu".to_string(), status: "completed".to_string(), asset_object_id: Some("assetobj_1".to_string()), asset_kind: Some("visual_novel_ambient_sound".to_string()), audio_src: Some("/generated-custom-world-scenes/a.wav".to_string()), }; let payload = serde_json::to_value(response).expect("response should serialize"); assert_eq!(payload["kind"], json!("sound_effect")); assert_eq!(payload["taskId"], json!("task-1")); assert_eq!(payload["assetObjectId"], json!("assetobj_1")); assert_eq!( payload["audioSrc"], json!("/generated-custom-world-scenes/a.wav") ); } #[test] fn runtime_stream_event_uses_tagged_envelope() { let event = VisualNovelRuntimeStreamEvent::Step { step: VisualNovelRuntimeStep::Narration { text: "门铃响了。".to_string(), }, }; let payload = serde_json::to_value(event).expect("event should serialize"); assert_eq!(payload["type"], json!("step")); assert_eq!(payload["step"]["type"], json!("narration")); assert_eq!(payload["step"]["text"], json!("门铃响了。")); } }