feat: add World Info/Lorebook, Author's Note, and Persona systems
Backend changes:
- Add RoleplaySettings and WorldInfoEntry data structures
- Implement per-character roleplay settings storage in ~/.config/claudia/roleplay_{id}.json
- Add Tauri commands for CRUD operations on World Info entries
- Add commands for saving Author's Note and Persona settings
Frontend changes:
- Add World Info entry management UI with add/edit/delete functionality
- Implement keyword-triggered context injection system with priority ordering
- Add Author's Note textarea with enable toggle
- Add Persona name and description fields with enable toggle
- Load roleplay settings when opening the roleplay panel
- Add CSS styles for World Info entry cards with hover effects
Features:
- World Info entries support multiple keywords, priority levels, and enable/disable
- Settings are per-character and persist across sessions
- Entries sorted by priority (higher priority injected first)
- Clean UI with edit/delete buttons and visual feedback
This commit is contained in:
@@ -241,6 +241,49 @@ impl Message {
|
||||
}
|
||||
}
|
||||
|
||||
// World Info / Lorebook Entry
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct WorldInfoEntry {
|
||||
id: String,
|
||||
keys: Vec<String>, // 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<String>,
|
||||
#[serde(default)]
|
||||
authors_note_enabled: bool,
|
||||
#[serde(default)]
|
||||
persona_name: Option<String>,
|
||||
#[serde(default)]
|
||||
persona_description: Option<String>,
|
||||
#[serde(default)]
|
||||
persona_enabled: bool,
|
||||
#[serde(default)]
|
||||
world_info: Vec<WorldInfoEntry>,
|
||||
}
|
||||
|
||||
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<Message>,
|
||||
@@ -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<usize, Stri
|
||||
Ok(message_count)
|
||||
}
|
||||
|
||||
// Roleplay Settings Commands
|
||||
|
||||
#[tauri::command]
|
||||
fn get_roleplay_settings(character_id: String) -> Result<RoleplaySettings, String> {
|
||||
Ok(load_roleplay_settings(&character_id))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn update_authors_note(
|
||||
character_id: String,
|
||||
content: Option<String>,
|
||||
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<String>,
|
||||
description: Option<String>,
|
||||
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<String>,
|
||||
content: String,
|
||||
priority: i32,
|
||||
case_sensitive: bool,
|
||||
) -> Result<WorldInfoEntry, String> {
|
||||
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<String>,
|
||||
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<String, String> {
|
||||
@@ -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");
|
||||
|
||||
262
src/main.js
262
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...');
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user