diff --git a/ROADMAP.md b/ROADMAP.md index e2fbf33..04fc96e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -9,30 +9,37 @@ - Character Management (multiple characters) - Character Avatars with upload and zoom - Expanded Character Editor (all v2/v3 fields) +- Prompt Presets System (built-in and custom presets with instruction blocks) +- Editable Built-in Presets with Restore to Default +- World Info/Lorebook System (keyword detection, priority, insertion) +- Author's Note (configurable depth and positioning) +- User Personas (identity management with chat/character locking) +- Regex Scripts (global and character-scoped text transformations) +- Chat History Import/Export (JSON format) -### 🎯 Current Focus: UI/UX Improvements -**Decision:** Before adding complex roleplay features, we're focusing on polishing the existing UI/UX to establish a solid foundation. This includes better visual design, improved workflows, and enhanced user experience. +### 🎯 Current Focus: Token Counter & Context Management +**Next Up:** Implementing token counter with real-time display and per-section breakdown to provide visibility into context usage. This is a critical feature for debugging prompt issues and optimizing context allocation. -See "Phase 7: Polish & UX" section for details on UI improvements being prioritized. +**Recent Completion:** Prompt Presets System with editable built-in presets, instruction block management, and restore-to-default functionality. ## Phase 1: Core Roleplay Infrastructure (High Priority) **Goal: Enable basic roleplay-focused prompt engineering** -### 1. World Info/Lorebook System -- [ ] Create UI for managing lorebook entries (keyword, content, priority) -- [ ] Implement keyword detection in recent messages -- [ ] Add context injection before message generation -- [ ] Support recursive entry activation -- [ ] Per-character lorebook assignment -- [ ] Import/export lorebook files +### 1. World Info/Lorebook System ✅ +- [x] Create UI for managing lorebook entries (keyword, content, priority) +- [x] Implement keyword detection in recent messages +- [x] Add context injection before message generation +- [x] Support recursive entry activation +- [x] Per-character lorebook assignment +- [x] Import/export lorebook files **Why Important:** World Info is the foundation of consistent roleplay. It allows dynamic context injection based on what's currently relevant in the conversation, saving tokens while maintaining world consistency. -### 2. Author's Note -- [ ] Add configurable Author's Note field (inserted at depth 1-5) -- [ ] Position control (after system, before/after examples, etc.) -- [ ] Per-character Author's Note support -- [ ] Template variables in Author's Note +### 2. Author's Note ✅ +- [x] Add configurable Author's Note field (inserted at depth 1-5) +- [x] Position control (after system, before/after examples, etc.) +- [x] Per-character Author's Note support +- [x] Template variables in Author's Note **Why Important:** Author's Note is considered better than system prompts for roleplay because it appears closer to the actual conversation, reducing AI tendency to ignore or forget instructions. @@ -47,12 +54,12 @@ See "Phase 7: Polish & UX" section for details on UI improvements being prioriti ## Phase 2: Enhanced Character Features (High Priority) **Goal: Better character representation and user identity** -### 1. User Personas -- [ ] Create persona management UI (name, description, avatar) -- [ ] Chat-level persona locking -- [ ] Character-level persona locking -- [ ] Default persona setting -- [ ] Quick persona switching +### 1. User Personas ✅ +- [x] Create persona management UI (name, description, avatar) +- [x] Chat-level persona locking +- [x] Character-level persona locking +- [x] Default persona setting +- [x] Quick persona switching **Why Important:** Allows users to have multiple identities for different roleplay scenarios without manually changing their name and description each time. @@ -190,13 +197,13 @@ See "Phase 7: Polish & UX" section for details on UI improvements being prioriti **Why Important:** Makes prompts and messages dynamic and reusable across different scenarios. -### 3. Regex Scripts -- [ ] Global and character-scoped scripts -- [ ] Text transformation on messages -- [ ] Auto-markdown formatting -- [ ] Import/export regex presets -- [ ] Regex testing interface -- [ ] Script priority/ordering +### 3. Regex Scripts ✅ +- [x] Global and character-scoped scripts +- [x] Text transformation on messages +- [x] Auto-markdown formatting +- [x] Import/export regex presets +- [x] Regex testing interface +- [x] Script priority/ordering **Why Important:** Allows automatic text formatting, correction, and enhancement without manual intervention. @@ -224,8 +231,8 @@ See "Phase 7: Polish & UX" section for details on UI improvements being prioriti ### 2. Export/Import Improvements - [ ] Export chats as markdown - [ ] Export chats as formatted text -- [ ] Export chats as JSON with metadata -- [ ] Import chats from other formats +- [x] Export chats as JSON with metadata +- [x] Import chats from other formats - [ ] Bulk character import - [ ] Character pack support (multiple characters + lorebooks) @@ -344,4 +351,4 @@ This roadmap is based on research into SillyTavern's features and best practices --- -Last updated: 2025-10-14 +Last updated: 2025-10-16 diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f3945f0..8e7b15d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -7,6 +7,8 @@ use futures::StreamExt; use tauri::Emitter; use base64::Engine; use regex::Regex; +use std::sync::{Mutex, OnceLock}; +use std::collections::HashMap; #[derive(Debug, Clone, Serialize, Deserialize)] struct ApiConfig { @@ -257,7 +259,7 @@ struct WorldInfoEntry { use_regex: bool, // Use regex matching instead of literal string matching } -// Roleplay Settings (Author's Note, Persona, World Info) +// Roleplay Settings (Author's Note, Persona, World Info, Prompt Presets) #[derive(Debug, Clone, Serialize, Deserialize)] struct RoleplaySettings { #[serde(default)] @@ -276,6 +278,10 @@ struct RoleplaySettings { world_info: Vec, #[serde(default = "default_scan_depth")] scan_depth: usize, // Scan last N messages for keywords (default 20) + #[serde(default = "default_recursion_depth")] + recursion_depth: usize, // Max depth for recursive World Info activation (default 3) + #[serde(default)] + active_preset_id: Option, // Selected prompt preset for this character } fn default_authors_note_depth() -> usize { @@ -286,6 +292,10 @@ fn default_scan_depth() -> usize { 20 } +fn default_recursion_depth() -> usize { + 3 +} + impl Default for RoleplaySettings { fn default() -> Self { Self { @@ -297,10 +307,176 @@ impl Default for RoleplaySettings { persona_enabled: false, world_info: Vec::new(), scan_depth: default_scan_depth(), + recursion_depth: default_recursion_depth(), + active_preset_id: None, // No preset selected by default } } } +// Prompt Preset System (Simplified MVP) + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct InstructionBlock { + id: String, + name: String, + content: String, + enabled: bool, + #[serde(default)] + order: i32, // Lower = earlier in context +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct FormatHints { + #[serde(default = "default_wi_format")] + wi_format: String, // e.g., "{0}" or "[WorldInfo: {0}]" + #[serde(default = "default_scenario_format")] + scenario_format: String, // e.g., "{{scenario}}" + #[serde(default = "default_personality_format")] + personality_format: String, // e.g., "[{{char}}'s personality: {{personality}}]" +} + +fn default_wi_format() -> String { + "{0}".to_string() +} + +fn default_scenario_format() -> String { + "{{scenario}}".to_string() +} + +fn default_personality_format() -> String { + "[{{char}}'s personality: {{personality}}]".to_string() +} + +impl Default for FormatHints { + fn default() -> Self { + Self { + wi_format: default_wi_format(), + scenario_format: default_scenario_format(), + personality_format: default_personality_format(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct PromptPreset { + id: String, + name: String, + description: String, + #[serde(default)] + system_additions: String, // Added to character system_prompt + #[serde(default)] + authors_note_default: String, // Default Author's Note content + #[serde(default)] + instructions: Vec, + #[serde(default)] + format_hints: FormatHints, +} + +// Simplified info for listing presets +#[derive(Debug, Clone, Serialize, Deserialize)] +struct PresetInfo { + id: String, + name: String, + description: String, +} + +// Create built-in presets + +fn create_default_preset() -> PromptPreset { + PromptPreset { + id: "default".to_string(), + name: "Default".to_string(), + description: "Minimal instructions for general use".to_string(), + system_additions: String::new(), + authors_note_default: String::new(), + instructions: vec![], + format_hints: FormatHints::default(), + } +} + +fn create_roleplay_preset() -> PromptPreset { + PromptPreset { + id: "roleplay".to_string(), + name: "Roleplay".to_string(), + description: "Optimized for immersive character roleplay".to_string(), + system_additions: "\n\n[Roleplay Guidelines:\n- Stay in character at all times\n- Use vivid, descriptive language\n- Show, don't tell - express emotions through actions and dialogue\n- Maintain consistent characterization\n- Respond dynamically to {{user}}'s actions]".to_string(), + authors_note_default: "[Write detailed, immersive responses focusing on {{char}}'s perspective. Include their thoughts, feelings, and physical reactions.]".to_string(), + instructions: vec![ + InstructionBlock { + id: Uuid::new_v4().to_string(), + name: "Immersion".to_string(), + content: "[Focus on sensory details and environmental descriptions to create immersion]".to_string(), + enabled: true, + order: 1, + }, + InstructionBlock { + id: Uuid::new_v4().to_string(), + name: "Character Voice".to_string(), + content: "[Maintain {{char}}'s unique voice, speech patterns, and personality traits]".to_string(), + enabled: true, + order: 2, + }, + ], + format_hints: FormatHints::default(), + } +} + +fn create_creative_writing_preset() -> PromptPreset { + PromptPreset { + id: "creative-writing".to_string(), + name: "Creative Writing".to_string(), + description: "For collaborative storytelling and narrative co-writing".to_string(), + system_additions: "\n\n[Creative Writing Mode:\n- Focus on narrative flow and story progression\n- Use varied sentence structure and literary devices\n- Balance description, dialogue, and action\n- Build tension and pacing appropriately\n- Maintain consistent tone and style]".to_string(), + authors_note_default: "[Continue the narrative with engaging prose. Advance the plot while maintaining character development.]".to_string(), + instructions: vec![ + InstructionBlock { + id: Uuid::new_v4().to_string(), + name: "Narrative Style".to_string(), + content: "[Write in a literary style with attention to prose quality and storytelling craft]".to_string(), + enabled: true, + order: 1, + }, + InstructionBlock { + id: Uuid::new_v4().to_string(), + name: "Story Structure".to_string(), + content: "[Consider story beats, conflict, and resolution. Build towards meaningful moments]".to_string(), + enabled: true, + order: 2, + }, + ], + format_hints: FormatHints::default(), + } +} + +fn create_assistant_preset() -> PromptPreset { + PromptPreset { + id: "assistant".to_string(), + name: "Assistant".to_string(), + description: "Traditional AI assistant behavior for practical tasks".to_string(), + system_additions: "\n\n[Assistant Guidelines:\n- Provide clear, helpful, and accurate information\n- Be concise but thorough\n- Use formatting (lists, bold, etc.) when it improves clarity\n- Ask clarifying questions when needed\n- Maintain a professional yet friendly tone]".to_string(), + authors_note_default: "[Focus on being helpful, informative, and user-friendly]".to_string(), + instructions: vec![ + InstructionBlock { + id: Uuid::new_v4().to_string(), + name: "Clarity".to_string(), + content: "[Organize information logically and use examples when helpful]".to_string(), + enabled: true, + order: 1, + }, + ], + format_hints: FormatHints::default(), + } +} + +fn get_builtin_presets() -> Vec { + vec![ + create_default_preset(), + create_roleplay_preset(), + create_creative_writing_preset(), + create_assistant_preset(), + ] +} + #[derive(Debug, Clone, Serialize, Deserialize)] struct ChatHistory { messages: Vec, @@ -414,6 +590,129 @@ fn save_roleplay_settings(character_id: &str, settings: &RoleplaySettings) -> Re Ok(()) } +// Prompt Preset Path and Loading Functions + +// Preset cache to avoid disk I/O on every message +static PRESET_CACHE: OnceLock>> = OnceLock::new(); + +fn get_preset_cache() -> &'static Mutex> { + PRESET_CACHE.get_or_init(|| Mutex::new(HashMap::new())) +} + +#[allow(dead_code)] +fn clear_preset_cache() { + if let Ok(mut cache) = get_preset_cache().lock() { + cache.clear(); + } +} + +fn get_presets_dir() -> PathBuf { + let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()); + PathBuf::from(home).join(".config/claudia/presets") +} + +fn get_preset_path(preset_id: &str) -> PathBuf { + get_presets_dir().join(format!("{}.json", preset_id)) +} + +fn load_preset(preset_id: &str) -> Option { + // Check cache first + if let Ok(cache) = get_preset_cache().lock() { + if let Some(preset) = cache.get(preset_id) { + return Some(preset.clone()); + } + } + + // First check if it's a built-in preset + let builtin_presets = get_builtin_presets(); + if let Some(preset) = builtin_presets.iter().find(|p| p.id == preset_id) { + // Cache built-in preset + if let Ok(mut cache) = get_preset_cache().lock() { + cache.insert(preset_id.to_string(), preset.clone()); + } + return Some(preset.clone()); + } + + // Then check user presets directory + let path = get_preset_path(preset_id); + match fs::read_to_string(&path) { + Ok(contents) => { + match serde_json::from_str::(&contents) { + Ok(preset) => { + // Cache successfully loaded preset + if let Ok(mut cache) = get_preset_cache().lock() { + cache.insert(preset_id.to_string(), preset.clone()); + } + Some(preset) + } + Err(e) => { + eprintln!("Failed to parse preset '{}': {}", preset_id, e); + None + } + } + } + Err(e) => { + eprintln!("Failed to load preset '{}' from {:?}: {}", preset_id, path, e); + None + } + } +} + +fn save_preset(preset: &PromptPreset) -> Result<(), String> { + let dir = get_presets_dir(); + fs::create_dir_all(&dir).map_err(|e| e.to_string())?; + + let path = get_preset_path(&preset.id); + let contents = serde_json::to_string_pretty(preset).map_err(|e| e.to_string())?; + fs::write(path, contents).map_err(|e| e.to_string())?; + + // Invalidate cache for this preset + if let Ok(mut cache) = get_preset_cache().lock() { + cache.remove(&preset.id); + } + + Ok(()) +} + +fn list_preset_infos() -> Vec { + let mut presets = Vec::new(); + + // Add built-in presets + for preset in get_builtin_presets() { + presets.push(PresetInfo { + id: preset.id, + name: preset.name, + description: preset.description, + }); + } + + // Add user presets from directory + let dir = get_presets_dir(); + if dir.exists() { + if let Ok(entries) = fs::read_dir(dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("json") { + if let Ok(contents) = fs::read_to_string(&path) { + if let Ok(preset) = serde_json::from_str::(&contents) { + // Skip if it's a duplicate of a built-in preset + if !presets.iter().any(|p| p.id == preset.id) { + presets.push(PresetInfo { + id: preset.id, + name: preset.name, + description: preset.description, + }); + } + } + } + } + } + } + } + + presets +} + // PNG Character Card Utilities // Manual PNG chunk parser - more reliable than relying on png crate's text chunk exposure @@ -865,62 +1164,101 @@ fn get_api_config() -> Result { // Roleplay Context Injection Logic -// Scan messages for World Info keywords and return activated entries -fn scan_for_world_info(messages: &[Message], world_info: &[WorldInfoEntry], scan_depth: usize) -> Vec { - let mut activated_entries = Vec::new(); +// Helper function to check if text contains any keyword from an entry +fn text_matches_entry(text: &str, entry: &WorldInfoEntry) -> bool { + for keyword in &entry.keys { + let matches = if entry.use_regex { + // Use regex matching + if let Ok(re) = Regex::new(keyword) { + re.is_match(text) + } else { + // Invalid regex - fall back to literal matching + eprintln!("Invalid regex pattern '{}' for entry {}: falling back to literal match", keyword, entry.id); + if entry.case_sensitive { + text.contains(keyword) + } else { + text.to_lowercase().contains(&keyword.to_lowercase()) + } + } + } else { + // Use literal string matching + if entry.case_sensitive { + text.contains(keyword) + } else { + text.to_lowercase().contains(&keyword.to_lowercase()) + } + }; - // Scan the last N messages (scan_depth) + if matches { + return true; + } + } + false +} + +// Scan messages for World Info keywords and return activated entries (with recursive activation) +fn scan_for_world_info(messages: &[Message], world_info: &[WorldInfoEntry], scan_depth: usize, recursion_depth: usize) -> Vec { + scan_for_world_info_recursive(messages, world_info, scan_depth, recursion_depth) +} + +// Recursive World Info scanning with configurable recursion depth +fn scan_for_world_info_recursive( + messages: &[Message], + world_info: &[WorldInfoEntry], + scan_depth: usize, + recursion_depth: usize, +) -> Vec { + use std::collections::HashSet; + + let mut activated_ids: HashSet = HashSet::new(); + let mut activated_entries: Vec = Vec::new(); + + // Collect text to scan from messages let messages_to_scan: Vec<&Message> = messages.iter() .rev() .take(scan_depth) .collect(); - for entry in world_info { - if !entry.enabled { - continue; - } + let mut scan_texts: Vec = messages_to_scan + .iter() + .map(|msg| msg.get_content().to_string()) + .collect(); - // Check if any keyword matches in the scanned messages - let mut activated = false; - for message in &messages_to_scan { - let content = message.get_content(); + // Iteratively scan for keywords with depth limit + for current_depth in 0..recursion_depth { + let mut newly_activated = Vec::new(); - for keyword in &entry.keys { - let matches = if entry.use_regex { - // Use regex matching - if let Ok(re) = Regex::new(keyword) { - re.is_match(content) - } else { - // Invalid regex - fall back to literal matching - eprintln!("Invalid regex pattern '{}' for entry {}: falling back to literal match", keyword, entry.id); - if entry.case_sensitive { - content.contains(keyword) - } else { - content.to_lowercase().contains(&keyword.to_lowercase()) - } - } - } else { - // Use literal string matching - if entry.case_sensitive { - content.contains(keyword) - } else { - content.to_lowercase().contains(&keyword.to_lowercase()) - } - }; + // Check each enabled entry against current scan texts + for entry in world_info { + if !entry.enabled || activated_ids.contains(&entry.id) { + continue; + } - if matches { - activated = true; + // Check if this entry matches any of the scan texts + for text in &scan_texts { + if text_matches_entry(text, entry) { + activated_ids.insert(entry.id.clone()); + newly_activated.push(entry.clone()); break; } } - - if activated { - break; - } } - if activated { - activated_entries.push(entry.clone()); + // If no new entries were activated, stop recursion + if newly_activated.is_empty() { + break; + } + + // Add newly activated entries to results + activated_entries.extend(newly_activated.clone()); + + // For next iteration, scan the content of newly activated entries + // (only if we haven't reached max depth) + if current_depth + 1 < recursion_depth { + scan_texts = newly_activated + .iter() + .map(|entry| entry.content.clone()) + .collect(); } } @@ -930,40 +1268,105 @@ fn scan_for_world_info(messages: &[Message], world_info: &[WorldInfoEntry], scan activated_entries } +// Replace template variables in text +fn replace_template_variables( + text: &str, + character: &Character, + settings: &RoleplaySettings, +) -> String { + use chrono::Local; + + let mut result = text.to_string(); + + // {{char}} - Character name + result = result.replace("{{char}}", &character.name); + + // {{user}} - User name (from persona if enabled, otherwise "User") + let user_name = if settings.persona_enabled { + settings.persona_name.as_deref().unwrap_or("User") + } else { + "User" + }; + result = result.replace("{{user}}", user_name); + + // {{date}} - Current date (YYYY-MM-DD format) + let now = Local::now(); + let date_str = now.format("%Y-%m-%d").to_string(); + result = result.replace("{{date}}", &date_str); + + // {{time}} - Current time (HH:MM format) + let time_str = now.format("%H:%M").to_string(); + result = result.replace("{{time}}", &time_str); + + result +} + // Build injected context from roleplay settings fn build_roleplay_context( - _character: &Character, + character: &Character, messages: &[Message], settings: &RoleplaySettings, ) -> (String, Option, usize) { let mut system_additions = String::new(); let mut authors_note_content = None; - // 1. Add Persona to system prompt - if settings.persona_enabled { - if let Some(name) = &settings.persona_name { - if let Some(desc) = &settings.persona_description { - system_additions.push_str(&format!("\n\n[{{{{user}}}}'s Persona: {} - {}]", name, desc)); + // 0. Apply Prompt Preset instructions if one is selected + if let Some(preset_id) = &settings.active_preset_id { + if let Some(preset) = load_preset(preset_id) { + // Add preset system additions (with template variables replaced) + if !preset.system_additions.is_empty() { + let processed_additions = replace_template_variables(&preset.system_additions, character, settings); + system_additions.push_str(&processed_additions); + } + + // Add enabled instruction blocks (sorted by order, with template variables replaced) + let mut enabled_instructions: Vec<_> = preset.instructions.iter() + .filter(|i| i.enabled) + .collect(); + enabled_instructions.sort_by_key(|i| i.order); + + for instruction in enabled_instructions { + let processed_content = replace_template_variables(&instruction.content, character, settings); + system_additions.push_str(&format!("\n{}", processed_content)); + } + + // Set default Author's Note from preset if user hasn't set one + if !settings.authors_note_enabled && !preset.authors_note_default.is_empty() { + let processed_note = replace_template_variables(&preset.authors_note_default, character, settings); + authors_note_content = Some(processed_note); } } } - // 2. Scan for World Info and add to system prompt - let activated_entries = scan_for_world_info(messages, &settings.world_info, settings.scan_depth); + // 1. Add Persona to system prompt (with template variables replaced) + if settings.persona_enabled { + if let Some(name) = &settings.persona_name { + if let Some(desc) = &settings.persona_description { + let processed_desc = replace_template_variables(desc, character, settings); + system_additions.push_str(&format!("\n\n[{}'s Persona: {}]", name, processed_desc)); + } + } + } + + // 2. Scan for World Info and add to system prompt (with template variables replaced) + let activated_entries = scan_for_world_info(messages, &settings.world_info, settings.scan_depth, settings.recursion_depth); if !activated_entries.is_empty() { system_additions.push_str("\n\n[Relevant World Information:"); for entry in activated_entries { - system_additions.push_str(&format!("\n- {}", entry.content)); + let processed_content = replace_template_variables(&entry.content, character, settings); + system_additions.push_str(&format!("\n- {}", processed_content)); } - system_additions.push_str("]"); + system_additions.push_str("\n]"); } - // 3. Store Author's Note for later injection + // 3. Store Author's Note for later injection (with template variables replaced) + // User's explicit Author's Note overrides preset default if settings.authors_note_enabled { if let Some(note) = &settings.authors_note { if !note.is_empty() { - authors_note_content = Some(note.clone()); + let processed_note = replace_template_variables(note, character, settings); + authors_note_content = Some(processed_note); } } } @@ -971,6 +1374,47 @@ fn build_roleplay_context( (system_additions, authors_note_content, settings.authors_note_depth) } +// Helper function to build API messages array with all context injection +fn build_api_messages( + character: &Character, + history: &ChatHistory, + roleplay_settings: &RoleplaySettings, +) -> Vec { + // Load roleplay settings and build context + let (system_additions, authors_note, note_depth) = build_roleplay_context(character, &history.messages, roleplay_settings); + + // Build messages with system prompt first - use simple Message for API (with template variables replaced) + let processed_system_prompt = replace_template_variables(&character.system_prompt, character, roleplay_settings); + let enhanced_system_prompt = format!("{}{}", processed_system_prompt, system_additions); + let mut api_messages = vec![Message::new_user(enhanced_system_prompt)]; + api_messages[0].role = "system".to_string(); + + // Add history messages with current swipe content + for msg in &history.messages { + let mut api_msg = Message::new_user(msg.get_content().to_string()); + api_msg.role = msg.role.clone(); + api_messages.push(api_msg); + } + + // Insert Author's Note before last N messages if it exists (configurable depth) + // FIX: If conversation is too short, insert after system message instead of skipping + if let Some(note) = authors_note { + let insert_pos = if api_messages.len() > (note_depth + 1) { + // Normal case: insert before last N messages + api_messages.len().saturating_sub(note_depth) + } else { + // Edge case: conversation too short, insert after system message + 1 + }; + + let mut note_msg = Message::new_user(format!("[Author's Note: {}]", note)); + note_msg.role = "system".to_string(); + api_messages.insert(insert_pos, note_msg); + } + + api_messages +} + #[tauri::command] async fn chat(message: String) -> Result { let config = load_config().ok_or_else(|| "API not configured".to_string())?; @@ -988,31 +1432,9 @@ async fn chat(message: String) -> Result { format!("{}/v1/chat/completions", base) }; - // Load roleplay settings and build context + // Build API messages with all roleplay context let roleplay_settings = load_roleplay_settings(&character.id); - let (system_additions, authors_note, note_depth) = build_roleplay_context(&character, &history.messages, &roleplay_settings); - - // Build messages with system prompt first - use simple Message for API - let enhanced_system_prompt = format!("{}{}", character.system_prompt, system_additions); - let mut api_messages = vec![Message::new_user(enhanced_system_prompt)]; - api_messages[0].role = "system".to_string(); - - // Add history messages with current swipe content - for msg in &history.messages { - let mut api_msg = Message::new_user(msg.get_content().to_string()); - api_msg.role = msg.role.clone(); - api_messages.push(api_msg); - } - - // Insert Author's Note before last N messages if it exists (configurable depth) - if let Some(note) = authors_note { - if api_messages.len() > (note_depth + 1) { // system + at least note_depth messages - let insert_pos = api_messages.len().saturating_sub(note_depth); - let mut note_msg = Message::new_user(format!("[Author's Note: {}]", note)); - note_msg.role = "system".to_string(); - api_messages.insert(insert_pos, note_msg); - } - } + let api_messages = build_api_messages(&character, &history, &roleplay_settings); let request = ChatRequest { model: config.model.clone(), @@ -1070,31 +1492,9 @@ async fn chat_stream(app_handle: tauri::AppHandle, message: String) -> Result (note_depth + 1) { // system + at least note_depth messages - let insert_pos = api_messages.len().saturating_sub(note_depth); - let mut note_msg = Message::new_user(format!("[Author's Note: {}]", note)); - note_msg.role = "system".to_string(); - api_messages.insert(insert_pos, note_msg); - } - } + let api_messages = build_api_messages(&character, &history, &roleplay_settings); let request = StreamChatRequest { model: config.model.clone(), @@ -1249,8 +1649,9 @@ async fn generate_response_only() -> Result { let roleplay_settings = load_roleplay_settings(&character.id); let (system_additions, authors_note, note_depth) = build_roleplay_context(&character, &history.messages, &roleplay_settings); - // Build messages with enhanced system prompt first - let enhanced_system_prompt = format!("{}{}", character.system_prompt, system_additions); + // Build messages with enhanced system prompt first (with template variables replaced) + let processed_system_prompt = replace_template_variables(&character.system_prompt, &character, &roleplay_settings); + let enhanced_system_prompt = format!("{}{}", processed_system_prompt, system_additions); let mut api_messages = vec![Message::new_user(enhanced_system_prompt)]; api_messages[0].role = "system".to_string(); @@ -1322,8 +1723,9 @@ async fn generate_response_stream(app_handle: tauri::AppHandle) -> Result Result<(), String> { + // Validate recursion depth (should be between 0 and 10) + if depth > 10 { + return Err("Recursion depth must be between 0 and 10".to_string()); + } + + let mut settings = load_roleplay_settings(&character_id); + settings.recursion_depth = depth; + save_roleplay_settings(&character_id, &settings) +} + +// Prompt Preset Commands + +#[tauri::command] +fn get_presets() -> Result, String> { + Ok(list_preset_infos()) +} + +#[tauri::command] +fn get_preset(preset_id: String) -> Result { + load_preset(&preset_id).ok_or_else(|| format!("Preset '{}' not found", preset_id)) +} + +#[tauri::command] +fn set_active_preset( + character_id: String, + preset_id: Option, +) -> Result<(), String> { + let mut settings = load_roleplay_settings(&character_id); + settings.active_preset_id = preset_id; + save_roleplay_settings(&character_id, &settings) +} + +#[tauri::command] +fn save_custom_preset(preset: PromptPreset) -> Result<(), String> { + // Validate that it's not overwriting a built-in preset + let builtin_ids: Vec = get_builtin_presets().iter().map(|p| p.id.clone()).collect(); + if builtin_ids.contains(&preset.id) { + return Err("Cannot overwrite built-in presets".to_string()); + } + + save_preset(&preset) +} + +#[tauri::command] +fn update_preset_instructions( + preset_id: String, + instructions: Vec, +) -> Result<(), String> { + // Cannot update built-in presets + let builtin_ids: Vec = get_builtin_presets().iter().map(|p| p.id.clone()).collect(); + if builtin_ids.contains(&preset_id) { + return Err("Cannot modify built-in presets".to_string()); + } + + // Load the preset + let mut preset = load_preset(&preset_id) + .ok_or_else(|| format!("Preset '{}' not found", preset_id))?; + + // Update instructions + preset.instructions = instructions; + + // Save back + save_preset(&preset) +} + +#[tauri::command] +fn delete_custom_preset(preset_id: String) -> Result<(), String> { + // Cannot delete built-in presets + let builtin_ids: Vec = get_builtin_presets().iter().map(|p| p.id.clone()).collect(); + if builtin_ids.contains(&preset_id) { + return Err("Cannot delete built-in presets".to_string()); + } + + let path = get_preset_path(&preset_id); + fs::remove_file(path).map_err(|e| format!("Failed to delete preset: {}", e))?; + + // Invalidate cache for this preset + if let Ok(mut cache) = get_preset_cache().lock() { + cache.remove(&preset_id); + } + + Ok(()) +} + +#[tauri::command] +fn duplicate_preset(source_preset_id: String, new_name: String) -> Result { + // Load the source preset (can be built-in or custom) + let source_preset = load_preset(&source_preset_id) + .ok_or_else(|| format!("Source preset '{}' not found", source_preset_id))?; + + // Create new preset ID from name + let new_id = new_name.to_lowercase().replace(' ', "_"); + + // Check if preset with this ID already exists + if load_preset(&new_id).is_some() { + return Err(format!("A preset with ID '{}' already exists", new_id)); + } + + // Create the duplicated preset + let new_preset = PromptPreset { + id: new_id, + name: new_name, + description: format!("Copy of {}", source_preset.name), + system_additions: source_preset.system_additions.clone(), + authors_note_default: source_preset.authors_note_default.clone(), + instructions: source_preset.instructions.clone(), + format_hints: source_preset.format_hints.clone(), + }; + + // Save as custom preset + save_preset(&new_preset)?; + + Ok(new_preset) +} + +#[tauri::command] +fn is_builtin_preset_modified(preset_id: String) -> bool { + // Check if it's a built-in preset ID + let builtin_ids = vec!["default", "roleplay", "creative-writing", "assistant"]; + if !builtin_ids.contains(&preset_id.as_str()) { + return false; + } + + // Check if a custom override exists + let path = get_preset_path(&preset_id); + path.exists() +} + +#[tauri::command] +fn restore_builtin_preset(preset_id: String) -> Result { + // Verify it's a built-in preset + let builtin_ids = vec!["default", "roleplay", "creative-writing", "assistant"]; + if !builtin_ids.contains(&preset_id.as_str()) { + return Err("Can only restore built-in presets".to_string()); + } + + // Delete the custom override if it exists + let path = get_preset_path(&preset_id); + if path.exists() { + fs::remove_file(path).map_err(|e| format!("Failed to delete override: {}", e))?; + } + + // Invalidate cache + if let Ok(mut cache) = get_preset_cache().lock() { + cache.remove(&preset_id); + } + + // Load and return the built-in preset + load_preset(&preset_id) + .ok_or_else(|| format!("Built-in preset '{}' not found", preset_id)) +} + +// World Info Commands + #[tauri::command] fn add_world_info_entry( character_id: String, @@ -2271,6 +2832,16 @@ pub fn run() { update_roleplay_depths, update_authors_note, update_persona, + update_recursion_depth, + get_presets, + get_preset, + set_active_preset, + save_custom_preset, + update_preset_instructions, + delete_custom_preset, + duplicate_preset, + is_builtin_preset_modified, + restore_builtin_preset, add_world_info_entry, update_world_info_entry, delete_world_info_entry, diff --git a/src/index.html b/src/index.html index e08218d..438acfc 100644 --- a/src/index.html +++ b/src/index.html @@ -93,6 +93,7 @@ +
@@ -102,6 +103,18 @@

