diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b35e747..dd52059 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -241,6 +241,49 @@ impl Message { } } +// World Info / Lorebook Entry +#[derive(Debug, Clone, Serialize, Deserialize)] +struct WorldInfoEntry { + id: String, + keys: Vec, // Keywords that trigger this entry + content: String, // The content to inject + enabled: bool, + #[serde(default)] + case_sensitive: bool, + #[serde(default)] + priority: i32, // Higher priority entries are injected first +} + +// Roleplay Settings (Author's Note, Persona, World Info) +#[derive(Debug, Clone, Serialize, Deserialize)] +struct RoleplaySettings { + #[serde(default)] + authors_note: Option, + #[serde(default)] + authors_note_enabled: bool, + #[serde(default)] + persona_name: Option, + #[serde(default)] + persona_description: Option, + #[serde(default)] + persona_enabled: bool, + #[serde(default)] + world_info: Vec, +} + +impl Default for RoleplaySettings { + fn default() -> Self { + Self { + authors_note: None, + authors_note_enabled: false, + persona_name: None, + persona_description: None, + persona_enabled: false, + world_info: Vec::new(), + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] struct ChatHistory { messages: Vec, @@ -330,6 +373,30 @@ fn get_avatar_path(filename: &str) -> PathBuf { get_avatars_dir().join(filename) } +fn get_roleplay_settings_path(character_id: &str) -> PathBuf { + let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()); + PathBuf::from(home).join(format!(".config/claudia/roleplay_{}.json", character_id)) +} + +fn load_roleplay_settings(character_id: &str) -> RoleplaySettings { + let path = get_roleplay_settings_path(character_id); + if let Ok(contents) = fs::read_to_string(path) { + serde_json::from_str(&contents).unwrap_or_default() + } else { + RoleplaySettings::default() + } +} + +fn save_roleplay_settings(character_id: &str, settings: &RoleplaySettings) -> Result<(), String> { + let path = get_roleplay_settings_path(character_id); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + let contents = serde_json::to_string_pretty(settings).map_err(|e| e.to_string())?; + fs::write(path, contents).map_err(|e| e.to_string())?; + Ok(()) +} + // PNG Character Card Utilities // Manual PNG chunk parser - more reliable than relying on png crate's text chunk exposure @@ -1512,6 +1579,98 @@ async fn import_chat_history(app_handle: tauri::AppHandle) -> Result Result { + Ok(load_roleplay_settings(&character_id)) +} + +#[tauri::command] +fn update_authors_note( + character_id: String, + content: Option, + enabled: bool, +) -> Result<(), String> { + let mut settings = load_roleplay_settings(&character_id); + settings.authors_note = content; + settings.authors_note_enabled = enabled; + save_roleplay_settings(&character_id, &settings) +} + +#[tauri::command] +fn update_persona( + character_id: String, + name: Option, + description: Option, + enabled: bool, +) -> Result<(), String> { + let mut settings = load_roleplay_settings(&character_id); + settings.persona_name = name; + settings.persona_description = description; + settings.persona_enabled = enabled; + save_roleplay_settings(&character_id, &settings) +} + +#[tauri::command] +fn add_world_info_entry( + character_id: String, + keys: Vec, + content: String, + priority: i32, + case_sensitive: bool, +) -> Result { + let mut settings = load_roleplay_settings(&character_id); + + let entry = WorldInfoEntry { + id: Uuid::new_v4().to_string(), + keys, + content, + enabled: true, + case_sensitive, + priority, + }; + + settings.world_info.push(entry.clone()); + save_roleplay_settings(&character_id, &settings)?; + + Ok(entry) +} + +#[tauri::command] +fn update_world_info_entry( + character_id: String, + entry_id: String, + keys: Vec, + content: String, + enabled: bool, + priority: i32, + case_sensitive: bool, +) -> Result<(), String> { + let mut settings = load_roleplay_settings(&character_id); + + if let Some(entry) = settings.world_info.iter_mut().find(|e| e.id == entry_id) { + entry.keys = keys; + entry.content = content; + entry.enabled = enabled; + entry.priority = priority; + entry.case_sensitive = case_sensitive; + save_roleplay_settings(&character_id, &settings) + } else { + Err("World Info entry not found".to_string()) + } +} + +#[tauri::command] +fn delete_world_info_entry( + character_id: String, + entry_id: String, +) -> Result<(), String> { + let mut settings = load_roleplay_settings(&character_id); + settings.world_info.retain(|e| e.id != entry_id); + save_roleplay_settings(&character_id, &settings) +} + // Export character card to PNG #[tauri::command] async fn export_character_card(app_handle: tauri::AppHandle, character_id: String) -> Result { @@ -1597,7 +1756,13 @@ pub fn run() { import_character_card, export_character_card, export_chat_history, - import_chat_history + import_chat_history, + get_roleplay_settings, + update_authors_note, + update_persona, + add_world_info_entry, + update_world_info_entry, + delete_world_info_entry ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src/main.js b/src/main.js index 80dca5a..f0f4637 100644 --- a/src/main.js +++ b/src/main.js @@ -1062,11 +1062,14 @@ function hideSettings() { } // Show/hide roleplay panel -function showRoleplayPanel() { +async function showRoleplayPanel() { const panel = document.getElementById('roleplay-panel'); const overlay = document.getElementById('roleplay-overlay'); panel.classList.add('open'); overlay.classList.add('show'); + + // Load roleplay settings when panel opens + await loadRoleplaySettings(); } function hideRoleplayPanel() { @@ -1330,6 +1333,11 @@ function setupAppControls() { applyFontSize(parseInt(e.target.value)); }); } + + // Setup roleplay panel buttons + document.getElementById('add-worldinfo-btn').addEventListener('click', handleAddWorldInfoEntry); + document.getElementById('save-authors-note-btn').addEventListener('click', handleSaveAuthorsNote); + document.getElementById('save-persona-btn').addEventListener('click', handleSavePersona); } // Keyboard shortcuts @@ -1655,6 +1663,258 @@ async function handleSaveCharacter(e) { } } +// World Info / Roleplay Settings Management + +let currentRoleplaySettings = null; + +// Load roleplay settings for current character +async function loadRoleplaySettings() { + if (!currentCharacter) return; + + try { + const settings = await invoke('get_roleplay_settings', { characterId: currentCharacter.id }); + currentRoleplaySettings = settings; + + // Load World Info entries + renderWorldInfoList(settings.world_info || []); + + // Load Author's Note + document.getElementById('authors-note-text').value = settings.authors_note || ''; + document.getElementById('authors-note-enabled').checked = settings.authors_note_enabled || false; + + // Load Persona + document.getElementById('persona-name').value = settings.persona_name || ''; + document.getElementById('persona-description').value = settings.persona_description || ''; + document.getElementById('persona-enabled').checked = settings.persona_enabled || false; + } catch (error) { + console.error('Failed to load roleplay settings:', error); + } +} + +// Render World Info entries +function renderWorldInfoList(entries) { + const listContainer = document.getElementById('worldinfo-list'); + listContainer.innerHTML = ''; + + if (entries.length === 0) { + const emptyMsg = document.createElement('p'); + emptyMsg.style.color = 'var(--text-secondary)'; + emptyMsg.style.fontSize = '14px'; + emptyMsg.style.textAlign = 'center'; + emptyMsg.style.padding = '20px'; + emptyMsg.textContent = 'No entries yet. Click "Add Entry" to create one.'; + listContainer.appendChild(emptyMsg); + return; + } + + // Sort entries by priority (higher first) + const sortedEntries = [...entries].sort((a, b) => (b.priority || 0) - (a.priority || 0)); + + sortedEntries.forEach(entry => { + const entryDiv = document.createElement('div'); + entryDiv.className = 'worldinfo-entry'; + entryDiv.dataset.entryId = entry.id; + + const header = document.createElement('div'); + header.className = 'worldinfo-entry-header'; + + const enableCheckbox = document.createElement('input'); + enableCheckbox.type = 'checkbox'; + enableCheckbox.checked = entry.enabled; + enableCheckbox.addEventListener('change', () => handleToggleWorldInfoEntry(entry.id, enableCheckbox.checked)); + + const keysText = document.createElement('span'); + keysText.className = 'worldinfo-keys'; + keysText.textContent = entry.keys.join(', '); + + const priority = document.createElement('span'); + priority.className = 'worldinfo-priority'; + priority.textContent = `Priority: ${entry.priority || 0}`; + + const actionsDiv = document.createElement('div'); + actionsDiv.className = 'worldinfo-entry-actions'; + + const editBtn = document.createElement('button'); + editBtn.className = 'worldinfo-btn'; + editBtn.textContent = 'Edit'; + editBtn.addEventListener('click', () => handleEditWorldInfoEntry(entry)); + + const deleteBtn = document.createElement('button'); + deleteBtn.className = 'worldinfo-btn worldinfo-btn-danger'; + deleteBtn.textContent = 'Delete'; + deleteBtn.addEventListener('click', () => handleDeleteWorldInfoEntry(entry.id)); + + actionsDiv.appendChild(editBtn); + actionsDiv.appendChild(deleteBtn); + + header.appendChild(enableCheckbox); + header.appendChild(keysText); + header.appendChild(priority); + header.appendChild(actionsDiv); + + const content = document.createElement('div'); + content.className = 'worldinfo-entry-content'; + content.textContent = entry.content; + + entryDiv.appendChild(header); + entryDiv.appendChild(content); + listContainer.appendChild(entryDiv); + }); +} + +// Add new World Info entry +async function handleAddWorldInfoEntry() { + const keys = prompt('Enter keywords (comma-separated):\nExample: John, John Smith'); + if (!keys) return; + + const content = prompt('Enter the content to inject when keywords are found:'); + if (!content) return; + + const priorityStr = prompt('Enter priority (higher = injected first, default 0):', '0'); + const priority = parseInt(priorityStr) || 0; + + try { + const keysArray = keys.split(',').map(k => k.trim()).filter(k => k); + await invoke('add_world_info_entry', { + characterId: currentCharacter.id, + keys: keysArray, + content: content.trim(), + priority, + caseSensitive: false + }); + + // Reload settings + await loadRoleplaySettings(); + } catch (error) { + console.error('Failed to add World Info entry:', error); + alert(`Failed to add entry: ${error}`); + } +} + +// Edit World Info entry +async function handleEditWorldInfoEntry(entry) { + const keys = prompt('Edit keywords (comma-separated):', entry.keys.join(', ')); + if (keys === null) return; + + const content = prompt('Edit content:', entry.content); + if (content === null) return; + + const priorityStr = prompt('Edit priority:', entry.priority.toString()); + if (priorityStr === null) return; + const priority = parseInt(priorityStr) || 0; + + try { + const keysArray = keys.split(',').map(k => k.trim()).filter(k => k); + await invoke('update_world_info_entry', { + characterId: currentCharacter.id, + entryId: entry.id, + keys: keysArray, + content: content.trim(), + enabled: entry.enabled, + priority, + caseSensitive: entry.case_sensitive + }); + + // Reload settings + await loadRoleplaySettings(); + } catch (error) { + console.error('Failed to update World Info entry:', error); + alert(`Failed to update entry: ${error}`); + } +} + +// Toggle World Info entry enabled state +async function handleToggleWorldInfoEntry(entryId, enabled) { + if (!currentRoleplaySettings) return; + + const entry = currentRoleplaySettings.world_info.find(e => e.id === entryId); + if (!entry) return; + + try { + await invoke('update_world_info_entry', { + characterId: currentCharacter.id, + entryId: entryId, + keys: entry.keys, + content: entry.content, + enabled: enabled, + priority: entry.priority, + caseSensitive: entry.case_sensitive + }); + + // Update local settings + entry.enabled = enabled; + } catch (error) { + console.error('Failed to toggle World Info entry:', error); + alert(`Failed to toggle entry: ${error}`); + } +} + +// Delete World Info entry +async function handleDeleteWorldInfoEntry(entryId) { + if (!confirm('Delete this World Info entry? This cannot be undone.')) return; + + try { + await invoke('delete_world_info_entry', { + characterId: currentCharacter.id, + entryId: entryId + }); + + // Reload settings + await loadRoleplaySettings(); + } catch (error) { + console.error('Failed to delete World Info entry:', error); + alert(`Failed to delete entry: ${error}`); + } +} + +// Save Author's Note +async function handleSaveAuthorsNote() { + if (!currentCharacter) return; + + const content = document.getElementById('authors-note-text').value.trim() || null; + const enabled = document.getElementById('authors-note-enabled').checked; + + try { + await invoke('update_authors_note', { + characterId: currentCharacter.id, + content, + enabled + }); + + // Show success message + setStatus('Author\'s Note saved', 'success'); + setTimeout(() => setStatus('Ready'), 2000); + } catch (error) { + console.error('Failed to save Author\'s Note:', error); + setStatus('Failed to save Author\'s Note', 'error'); + } +} + +// Save Persona +async function handleSavePersona() { + if (!currentCharacter) return; + + const name = document.getElementById('persona-name').value.trim() || null; + const description = document.getElementById('persona-description').value.trim() || null; + const enabled = document.getElementById('persona-enabled').checked; + + try { + await invoke('update_persona', { + characterId: currentCharacter.id, + name, + description, + enabled + }); + + // Show success message + setStatus('Persona saved', 'success'); + setTimeout(() => setStatus('Ready'), 2000); + } catch (error) { + console.error('Failed to save Persona:', error); + setStatus('Failed to save Persona', 'error'); + } +} + // Load existing config if available async function loadExistingConfig() { console.log('Loading existing config...'); diff --git a/src/styles.css b/src/styles.css index 55d45f0..74ab542 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1611,6 +1611,91 @@ body.view-comfortable .message-content pre { margin-top: 12px; } +.worldinfo-entry { + background: var(--bg-tertiary); + border: 1px solid var(--border); + border-radius: 8px; + padding: 12px; + transition: all 0.2s ease; +} + +.worldinfo-entry:hover { + border-color: var(--accent); +} + +.worldinfo-entry-header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 8px; + flex-wrap: wrap; +} + +.worldinfo-entry-header input[type="checkbox"] { + width: 18px; + height: 18px; + cursor: pointer; + flex-shrink: 0; +} + +.worldinfo-keys { + flex: 1; + font-size: 13px; + font-weight: 500; + color: var(--text-primary); +} + +.worldinfo-priority { + font-size: 11px; + color: var(--text-secondary); + background: rgba(99, 102, 241, 0.1); + padding: 2px 8px; + border-radius: 4px; +} + +.worldinfo-entry-actions { + display: flex; + gap: 6px; +} + +.worldinfo-btn { + padding: 4px 12px; + font-size: 12px; + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text-primary); + cursor: pointer; + transition: all 0.2s ease; + font-family: inherit; +} + +.worldinfo-btn:hover { + background: var(--border); + border-color: var(--accent); +} + +.worldinfo-btn-danger { + background: rgba(239, 68, 68, 0.1); + border-color: rgba(239, 68, 68, 0.2); + color: #ef4444; +} + +.worldinfo-btn-danger:hover { + background: rgba(239, 68, 68, 0.2); + border-color: #ef4444; +} + +.worldinfo-entry-content { + font-size: 13px; + color: var(--text-secondary); + line-height: 1.5; + padding: 8px; + background: var(--bg-secondary); + border-radius: 6px; + word-wrap: break-word; +} + .header-left-controls { display: flex; gap: 8px;