feat: make built-in presets editable with restore-to-default
Add ability to edit built-in presets (Default, Roleplay, Creative Writing, Assistant) while preserving original defaults: - Built-in presets are now fully editable (system additions, author's note, instruction blocks) - Modifications are saved as overrides in ~/.config/claudia/presets/ - "Modified" badge appears when built-in preset has been customized - "Restore to Default" button removes overrides and restores originals - Backend commands: is_builtin_preset_modified, restore_builtin_preset - All instruction blocks support expand/collapse and drag-and-drop reordering Also update ROADMAP.md to reflect completed features: - World Info/Lorebook System ✅ - Author's Note ✅ - User Personas ✅ - Regex Scripts ✅ - Chat History Import/Export ✅ 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
69
ROADMAP.md
69
ROADMAP.md
@@ -9,30 +9,37 @@
|
|||||||
- Character Management (multiple characters)
|
- Character Management (multiple characters)
|
||||||
- Character Avatars with upload and zoom
|
- Character Avatars with upload and zoom
|
||||||
- Expanded Character Editor (all v2/v3 fields)
|
- 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
|
### 🎯 Current Focus: Token Counter & Context Management
|
||||||
**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.
|
**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)
|
## Phase 1: Core Roleplay Infrastructure (High Priority)
|
||||||
**Goal: Enable basic roleplay-focused prompt engineering**
|
**Goal: Enable basic roleplay-focused prompt engineering**
|
||||||
|
|
||||||
### 1. World Info/Lorebook System
|
### 1. World Info/Lorebook System ✅
|
||||||
- [ ] Create UI for managing lorebook entries (keyword, content, priority)
|
- [x] Create UI for managing lorebook entries (keyword, content, priority)
|
||||||
- [ ] Implement keyword detection in recent messages
|
- [x] Implement keyword detection in recent messages
|
||||||
- [ ] Add context injection before message generation
|
- [x] Add context injection before message generation
|
||||||
- [ ] Support recursive entry activation
|
- [x] Support recursive entry activation
|
||||||
- [ ] Per-character lorebook assignment
|
- [x] Per-character lorebook assignment
|
||||||
- [ ] Import/export lorebook files
|
- [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.
|
**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
|
### 2. Author's Note ✅
|
||||||
- [ ] Add configurable Author's Note field (inserted at depth 1-5)
|
- [x] Add configurable Author's Note field (inserted at depth 1-5)
|
||||||
- [ ] Position control (after system, before/after examples, etc.)
|
- [x] Position control (after system, before/after examples, etc.)
|
||||||
- [ ] Per-character Author's Note support
|
- [x] Per-character Author's Note support
|
||||||
- [ ] Template variables in Author's Note
|
- [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.
|
**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)
|
## Phase 2: Enhanced Character Features (High Priority)
|
||||||
**Goal: Better character representation and user identity**
|
**Goal: Better character representation and user identity**
|
||||||
|
|
||||||
### 1. User Personas
|
### 1. User Personas ✅
|
||||||
- [ ] Create persona management UI (name, description, avatar)
|
- [x] Create persona management UI (name, description, avatar)
|
||||||
- [ ] Chat-level persona locking
|
- [x] Chat-level persona locking
|
||||||
- [ ] Character-level persona locking
|
- [x] Character-level persona locking
|
||||||
- [ ] Default persona setting
|
- [x] Default persona setting
|
||||||
- [ ] Quick persona switching
|
- [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.
|
**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.
|
**Why Important:** Makes prompts and messages dynamic and reusable across different scenarios.
|
||||||
|
|
||||||
### 3. Regex Scripts
|
### 3. Regex Scripts ✅
|
||||||
- [ ] Global and character-scoped scripts
|
- [x] Global and character-scoped scripts
|
||||||
- [ ] Text transformation on messages
|
- [x] Text transformation on messages
|
||||||
- [ ] Auto-markdown formatting
|
- [x] Auto-markdown formatting
|
||||||
- [ ] Import/export regex presets
|
- [x] Import/export regex presets
|
||||||
- [ ] Regex testing interface
|
- [x] Regex testing interface
|
||||||
- [ ] Script priority/ordering
|
- [x] Script priority/ordering
|
||||||
|
|
||||||
**Why Important:** Allows automatic text formatting, correction, and enhancement without manual intervention.
|
**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
|
### 2. Export/Import Improvements
|
||||||
- [ ] Export chats as markdown
|
- [ ] Export chats as markdown
|
||||||
- [ ] Export chats as formatted text
|
- [ ] Export chats as formatted text
|
||||||
- [ ] Export chats as JSON with metadata
|
- [x] Export chats as JSON with metadata
|
||||||
- [ ] Import chats from other formats
|
- [x] Import chats from other formats
|
||||||
- [ ] Bulk character import
|
- [ ] Bulk character import
|
||||||
- [ ] Character pack support (multiple characters + lorebooks)
|
- [ ] 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
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ use futures::StreamExt;
|
|||||||
use tauri::Emitter;
|
use tauri::Emitter;
|
||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
|
use std::sync::{Mutex, OnceLock};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
struct ApiConfig {
|
struct ApiConfig {
|
||||||
@@ -257,7 +259,7 @@ struct WorldInfoEntry {
|
|||||||
use_regex: bool, // Use regex matching instead of literal string matching
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
struct RoleplaySettings {
|
struct RoleplaySettings {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -276,6 +278,10 @@ struct RoleplaySettings {
|
|||||||
world_info: Vec<WorldInfoEntry>,
|
world_info: Vec<WorldInfoEntry>,
|
||||||
#[serde(default = "default_scan_depth")]
|
#[serde(default = "default_scan_depth")]
|
||||||
scan_depth: usize, // Scan last N messages for keywords (default 20)
|
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<String>, // Selected prompt preset for this character
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_authors_note_depth() -> usize {
|
fn default_authors_note_depth() -> usize {
|
||||||
@@ -286,6 +292,10 @@ fn default_scan_depth() -> usize {
|
|||||||
20
|
20
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_recursion_depth() -> usize {
|
||||||
|
3
|
||||||
|
}
|
||||||
|
|
||||||
impl Default for RoleplaySettings {
|
impl Default for RoleplaySettings {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -297,10 +307,176 @@ impl Default for RoleplaySettings {
|
|||||||
persona_enabled: false,
|
persona_enabled: false,
|
||||||
world_info: Vec::new(),
|
world_info: Vec::new(),
|
||||||
scan_depth: default_scan_depth(),
|
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<InstructionBlock>,
|
||||||
|
#[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<PromptPreset> {
|
||||||
|
vec![
|
||||||
|
create_default_preset(),
|
||||||
|
create_roleplay_preset(),
|
||||||
|
create_creative_writing_preset(),
|
||||||
|
create_assistant_preset(),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
struct ChatHistory {
|
struct ChatHistory {
|
||||||
messages: Vec<Message>,
|
messages: Vec<Message>,
|
||||||
@@ -414,6 +590,129 @@ fn save_roleplay_settings(character_id: &str, settings: &RoleplaySettings) -> Re
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prompt Preset Path and Loading Functions
|
||||||
|
|
||||||
|
// Preset cache to avoid disk I/O on every message
|
||||||
|
static PRESET_CACHE: OnceLock<Mutex<HashMap<String, PromptPreset>>> = OnceLock::new();
|
||||||
|
|
||||||
|
fn get_preset_cache() -> &'static Mutex<HashMap<String, PromptPreset>> {
|
||||||
|
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<PromptPreset> {
|
||||||
|
// 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::<PromptPreset>(&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<PresetInfo> {
|
||||||
|
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::<PromptPreset>(&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
|
// PNG Character Card Utilities
|
||||||
|
|
||||||
// Manual PNG chunk parser - more reliable than relying on png crate's text chunk exposure
|
// Manual PNG chunk parser - more reliable than relying on png crate's text chunk exposure
|
||||||
@@ -865,62 +1164,101 @@ fn get_api_config() -> Result<ApiConfig, String> {
|
|||||||
|
|
||||||
// Roleplay Context Injection Logic
|
// Roleplay Context Injection Logic
|
||||||
|
|
||||||
// Scan messages for World Info keywords and return activated entries
|
// Helper function to check if text contains any keyword from an entry
|
||||||
fn scan_for_world_info(messages: &[Message], world_info: &[WorldInfoEntry], scan_depth: usize) -> Vec<WorldInfoEntry> {
|
fn text_matches_entry(text: &str, entry: &WorldInfoEntry) -> bool {
|
||||||
let mut activated_entries = Vec::new();
|
|
||||||
|
|
||||||
// Scan the last N messages (scan_depth)
|
|
||||||
let messages_to_scan: Vec<&Message> = messages.iter()
|
|
||||||
.rev()
|
|
||||||
.take(scan_depth)
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
for entry in world_info {
|
|
||||||
if !entry.enabled {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if any keyword matches in the scanned messages
|
|
||||||
let mut activated = false;
|
|
||||||
for message in &messages_to_scan {
|
|
||||||
let content = message.get_content();
|
|
||||||
|
|
||||||
for keyword in &entry.keys {
|
for keyword in &entry.keys {
|
||||||
let matches = if entry.use_regex {
|
let matches = if entry.use_regex {
|
||||||
// Use regex matching
|
// Use regex matching
|
||||||
if let Ok(re) = Regex::new(keyword) {
|
if let Ok(re) = Regex::new(keyword) {
|
||||||
re.is_match(content)
|
re.is_match(text)
|
||||||
} else {
|
} else {
|
||||||
// Invalid regex - fall back to literal matching
|
// Invalid regex - fall back to literal matching
|
||||||
eprintln!("Invalid regex pattern '{}' for entry {}: falling back to literal match", keyword, entry.id);
|
eprintln!("Invalid regex pattern '{}' for entry {}: falling back to literal match", keyword, entry.id);
|
||||||
if entry.case_sensitive {
|
if entry.case_sensitive {
|
||||||
content.contains(keyword)
|
text.contains(keyword)
|
||||||
} else {
|
} else {
|
||||||
content.to_lowercase().contains(&keyword.to_lowercase())
|
text.to_lowercase().contains(&keyword.to_lowercase())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Use literal string matching
|
// Use literal string matching
|
||||||
if entry.case_sensitive {
|
if entry.case_sensitive {
|
||||||
content.contains(keyword)
|
text.contains(keyword)
|
||||||
} else {
|
} else {
|
||||||
content.to_lowercase().contains(&keyword.to_lowercase())
|
text.to_lowercase().contains(&keyword.to_lowercase())
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if matches {
|
if matches {
|
||||||
activated = true;
|
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<WorldInfoEntry> {
|
||||||
|
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<WorldInfoEntry> {
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
let mut activated_ids: HashSet<String> = HashSet::new();
|
||||||
|
let mut activated_entries: Vec<WorldInfoEntry> = Vec::new();
|
||||||
|
|
||||||
|
// Collect text to scan from messages
|
||||||
|
let messages_to_scan: Vec<&Message> = messages.iter()
|
||||||
|
.rev()
|
||||||
|
.take(scan_depth)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut scan_texts: Vec<String> = messages_to_scan
|
||||||
|
.iter()
|
||||||
|
.map(|msg| msg.get_content().to_string())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Iteratively scan for keywords with depth limit
|
||||||
|
for current_depth in 0..recursion_depth {
|
||||||
|
let mut newly_activated = Vec::new();
|
||||||
|
|
||||||
|
// Check each enabled entry against current scan texts
|
||||||
|
for entry in world_info {
|
||||||
|
if !entry.enabled || activated_ids.contains(&entry.id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if activated {
|
// If no new entries were activated, stop recursion
|
||||||
|
if newly_activated.is_empty() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if activated {
|
// Add newly activated entries to results
|
||||||
activated_entries.push(entry.clone());
|
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
|
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
|
// Build injected context from roleplay settings
|
||||||
fn build_roleplay_context(
|
fn build_roleplay_context(
|
||||||
_character: &Character,
|
character: &Character,
|
||||||
messages: &[Message],
|
messages: &[Message],
|
||||||
settings: &RoleplaySettings,
|
settings: &RoleplaySettings,
|
||||||
) -> (String, Option<String>, usize) {
|
) -> (String, Option<String>, usize) {
|
||||||
let mut system_additions = String::new();
|
let mut system_additions = String::new();
|
||||||
let mut authors_note_content = None;
|
let mut authors_note_content = None;
|
||||||
|
|
||||||
// 1. Add Persona to system prompt
|
// 0. Apply Prompt Preset instructions if one is selected
|
||||||
if settings.persona_enabled {
|
if let Some(preset_id) = &settings.active_preset_id {
|
||||||
if let Some(name) = &settings.persona_name {
|
if let Some(preset) = load_preset(preset_id) {
|
||||||
if let Some(desc) = &settings.persona_description {
|
// Add preset system additions (with template variables replaced)
|
||||||
system_additions.push_str(&format!("\n\n[{{{{user}}}}'s Persona: {} - {}]", name, desc));
|
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
|
// 1. Add Persona to system prompt (with template variables replaced)
|
||||||
let activated_entries = scan_for_world_info(messages, &settings.world_info, settings.scan_depth);
|
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() {
|
if !activated_entries.is_empty() {
|
||||||
system_additions.push_str("\n\n[Relevant World Information:");
|
system_additions.push_str("\n\n[Relevant World Information:");
|
||||||
for entry in activated_entries {
|
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 settings.authors_note_enabled {
|
||||||
if let Some(note) = &settings.authors_note {
|
if let Some(note) = &settings.authors_note {
|
||||||
if !note.is_empty() {
|
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)
|
(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<Message> {
|
||||||
|
// 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]
|
#[tauri::command]
|
||||||
async fn chat(message: String) -> Result<String, String> {
|
async fn chat(message: String) -> Result<String, String> {
|
||||||
let config = load_config().ok_or_else(|| "API not configured".to_string())?;
|
let config = load_config().ok_or_else(|| "API not configured".to_string())?;
|
||||||
@@ -988,31 +1432,9 @@ async fn chat(message: String) -> Result<String, String> {
|
|||||||
format!("{}/v1/chat/completions", base)
|
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 roleplay_settings = load_roleplay_settings(&character.id);
|
||||||
let (system_additions, authors_note, note_depth) = build_roleplay_context(&character, &history.messages, &roleplay_settings);
|
let api_messages = build_api_messages(&character, &history, &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 request = ChatRequest {
|
let request = ChatRequest {
|
||||||
model: config.model.clone(),
|
model: config.model.clone(),
|
||||||
@@ -1070,31 +1492,9 @@ async fn chat_stream(app_handle: tauri::AppHandle, message: String) -> Result<St
|
|||||||
format!("{}/v1/chat/completions", base)
|
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 roleplay_settings = load_roleplay_settings(&character.id);
|
||||||
let (system_additions, authors_note, note_depth) = build_roleplay_context(&character, &history.messages, &roleplay_settings);
|
let api_messages = build_api_messages(&character, &history, &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 request = StreamChatRequest {
|
let request = StreamChatRequest {
|
||||||
model: config.model.clone(),
|
model: config.model.clone(),
|
||||||
@@ -1249,8 +1649,9 @@ async fn generate_response_only() -> Result<String, String> {
|
|||||||
let roleplay_settings = load_roleplay_settings(&character.id);
|
let roleplay_settings = load_roleplay_settings(&character.id);
|
||||||
let (system_additions, authors_note, note_depth) = build_roleplay_context(&character, &history.messages, &roleplay_settings);
|
let (system_additions, authors_note, note_depth) = build_roleplay_context(&character, &history.messages, &roleplay_settings);
|
||||||
|
|
||||||
// Build messages with enhanced system prompt first
|
// Build messages with enhanced system prompt first (with template variables replaced)
|
||||||
let enhanced_system_prompt = format!("{}{}", character.system_prompt, system_additions);
|
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)];
|
let mut api_messages = vec![Message::new_user(enhanced_system_prompt)];
|
||||||
api_messages[0].role = "system".to_string();
|
api_messages[0].role = "system".to_string();
|
||||||
|
|
||||||
@@ -1322,8 +1723,9 @@ async fn generate_response_stream(app_handle: tauri::AppHandle) -> Result<String
|
|||||||
let roleplay_settings = load_roleplay_settings(&character.id);
|
let roleplay_settings = load_roleplay_settings(&character.id);
|
||||||
let (system_additions, authors_note, note_depth) = build_roleplay_context(&character, &history.messages, &roleplay_settings);
|
let (system_additions, authors_note, note_depth) = build_roleplay_context(&character, &history.messages, &roleplay_settings);
|
||||||
|
|
||||||
// Build messages with enhanced system prompt first
|
// Build messages with enhanced system prompt first (with template variables replaced)
|
||||||
let enhanced_system_prompt = format!("{}{}", character.system_prompt, system_additions);
|
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)];
|
let mut api_messages = vec![Message::new_user(enhanced_system_prompt)];
|
||||||
api_messages[0].role = "system".to_string();
|
api_messages[0].role = "system".to_string();
|
||||||
|
|
||||||
@@ -1983,6 +2385,165 @@ fn update_persona(
|
|||||||
save_roleplay_settings(&character_id, &settings)
|
save_roleplay_settings(&character_id, &settings)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn update_recursion_depth(
|
||||||
|
character_id: String,
|
||||||
|
depth: usize,
|
||||||
|
) -> 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<Vec<PresetInfo>, String> {
|
||||||
|
Ok(list_preset_infos())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn get_preset(preset_id: String) -> Result<PromptPreset, String> {
|
||||||
|
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<String>,
|
||||||
|
) -> 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<String> = 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<InstructionBlock>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
// Cannot update built-in presets
|
||||||
|
let builtin_ids: Vec<String> = 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<String> = 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<PromptPreset, String> {
|
||||||
|
// 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<PromptPreset, String> {
|
||||||
|
// 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]
|
#[tauri::command]
|
||||||
fn add_world_info_entry(
|
fn add_world_info_entry(
|
||||||
character_id: String,
|
character_id: String,
|
||||||
@@ -2271,6 +2832,16 @@ pub fn run() {
|
|||||||
update_roleplay_depths,
|
update_roleplay_depths,
|
||||||
update_authors_note,
|
update_authors_note,
|
||||||
update_persona,
|
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,
|
add_world_info_entry,
|
||||||
update_world_info_entry,
|
update_world_info_entry,
|
||||||
delete_world_info_entry,
|
delete_world_info_entry,
|
||||||
|
|||||||
101
src/index.html
101
src/index.html
@@ -93,6 +93,7 @@
|
|||||||
<button class="roleplay-tab-btn active" data-tab="worldinfo">World Info</button>
|
<button class="roleplay-tab-btn active" data-tab="worldinfo">World Info</button>
|
||||||
<button class="roleplay-tab-btn" data-tab="authorsnote">Author's Note</button>
|
<button class="roleplay-tab-btn" data-tab="authorsnote">Author's Note</button>
|
||||||
<button class="roleplay-tab-btn" data-tab="persona">Persona</button>
|
<button class="roleplay-tab-btn" data-tab="persona">Persona</button>
|
||||||
|
<button class="roleplay-tab-btn" data-tab="presets">Prompt Preset</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="worldinfo-tab" class="roleplay-tab-content active">
|
<div id="worldinfo-tab" class="roleplay-tab-content active">
|
||||||
@@ -102,6 +103,18 @@
|
|||||||
<p style="color: var(--text-secondary); font-size: 12px; margin-bottom: 12px;">
|
<p style="color: var(--text-secondary); font-size: 12px; margin-bottom: 12px;">
|
||||||
Create entries that inject context when keywords are mentioned.
|
Create entries that inject context when keywords are mentioned.
|
||||||
</p>
|
</p>
|
||||||
|
<label for="recursion-depth" style="font-size: 13px; margin-top: 8px;">Recursion Depth</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="recursion-depth"
|
||||||
|
min="0"
|
||||||
|
max="10"
|
||||||
|
value="3"
|
||||||
|
style="width: 80px; margin-bottom: 8px;"
|
||||||
|
/>
|
||||||
|
<p style="color: var(--text-secondary); font-size: 11px; margin-bottom: 12px;">
|
||||||
|
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)
|
||||||
|
</p>
|
||||||
<button type="button" id="add-worldinfo-btn" class="btn-secondary" style="width: 100%;">
|
<button type="button" id="add-worldinfo-btn" class="btn-secondary" style="width: 100%;">
|
||||||
+ Add Entry
|
+ Add Entry
|
||||||
</button>
|
</button>
|
||||||
@@ -124,6 +137,15 @@
|
|||||||
placeholder="Write in present tense. Focus on sensory details..."
|
placeholder="Write in present tense. Focus on sensory details..."
|
||||||
rows="6"
|
rows="6"
|
||||||
></textarea>
|
></textarea>
|
||||||
|
<div style="background: var(--bg-secondary); padding: 8px; border-radius: 4px; margin-top: 8px;">
|
||||||
|
<p style="color: var(--text-secondary); font-size: 11px; margin: 0 0 4px 0; font-weight: 500;">Template Variables:</p>
|
||||||
|
<p style="color: var(--text-secondary); font-size: 11px; margin: 0; font-family: monospace;">
|
||||||
|
{{char}} - Character name<br/>
|
||||||
|
{{user}} - User/Persona name<br/>
|
||||||
|
{{date}} - Current date (YYYY-MM-DD)<br/>
|
||||||
|
{{time}} - Current time (HH:MM)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>
|
<label>
|
||||||
@@ -169,6 +191,85 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="presets-tab" class="roleplay-tab-content">
|
||||||
|
<div class="roleplay-content">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="preset-select">Prompt Preset</label>
|
||||||
|
<p style="color: var(--text-secondary); font-size: 12px; margin-bottom: 8px;">
|
||||||
|
Choose a preset to apply specialized prompting strategies for different use cases.
|
||||||
|
</p>
|
||||||
|
<select id="preset-select" style="width: 100%; margin-bottom: 12px;">
|
||||||
|
<option value="">No Preset</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Preset Info/Editor -->
|
||||||
|
<div id="preset-info" style="display: none; background: var(--bg-secondary); padding: 12px; border-radius: 6px; margin-bottom: 16px;">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
|
||||||
|
<div style="font-weight: 500; color: var(--text-primary);">
|
||||||
|
<span id="preset-name"></span>
|
||||||
|
<span id="preset-builtin-badge" style="display: none; font-size: 10px; color: var(--text-secondary); margin-left: 8px; padding: 2px 6px; background: var(--bg-primary); border-radius: 3px;">Built-in</span>
|
||||||
|
<span id="preset-modified-badge" style="display: none; font-size: 10px; color: var(--accent); margin-left: 8px; padding: 2px 6px; background: var(--bg-primary); border-radius: 3px;">Modified</span>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; gap: 4px;">
|
||||||
|
<button type="button" id="restore-preset-btn" class="worldinfo-btn" style="display: none; font-size: 11px; padding: 4px 8px;">Restore to Default</button>
|
||||||
|
<button type="button" id="duplicate-preset-btn" class="worldinfo-btn" style="display: none; font-size: 11px; padding: 4px 8px;">Duplicate</button>
|
||||||
|
<button type="button" id="delete-preset-btn" class="worldinfo-btn worldinfo-btn-danger" style="display: none; font-size: 11px; padding: 4px 8px;">Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p id="preset-description" style="color: var(--text-secondary); font-size: 12px; margin-bottom: 12px;"></p>
|
||||||
|
|
||||||
|
<!-- System Additions (Read-only preview for built-in, editable for custom) -->
|
||||||
|
<div id="preset-system-section" style="margin-bottom: 12px;">
|
||||||
|
<div style="font-size: 11px; color: var(--text-secondary); margin-bottom: 4px;">
|
||||||
|
<strong>System Additions:</strong>
|
||||||
|
</div>
|
||||||
|
<div id="preset-system-readonly" style="display: none; background: var(--bg-primary); padding: 8px; border-radius: 4px; font-size: 11px; white-space: pre-wrap;"></div>
|
||||||
|
<textarea id="preset-system-editable" style="display: none; width: 100%; min-height: 60px; font-size: 11px;" placeholder="Additional text to prepend to system prompt..."></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Instruction Blocks Editor -->
|
||||||
|
<div id="preset-instructions-section">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
|
||||||
|
<div style="font-size: 11px; color: var(--text-secondary);">
|
||||||
|
<strong>Instruction Blocks:</strong>
|
||||||
|
</div>
|
||||||
|
<button type="button" id="add-instruction-btn" class="worldinfo-btn" style="display: none; font-size: 11px; padding: 4px 8px;">+ Add Block</button>
|
||||||
|
</div>
|
||||||
|
<div id="preset-instructions-list" style="background: var(--bg-primary); padding: 8px; border-radius: 4px; margin-bottom: 12px;">
|
||||||
|
<!-- Instructions will be listed here -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Author's Note Default -->
|
||||||
|
<div id="preset-authors-note-section" style="margin-bottom: 12px;">
|
||||||
|
<div style="font-size: 11px; color: var(--text-secondary); margin-bottom: 4px;">
|
||||||
|
<strong>Default Author's Note:</strong>
|
||||||
|
</div>
|
||||||
|
<div id="preset-authors-note-readonly" style="display: none; background: var(--bg-primary); padding: 8px; border-radius: 4px; font-size: 11px; white-space: pre-wrap;"></div>
|
||||||
|
<textarea id="preset-authors-note-editable" style="display: none; width: 100%; min-height: 60px; font-size: 11px;" placeholder="Default Author's Note if user hasn't set one..."></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Save Changes Button (only for custom presets) -->
|
||||||
|
<button type="button" id="save-preset-changes-btn" class="btn-secondary" style="display: none; width: 100%; margin-bottom: 8px;">
|
||||||
|
Save Changes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="button" id="apply-preset-btn" class="btn-primary" style="width: 100%; margin-bottom: 8px;" disabled>
|
||||||
|
Apply Preset
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button type="button" id="create-preset-btn" class="btn-secondary" style="width: 100%;">
|
||||||
|
Create Custom Preset
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p style="color: var(--text-secondary); font-size: 11px; margin-top: 12px; padding: 8px; background: var(--bg-secondary); border-radius: 4px;">
|
||||||
|
<strong>Note:</strong> Custom presets will be stored in ~/.config/claudia/presets/ and will be available across all characters.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Settings overlay backdrop -->
|
<!-- Settings overlay backdrop -->
|
||||||
|
|||||||
731
src/main.js
731
src/main.js
@@ -1338,6 +1338,21 @@ function setupAppControls() {
|
|||||||
document.getElementById('add-worldinfo-btn').addEventListener('click', handleAddWorldInfoEntry);
|
document.getElementById('add-worldinfo-btn').addEventListener('click', handleAddWorldInfoEntry);
|
||||||
document.getElementById('save-authors-note-btn').addEventListener('click', handleSaveAuthorsNote);
|
document.getElementById('save-authors-note-btn').addEventListener('click', handleSaveAuthorsNote);
|
||||||
document.getElementById('save-persona-btn').addEventListener('click', handleSavePersona);
|
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
|
// Keyboard shortcuts
|
||||||
@@ -1678,6 +1693,9 @@ async function loadRoleplaySettings() {
|
|||||||
// Load World Info entries
|
// Load World Info entries
|
||||||
renderWorldInfoList(settings.world_info || []);
|
renderWorldInfoList(settings.world_info || []);
|
||||||
|
|
||||||
|
// Load World Info recursion depth
|
||||||
|
document.getElementById('recursion-depth').value = settings.recursion_depth || 3;
|
||||||
|
|
||||||
// Load Author's Note
|
// Load Author's Note
|
||||||
document.getElementById('authors-note-text').value = settings.authors_note || '';
|
document.getElementById('authors-note-text').value = settings.authors_note || '';
|
||||||
document.getElementById('authors-note-enabled').checked = settings.authors_note_enabled || false;
|
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-name').value = settings.persona_name || '';
|
||||||
document.getElementById('persona-description').value = settings.persona_description || '';
|
document.getElementById('persona-description').value = settings.persona_description || '';
|
||||||
document.getElementById('persona-enabled').checked = settings.persona_enabled || false;
|
document.getElementById('persona-enabled').checked = settings.persona_enabled || false;
|
||||||
|
|
||||||
|
// Load Presets
|
||||||
|
await loadPresets();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load roleplay settings:', 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 = '<option value="">No Preset</option>';
|
||||||
|
|
||||||
|
// 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 = '<div style="text-align: center; color: var(--text-secondary); font-size: 11px; padding: 12px;">No instruction blocks yet.</div>';
|
||||||
|
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
|
// Load existing config if available
|
||||||
async function loadExistingConfig() {
|
async function loadExistingConfig() {
|
||||||
console.log('Loading existing config...');
|
console.log('Loading existing config...');
|
||||||
|
|||||||
Reference in New Issue
Block a user