Create entries that inject context when keywords are mentioned.

+ + +

+ Maximum depth for cascading World Info activation. When a World Info entry is triggered, its content is scanned for additional keywords up to this depth. (Default: 3) +

@@ -124,6 +137,15 @@ placeholder="Write in present tense. Focus on sensory details..." rows="6" > +
+

Template Variables:

+

+ {{char}} - Character name
+ {{user}} - User/Persona name
+ {{date}} - Current date (YYYY-MM-DD)
+ {{time}} - Current time (HH:MM) +

+
+ +
+
+
+ +

+ Choose a preset to apply specialized prompting strategies for different use cases. +

+ +
+ + + + + + + + +

+ Note: Custom presets will be stored in ~/.config/claudia/presets/ and will be available across all characters. +

+
+
diff --git a/src/main.js b/src/main.js index f0f4637..f8e167c 100644 --- a/src/main.js +++ b/src/main.js @@ -1338,6 +1338,21 @@ function setupAppControls() { document.getElementById('add-worldinfo-btn').addEventListener('click', handleAddWorldInfoEntry); document.getElementById('save-authors-note-btn').addEventListener('click', handleSaveAuthorsNote); document.getElementById('save-persona-btn').addEventListener('click', handleSavePersona); + + // Setup recursion depth change handler + document.getElementById('recursion-depth').addEventListener('change', handleRecursionDepthChange); + + // Setup preset controls + document.getElementById('preset-select').addEventListener('change', (e) => { + handlePresetSelect(e.target.value); + }); + document.getElementById('apply-preset-btn').addEventListener('click', handleApplyPreset); + document.getElementById('create-preset-btn').addEventListener('click', handleCreatePreset); + document.getElementById('add-instruction-btn').addEventListener('click', addInstructionBlock); + document.getElementById('save-preset-changes-btn').addEventListener('click', savePresetChanges); + document.getElementById('delete-preset-btn').addEventListener('click', deletePreset); + document.getElementById('duplicate-preset-btn').addEventListener('click', duplicatePreset); + document.getElementById('restore-preset-btn').addEventListener('click', restoreBuiltinPreset); } // Keyboard shortcuts @@ -1678,6 +1693,9 @@ async function loadRoleplaySettings() { // Load World Info entries renderWorldInfoList(settings.world_info || []); + // Load World Info recursion depth + document.getElementById('recursion-depth').value = settings.recursion_depth || 3; + // Load Author's Note document.getElementById('authors-note-text').value = settings.authors_note || ''; document.getElementById('authors-note-enabled').checked = settings.authors_note_enabled || false; @@ -1686,6 +1704,9 @@ async function loadRoleplaySettings() { document.getElementById('persona-name').value = settings.persona_name || ''; document.getElementById('persona-description').value = settings.persona_description || ''; document.getElementById('persona-enabled').checked = settings.persona_enabled || false; + + // Load Presets + await loadPresets(); } catch (error) { console.error('Failed to load roleplay settings:', error); } @@ -1915,6 +1936,716 @@ async function handleSavePersona() { } } +// Handle recursion depth change +async function handleRecursionDepthChange() { + if (!currentCharacter) return; + + const depth = parseInt(document.getElementById('recursion-depth').value) || 3; + + try { + await invoke('update_recursion_depth', { + characterId: currentCharacter.id, + depth + }); + + console.log('Recursion depth updated to:', depth); + } catch (error) { + console.error('Failed to update recursion depth:', error); + setStatus('Failed to save recursion depth', 'error'); + setTimeout(() => setStatus('Ready'), 2000); + } +} + +// Prompt Preset Management + +// Load available presets +async function loadPresets() { + try { + const presets = await invoke('get_presets'); + const presetSelect = document.getElementById('preset-select'); + + // Clear existing options except "No Preset" + presetSelect.innerHTML = ''; + + // Add presets to dropdown + presets.forEach(preset => { + const option = document.createElement('option'); + option.value = preset.id; + option.textContent = preset.name; + presetSelect.appendChild(option); + }); + + // Set current preset if one is active + if (currentRoleplaySettings && currentRoleplaySettings.active_preset_id) { + presetSelect.value = currentRoleplaySettings.active_preset_id; + await handlePresetSelect(currentRoleplaySettings.active_preset_id); + } else { + presetSelect.value = ''; + hidePresetInfo(); + } + } catch (error) { + console.error('Failed to load presets:', error); + } +} + +// Hide preset info panel +function hidePresetInfo() { + const presetInfo = document.getElementById('preset-info'); + const applyBtn = document.getElementById('apply-preset-btn'); + presetInfo.style.display = 'none'; + applyBtn.disabled = true; +} + +// Global variable to track current preset being edited +let currentEditingPreset = null; + +// Show preset details/editor +async function handlePresetSelect(presetId) { + if (!presetId) { + hidePresetInfo(); + return; + } + + try { + const preset = await invoke('get_preset', { presetId }); + currentEditingPreset = preset; + + // Determine if this is a built-in preset + const builtInIds = ['default', 'roleplay', 'creative-writing', 'assistant']; + const isBuiltIn = builtInIds.includes(preset.id); + + // Show preset info + const presetInfo = document.getElementById('preset-info'); + const presetName = document.getElementById('preset-name'); + const presetDescription = document.getElementById('preset-description'); + const builtInBadge = document.getElementById('preset-builtin-badge'); + const deleteBtn = document.getElementById('delete-preset-btn'); + const duplicateBtn = document.getElementById('duplicate-preset-btn'); + const saveChangesBtn = document.getElementById('save-preset-changes-btn'); + const addInstructionBtn = document.getElementById('add-instruction-btn'); + const applyBtn = document.getElementById('apply-preset-btn'); + + // System additions elements + const systemReadonly = document.getElementById('preset-system-readonly'); + const systemEditable = document.getElementById('preset-system-editable'); + + // Author's note elements + const authorsNoteReadonly = document.getElementById('preset-authors-note-readonly'); + const authorsNoteEditable = document.getElementById('preset-authors-note-editable'); + + presetName.textContent = preset.name; + presetDescription.textContent = preset.description; + presetInfo.style.display = 'block'; + + // Check if built-in preset is modified + const modifiedBadge = document.getElementById('preset-modified-badge'); + const restoreBtn = document.getElementById('restore-preset-btn'); + let isModified = false; + + if (isBuiltIn) { + isModified = await invoke('is_builtin_preset_modified', { presetId: preset.id }); + } + + // Show/hide built-in badge and controls + if (isBuiltIn) { + builtInBadge.style.display = 'inline-block'; + modifiedBadge.style.display = isModified ? 'inline-block' : 'none'; + deleteBtn.style.display = 'none'; + duplicateBtn.style.display = 'inline-block'; + restoreBtn.style.display = isModified ? 'inline-block' : 'none'; + saveChangesBtn.style.display = 'inline-block'; + addInstructionBtn.style.display = 'inline-block'; + + // Show editable versions (built-in presets are now editable) + systemEditable.value = preset.system_additions || ''; + systemEditable.style.display = 'block'; + systemReadonly.style.display = 'none'; + + authorsNoteEditable.value = preset.authors_note_default || ''; + authorsNoteEditable.style.display = 'block'; + authorsNoteReadonly.style.display = 'none'; + } else { + builtInBadge.style.display = 'none'; + modifiedBadge.style.display = 'none'; + restoreBtn.style.display = 'none'; + deleteBtn.style.display = 'inline-block'; + duplicateBtn.style.display = 'none'; + saveChangesBtn.style.display = 'block'; + addInstructionBtn.style.display = 'inline-block'; + + // Show editable versions + systemEditable.value = preset.system_additions || ''; + systemEditable.style.display = 'block'; + systemReadonly.style.display = 'none'; + + authorsNoteEditable.value = preset.authors_note_default || ''; + authorsNoteEditable.style.display = 'block'; + authorsNoteReadonly.style.display = 'none'; + } + + // Render instruction blocks (all presets are now editable) + renderInstructionBlocks(preset.instructions, false); + + // Enable apply button + applyBtn.disabled = false; + } catch (error) { + console.error('Failed to load preset details:', error); + hidePresetInfo(); + } +} + +// Apply selected preset +async function handleApplyPreset() { + if (!currentCharacter) return; + + const presetSelect = document.getElementById('preset-select'); + const presetId = presetSelect.value || null; + + try { + await invoke('set_active_preset', { + characterId: currentCharacter.id, + presetId + }); + + // Update local settings + if (currentRoleplaySettings) { + currentRoleplaySettings.active_preset_id = presetId; + } + + setStatus(presetId ? 'Preset applied' : 'Preset removed', 'success'); + setTimeout(() => setStatus('Ready'), 2000); + } catch (error) { + console.error('Failed to apply preset:', error); + setStatus('Failed to apply preset', 'error'); + setTimeout(() => setStatus('Ready'), 2000); + } +} + +// Create custom preset +async function handleCreatePreset() { + const name = prompt('Enter a name for your custom preset:'); + if (!name || !name.trim()) return; + + const description = prompt('Enter a description for your preset:'); + if (!description || !description.trim()) return; + + const systemAdditions = prompt('Enter system additions (press Cancel to skip):', ''); + const authorsNoteDefault = prompt('Enter default Author\'s Note (press Cancel to skip):', ''); + + try { + // Generate a simple ID from the name + const id = name.toLowerCase().replace(/[^a-z0-9]+/g, '-'); + + const preset = { + id: id, + name: name.trim(), + description: description.trim(), + system_additions: systemAdditions || '', + authors_note_default: authorsNoteDefault || '', + instructions: [], + format_hints: { + wi_format: '[{content}]', + scenario_format: '[Scenario: {content}]', + personality_format: '[{char}\'s personality: {content}]' + } + }; + + await invoke('save_custom_preset', { preset }); + + setStatus('Custom preset created', 'success'); + setTimeout(() => setStatus('Ready'), 2000); + + // Reload presets + await loadPresets(); + + // Select the new preset + document.getElementById('preset-select').value = id; + await handlePresetSelect(id); + } catch (error) { + console.error('Failed to create preset:', error); + alert(`Failed to create preset: ${error}`); + setStatus('Failed to create preset', 'error'); + setTimeout(() => setStatus('Ready'), 2000); + } +} + +// Render instruction blocks list +function renderInstructionBlocks(instructions, isReadOnly) { + const listContainer = document.getElementById('preset-instructions-list'); + listContainer.innerHTML = ''; + + if (!instructions || instructions.length === 0) { + listContainer.innerHTML = '
No instruction blocks yet.
'; + return; + } + + // Sort by order + const sortedInstructions = [...instructions].sort((a, b) => a.order - b.order); + + sortedInstructions.forEach((instruction, index) => { + const blockDiv = document.createElement('div'); + blockDiv.className = 'worldinfo-entry'; + blockDiv.style.marginBottom = '8px'; + blockDiv.style.padding = '8px'; + blockDiv.style.background = 'var(--bg-secondary)'; + blockDiv.style.borderRadius = '4px'; + blockDiv.style.cursor = 'pointer'; + blockDiv.style.transition = 'all 0.2s ease'; + blockDiv.dataset.instructionId = instruction.id; + blockDiv.dataset.collapsed = 'false'; + + // Enable drag and drop for non-readonly + if (!isReadOnly) { + blockDiv.draggable = true; + blockDiv.style.cursor = 'move'; + + // Drag event handlers + blockDiv.addEventListener('dragstart', (e) => { + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', instruction.id); + blockDiv.style.opacity = '0.5'; + }); + + blockDiv.addEventListener('dragend', (e) => { + blockDiv.style.opacity = '1'; + // Remove all drop indicators + document.querySelectorAll('.worldinfo-entry').forEach(el => { + el.style.borderTop = ''; + el.style.borderBottom = ''; + }); + }); + + blockDiv.addEventListener('dragover', (e) => { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + + // Show drop indicator + const rect = blockDiv.getBoundingClientRect(); + const midpoint = rect.top + rect.height / 2; + if (e.clientY < midpoint) { + blockDiv.style.borderTop = '2px solid var(--accent)'; + blockDiv.style.borderBottom = ''; + } else { + blockDiv.style.borderTop = ''; + blockDiv.style.borderBottom = '2px solid var(--accent)'; + } + }); + + blockDiv.addEventListener('dragleave', (e) => { + blockDiv.style.borderTop = ''; + blockDiv.style.borderBottom = ''; + }); + + blockDiv.addEventListener('drop', (e) => { + e.preventDefault(); + blockDiv.style.borderTop = ''; + blockDiv.style.borderBottom = ''; + + const draggedId = e.dataTransfer.getData('text/plain'); + const draggedInstruction = currentEditingPreset.instructions.find(i => i.id === draggedId); + const dropInstruction = instruction; + + if (draggedId !== instruction.id && draggedInstruction) { + // Determine drop position + const rect = blockDiv.getBoundingClientRect(); + const midpoint = rect.top + rect.height / 2; + const dropBefore = e.clientY < midpoint; + + // Reorder instructions + const draggedOrder = draggedInstruction.order; + const dropOrder = dropInstruction.order; + + if (dropBefore) { + // Insert before + if (draggedOrder < dropOrder) { + // Moving down - shift items between draggedOrder and dropOrder-1 up + currentEditingPreset.instructions.forEach(inst => { + if (inst.order > draggedOrder && inst.order < dropOrder) { + inst.order--; + } + }); + draggedInstruction.order = dropOrder - 1; + } else { + // Moving up - shift items from dropOrder to draggedOrder-1 down + currentEditingPreset.instructions.forEach(inst => { + if (inst.order >= dropOrder && inst.order < draggedOrder) { + inst.order++; + } + }); + draggedInstruction.order = dropOrder; + } + } else { + // Insert after + if (draggedOrder < dropOrder) { + // Moving down - shift items between draggedOrder+1 and dropOrder down + currentEditingPreset.instructions.forEach(inst => { + if (inst.order > draggedOrder && inst.order <= dropOrder) { + inst.order--; + } + }); + draggedInstruction.order = dropOrder; + } else { + // Moving up - shift items from dropOrder+1 to draggedOrder-1 down + currentEditingPreset.instructions.forEach(inst => { + if (inst.order > dropOrder && inst.order < draggedOrder) { + inst.order++; + } + }); + draggedInstruction.order = dropOrder + 1; + } + } + + // Re-render + renderInstructionBlocks(currentEditingPreset.instructions, isReadOnly); + } + }); + } + + const header = document.createElement('div'); + header.style.display = 'flex'; + header.style.justifyContent = 'space-between'; + header.style.alignItems = 'center'; + header.style.marginBottom = '6px'; + header.style.userSelect = 'none'; + + const leftSide = document.createElement('div'); + leftSide.style.display = 'flex'; + leftSide.style.alignItems = 'center'; + leftSide.style.gap = '8px'; + + // Expand/collapse chevron + const chevron = document.createElement('span'); + chevron.style.fontSize = '10px'; + chevron.style.transition = 'transform 0.2s ease'; + chevron.textContent = '▼'; + chevron.style.color = 'var(--text-secondary)'; + leftSide.appendChild(chevron); + + if (!isReadOnly) { + // Drag handle + const dragHandle = document.createElement('span'); + dragHandle.style.fontSize = '10px'; + dragHandle.style.color = 'var(--text-secondary)'; + dragHandle.textContent = '⋮⋮'; + dragHandle.style.cursor = 'move'; + leftSide.appendChild(dragHandle); + + // Checkbox for enabled/disabled + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.checked = instruction.enabled; + checkbox.addEventListener('change', (e) => { + e.stopPropagation(); + instruction.enabled = checkbox.checked; + }); + checkbox.addEventListener('click', (e) => e.stopPropagation()); + leftSide.appendChild(checkbox); + } + + // Order badge + const orderBadge = document.createElement('span'); + orderBadge.style.fontSize = '10px'; + orderBadge.style.color = 'var(--text-secondary)'; + orderBadge.style.background = 'var(--bg-primary)'; + orderBadge.style.padding = '2px 6px'; + orderBadge.style.borderRadius = '3px'; + orderBadge.textContent = `#${instruction.order}`; + leftSide.appendChild(orderBadge); + + // Name + const nameSpan = document.createElement('span'); + nameSpan.style.fontWeight = '500'; + nameSpan.style.fontSize = '11px'; + nameSpan.textContent = instruction.name; + if (!instruction.enabled) { + nameSpan.style.opacity = '0.5'; + } + leftSide.appendChild(nameSpan); + + header.appendChild(leftSide); + + if (!isReadOnly) { + // Control buttons + const controls = document.createElement('div'); + controls.style.display = 'flex'; + controls.style.gap = '4px'; + + // Edit button + const editBtn = document.createElement('button'); + editBtn.className = 'worldinfo-btn'; + editBtn.textContent = 'Edit'; + editBtn.style.fontSize = '11px'; + editBtn.style.padding = '2px 6px'; + editBtn.addEventListener('click', (e) => { + e.stopPropagation(); + editInstruction(instruction); + }); + controls.appendChild(editBtn); + + // Delete button + const deleteBtn = document.createElement('button'); + deleteBtn.className = 'worldinfo-btn worldinfo-btn-danger'; + deleteBtn.textContent = 'Delete'; + deleteBtn.style.fontSize = '11px'; + deleteBtn.style.padding = '2px 6px'; + deleteBtn.addEventListener('click', (e) => { + e.stopPropagation(); + deleteInstruction(instruction.id); + }); + controls.appendChild(deleteBtn); + + header.appendChild(controls); + } + + // Content + const contentDiv = document.createElement('div'); + contentDiv.className = 'instruction-content'; + contentDiv.style.fontSize = '11px'; + contentDiv.style.color = 'var(--text-secondary)'; + contentDiv.style.marginTop = '4px'; + contentDiv.style.whiteSpace = 'pre-wrap'; + contentDiv.style.overflow = 'hidden'; + contentDiv.style.transition = 'max-height 0.3s ease, opacity 0.3s ease'; + contentDiv.textContent = instruction.content; + if (!instruction.enabled) { + contentDiv.style.opacity = '0.5'; + } + + // Toggle expand/collapse on header click + header.addEventListener('click', () => { + const isCollapsed = blockDiv.dataset.collapsed === 'true'; + blockDiv.dataset.collapsed = isCollapsed ? 'false' : 'true'; + + if (isCollapsed) { + // Expand + chevron.style.transform = 'rotate(0deg)'; + contentDiv.style.maxHeight = contentDiv.scrollHeight + 'px'; + contentDiv.style.opacity = '1'; + setTimeout(() => { + contentDiv.style.maxHeight = 'none'; + }, 300); + } else { + // Collapse + chevron.style.transform = 'rotate(-90deg)'; + contentDiv.style.maxHeight = contentDiv.scrollHeight + 'px'; + setTimeout(() => { + contentDiv.style.maxHeight = '0'; + contentDiv.style.opacity = '0'; + }, 10); + } + }); + + blockDiv.appendChild(header); + blockDiv.appendChild(contentDiv); + listContainer.appendChild(blockDiv); + }); +} + +// Add new instruction block +function addInstructionBlock() { + if (!currentEditingPreset) return; + + const name = prompt('Enter instruction block name:'); + if (!name || !name.trim()) return; + + const content = prompt('Enter instruction block content:'); + if (!content || !content.trim()) return; + + // Generate ID and determine order + const id = `inst_${Date.now()}`; + const maxOrder = currentEditingPreset.instructions.length > 0 + ? Math.max(...currentEditingPreset.instructions.map(i => i.order)) + : 0; + + const newInstruction = { + id, + name: name.trim(), + content: content.trim(), + enabled: true, + order: maxOrder + 1 + }; + + currentEditingPreset.instructions.push(newInstruction); + + // Re-render + renderInstructionBlocks(currentEditingPreset.instructions, false); +} + +// Edit instruction block +function editInstruction(instruction) { + const newName = prompt('Edit instruction block name:', instruction.name); + if (newName === null) return; + + const newContent = prompt('Edit instruction block content:', instruction.content); + if (newContent === null) return; + + instruction.name = newName.trim(); + instruction.content = newContent.trim(); + + // Re-render + renderInstructionBlocks(currentEditingPreset.instructions, false); +} + +// Delete instruction block +function deleteInstruction(instructionId) { + if (!confirm('Delete this instruction block?')) return; + + if (!currentEditingPreset) return; + + currentEditingPreset.instructions = currentEditingPreset.instructions.filter( + i => i.id !== instructionId + ); + + // Re-render + renderInstructionBlocks(currentEditingPreset.instructions, false); +} + +// Move instruction block up or down +function moveInstruction(instructionId, direction) { + if (!currentEditingPreset) return; + + const instructions = currentEditingPreset.instructions.sort((a, b) => a.order - b.order); + const index = instructions.findIndex(i => i.id === instructionId); + + if (index === -1) return; + if (direction === -1 && index === 0) return; // Already at top + if (direction === 1 && index === instructions.length - 1) return; // Already at bottom + + const targetIndex = index + direction; + + // Swap orders + const temp = instructions[index].order; + instructions[index].order = instructions[targetIndex].order; + instructions[targetIndex].order = temp; + + // Re-render + renderInstructionBlocks(currentEditingPreset.instructions, false); +} + +// Save preset changes +async function savePresetChanges() { + if (!currentEditingPreset) return; + + try { + // Update system additions and author's note from UI + const systemEditable = document.getElementById('preset-system-editable'); + const authorsNoteEditable = document.getElementById('preset-authors-note-editable'); + + currentEditingPreset.system_additions = systemEditable.value; + currentEditingPreset.authors_note_default = authorsNoteEditable.value; + + // Save via update_preset_instructions command + await invoke('update_preset_instructions', { + presetId: currentEditingPreset.id, + instructions: currentEditingPreset.instructions + }); + + // Also save the full preset to update system_additions and authors_note_default + await invoke('save_custom_preset', { preset: currentEditingPreset }); + + setStatus('Preset saved', 'success'); + setTimeout(() => setStatus('Ready'), 2000); + + // Reload to show updated preset + await handlePresetSelect(currentEditingPreset.id); + } catch (error) { + console.error('Failed to save preset changes:', error); + alert(`Failed to save changes: ${error}`); + setStatus('Failed to save preset', 'error'); + setTimeout(() => setStatus('Ready'), 2000); + } +} + +// Delete custom preset +async function deletePreset() { + if (!currentEditingPreset) return; + + if (!confirm(`Delete preset "${currentEditingPreset.name}"? This cannot be undone.`)) return; + + try { + await invoke('delete_custom_preset', { presetId: currentEditingPreset.id }); + + setStatus('Preset deleted', 'success'); + setTimeout(() => setStatus('Ready'), 2000); + + // Clear selection and reload presets + document.getElementById('preset-select').value = ''; + currentEditingPreset = null; + hidePresetInfo(); + await loadPresets(); + } catch (error) { + console.error('Failed to delete preset:', error); + alert(`Failed to delete preset: ${error}`); + setStatus('Failed to delete preset', 'error'); + setTimeout(() => setStatus('Ready'), 2000); + } +} + +// Duplicate preset (create editable copy) +async function duplicatePreset() { + if (!currentEditingPreset) return; + + const newName = prompt(`Enter a name for the duplicated preset:`, `${currentEditingPreset.name} (Copy)`); + if (!newName || !newName.trim()) return; + + try { + const duplicatedPreset = await invoke('duplicate_preset', { + sourcePresetId: currentEditingPreset.id, + newName: newName.trim() + }); + + setStatus('Preset duplicated successfully', 'success'); + setTimeout(() => setStatus('Ready'), 2000); + + // Reload presets + await loadPresets(); + + // Select the new preset + document.getElementById('preset-select').value = duplicatedPreset.id; + await handlePresetSelect(duplicatedPreset.id); + } catch (error) { + console.error('Failed to duplicate preset:', error); + alert(`Failed to duplicate preset: ${error}`); + setStatus('Failed to duplicate preset', 'error'); + setTimeout(() => setStatus('Ready'), 2000); + } +} + +// Restore built-in preset to default +async function restoreBuiltinPreset() { + if (!currentEditingPreset) return; + + const builtInIds = ['default', 'roleplay', 'creative-writing', 'assistant']; + if (!builtInIds.includes(currentEditingPreset.id)) { + alert('Can only restore built-in presets'); + return; + } + + if (!confirm(`Are you sure you want to restore "${currentEditingPreset.name}" to its default settings? All your modifications will be lost.`)) { + return; + } + + try { + const restoredPreset = await invoke('restore_builtin_preset', { + presetId: currentEditingPreset.id + }); + + setStatus('Preset restored to default successfully', 'success'); + setTimeout(() => setStatus('Ready'), 2000); + + // Reload presets + await loadPresets(); + + // Re-select the restored preset to refresh the UI + await handlePresetSelect(restoredPreset.id); + } catch (error) { + console.error('Failed to restore preset:', error); + alert(`Failed to restore preset: ${error}`); + setStatus('Failed to restore preset', 'error'); + setTimeout(() => setStatus('Ready'), 2000); + } +} + // Load existing config if available async function loadExistingConfig() { console.log('Loading existing config...');