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:
2025-10-16 13:19:43 -07:00
parent cdb7baa197
commit 828475ae4f
4 changed files with 1548 additions and 138 deletions

View File

@@ -1338,6 +1338,21 @@ function setupAppControls() {
document.getElementById('add-worldinfo-btn').addEventListener('click', handleAddWorldInfoEntry);
document.getElementById('save-authors-note-btn').addEventListener('click', handleSaveAuthorsNote);
document.getElementById('save-persona-btn').addEventListener('click', handleSavePersona);
// Setup recursion depth change handler
document.getElementById('recursion-depth').addEventListener('change', handleRecursionDepthChange);
// Setup preset controls
document.getElementById('preset-select').addEventListener('change', (e) => {
handlePresetSelect(e.target.value);
});
document.getElementById('apply-preset-btn').addEventListener('click', handleApplyPreset);
document.getElementById('create-preset-btn').addEventListener('click', handleCreatePreset);
document.getElementById('add-instruction-btn').addEventListener('click', addInstructionBlock);
document.getElementById('save-preset-changes-btn').addEventListener('click', savePresetChanges);
document.getElementById('delete-preset-btn').addEventListener('click', deletePreset);
document.getElementById('duplicate-preset-btn').addEventListener('click', duplicatePreset);
document.getElementById('restore-preset-btn').addEventListener('click', restoreBuiltinPreset);
}
// Keyboard shortcuts
@@ -1678,6 +1693,9 @@ async function loadRoleplaySettings() {
// Load World Info entries
renderWorldInfoList(settings.world_info || []);
// Load World Info recursion depth
document.getElementById('recursion-depth').value = settings.recursion_depth || 3;
// Load Author's Note
document.getElementById('authors-note-text').value = settings.authors_note || '';
document.getElementById('authors-note-enabled').checked = settings.authors_note_enabled || false;
@@ -1686,6 +1704,9 @@ async function loadRoleplaySettings() {
document.getElementById('persona-name').value = settings.persona_name || '';
document.getElementById('persona-description').value = settings.persona_description || '';
document.getElementById('persona-enabled').checked = settings.persona_enabled || false;
// Load Presets
await loadPresets();
} catch (error) {
console.error('Failed to load roleplay settings:', error);
}
@@ -1915,6 +1936,716 @@ async function handleSavePersona() {
}
}
// Handle recursion depth change
async function handleRecursionDepthChange() {
if (!currentCharacter) return;
const depth = parseInt(document.getElementById('recursion-depth').value) || 3;
try {
await invoke('update_recursion_depth', {
characterId: currentCharacter.id,
depth
});
console.log('Recursion depth updated to:', depth);
} catch (error) {
console.error('Failed to update recursion depth:', error);
setStatus('Failed to save recursion depth', 'error');
setTimeout(() => setStatus('Ready'), 2000);
}
}
// Prompt Preset Management
// Load available presets
async function loadPresets() {
try {
const presets = await invoke('get_presets');
const presetSelect = document.getElementById('preset-select');
// Clear existing options except "No Preset"
presetSelect.innerHTML = '<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
async function loadExistingConfig() {
console.log('Loading existing config...');