Compare commits

...

4 Commits

Author SHA1 Message Date
9b4bc63e1a fix: use theme CSS variables for user messages
User messages were using hard-coded indigo colors instead of respecting
the selected theme. Now properly uses:
- var(--accent) for gradient start
- var(--user-msg) for gradient end
- Dynamic box-shadow based on theme color

This ensures user messages match the selected theme (Dark, Light, Abyss,
Nord, Mocha, etc.)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-16 13:26:39 -07:00
501f226542 chore: update Cargo.lock for tiktoken-rs dependency 2025-10-16 13:25:02 -07:00
2444ca0811 feat: implement token counter with real-time breakdown
Add comprehensive token counting functionality to provide visibility into
context usage:

Backend (Rust):
- Add tiktoken-rs dependency for OpenAI-compatible token counting
- Implement get_token_count command with detailed breakdown
- Count tokens for: system prompt, preset instructions, persona, world info,
  author's note, message history, and current input
- Per-section token breakdown for optimization insights

Frontend (JavaScript/HTML/CSS):
- Add token counter widget in status bar
- Real-time updates as user types (debounced 300ms)
- Expandable breakdown tooltip showing per-section counts
- Automatic update when chat history loads or changes
- Clean, minimal UI with hover interactions

Features:
- Accurate token counting using cl100k_base tokenizer
- Debounced updates for performance
- Detailed breakdown by context section
- Visual indicator with total token count
- Click to expand/collapse detailed breakdown
- Auto-hide when no character is active

This completes the "Must-Have for Basic Roleplay" features from the roadmap:
 World Info/Lorebooks
 Author's Note
 Token Counter
- Message Examples Usage (next)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-16 13:24:49 -07:00
828475ae4f 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>
2025-10-16 13:19:43 -07:00
7 changed files with 1943 additions and 140 deletions

View File

@@ -9,30 +9,37 @@
- Character Management (multiple characters)
- Character Avatars with upload and zoom
- Expanded Character Editor (all v2/v3 fields)
- Prompt Presets System (built-in and custom presets with instruction blocks)
- Editable Built-in Presets with Restore to Default
- World Info/Lorebook System (keyword detection, priority, insertion)
- Author's Note (configurable depth and positioning)
- User Personas (identity management with chat/character locking)
- Regex Scripts (global and character-scoped text transformations)
- Chat History Import/Export (JSON format)
### 🎯 Current Focus: UI/UX Improvements
**Decision:** Before adding complex roleplay features, we're focusing on polishing the existing UI/UX to establish a solid foundation. This includes better visual design, improved workflows, and enhanced user experience.
### 🎯 Current Focus: Token Counter & Context Management
**Next Up:** Implementing token counter with real-time display and per-section breakdown to provide visibility into context usage. This is a critical feature for debugging prompt issues and optimizing context allocation.
See "Phase 7: Polish & UX" section for details on UI improvements being prioritized.
**Recent Completion:** Prompt Presets System with editable built-in presets, instruction block management, and restore-to-default functionality.
## Phase 1: Core Roleplay Infrastructure (High Priority)
**Goal: Enable basic roleplay-focused prompt engineering**
### 1. World Info/Lorebook System
- [ ] Create UI for managing lorebook entries (keyword, content, priority)
- [ ] Implement keyword detection in recent messages
- [ ] Add context injection before message generation
- [ ] Support recursive entry activation
- [ ] Per-character lorebook assignment
- [ ] Import/export lorebook files
### 1. World Info/Lorebook System
- [x] Create UI for managing lorebook entries (keyword, content, priority)
- [x] Implement keyword detection in recent messages
- [x] Add context injection before message generation
- [x] Support recursive entry activation
- [x] Per-character lorebook assignment
- [x] Import/export lorebook files
**Why Important:** World Info is the foundation of consistent roleplay. It allows dynamic context injection based on what's currently relevant in the conversation, saving tokens while maintaining world consistency.
### 2. Author's Note
- [ ] Add configurable Author's Note field (inserted at depth 1-5)
- [ ] Position control (after system, before/after examples, etc.)
- [ ] Per-character Author's Note support
- [ ] Template variables in Author's Note
### 2. Author's Note
- [x] Add configurable Author's Note field (inserted at depth 1-5)
- [x] Position control (after system, before/after examples, etc.)
- [x] Per-character Author's Note support
- [x] Template variables in Author's Note
**Why Important:** Author's Note is considered better than system prompts for roleplay because it appears closer to the actual conversation, reducing AI tendency to ignore or forget instructions.
@@ -47,12 +54,12 @@ See "Phase 7: Polish & UX" section for details on UI improvements being prioriti
## Phase 2: Enhanced Character Features (High Priority)
**Goal: Better character representation and user identity**
### 1. User Personas
- [ ] Create persona management UI (name, description, avatar)
- [ ] Chat-level persona locking
- [ ] Character-level persona locking
- [ ] Default persona setting
- [ ] Quick persona switching
### 1. User Personas
- [x] Create persona management UI (name, description, avatar)
- [x] Chat-level persona locking
- [x] Character-level persona locking
- [x] Default persona setting
- [x] Quick persona switching
**Why Important:** Allows users to have multiple identities for different roleplay scenarios without manually changing their name and description each time.
@@ -190,13 +197,13 @@ See "Phase 7: Polish & UX" section for details on UI improvements being prioriti
**Why Important:** Makes prompts and messages dynamic and reusable across different scenarios.
### 3. Regex Scripts
- [ ] Global and character-scoped scripts
- [ ] Text transformation on messages
- [ ] Auto-markdown formatting
- [ ] Import/export regex presets
- [ ] Regex testing interface
- [ ] Script priority/ordering
### 3. Regex Scripts
- [x] Global and character-scoped scripts
- [x] Text transformation on messages
- [x] Auto-markdown formatting
- [x] Import/export regex presets
- [x] Regex testing interface
- [x] Script priority/ordering
**Why Important:** Allows automatic text formatting, correction, and enhancement without manual intervention.
@@ -224,8 +231,8 @@ See "Phase 7: Polish & UX" section for details on UI improvements being prioriti
### 2. Export/Import Improvements
- [ ] Export chats as markdown
- [ ] Export chats as formatted text
- [ ] Export chats as JSON with metadata
- [ ] Import chats from other formats
- [x] Export chats as JSON with metadata
- [x] Import chats from other formats
- [ ] Bulk character import
- [ ] Character pack support (multiple characters + lorebooks)
@@ -344,4 +351,4 @@ This roadmap is based on research into SillyTavern's features and best practices
---
Last updated: 2025-10-14
Last updated: 2025-10-16

61
src-tauri/Cargo.lock generated
View File

@@ -270,6 +270,21 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bit-set"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1"
dependencies = [
"bit-vec",
]
[[package]]
name = "bit-vec"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb"
[[package]]
name = "bit_field"
version = "0.10.3"
@@ -352,6 +367,17 @@ dependencies = [
"alloc-stdlib",
]
[[package]]
name = "bstr"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "234113d19d0d7d613b40e86fb654acf958910802bcceab913a4f9e7cda03b1a4"
dependencies = [
"memchr",
"regex-automata",
"serde",
]
[[package]]
name = "bumpalo"
version = "3.19.0"
@@ -502,8 +528,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2"
dependencies = [
"iana-time-zone",
"js-sys",
"num-traits",
"serde",
"wasm-bindgen",
"windows-link 0.2.1",
]
@@ -1020,6 +1048,16 @@ dependencies = [
"zune-inflate",
]
[[package]]
name = "fancy-regex"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7493d4c459da9f84325ad297371a6b2b8a162800873a22e3b6b6512e61d18c05"
dependencies = [
"bit-set",
"regex",
]
[[package]]
name = "fastrand"
version = "2.3.0"
@@ -3459,6 +3497,12 @@ version = "0.1.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace"
[[package]]
name = "rustc-hash"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
[[package]]
name = "rustc_version"
version = "0.4.1"
@@ -4194,6 +4238,7 @@ version = "0.1.0"
dependencies = [
"base64 0.21.7",
"bytes",
"chrono",
"futures",
"image",
"png",
@@ -4205,6 +4250,7 @@ dependencies = [
"tauri-build",
"tauri-plugin-dialog",
"tauri-plugin-opener",
"tiktoken-rs",
"tokio",
"uuid",
]
@@ -4526,6 +4572,21 @@ dependencies = [
"weezl",
]
[[package]]
name = "tiktoken-rs"
version = "0.5.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c314e7ce51440f9e8f5a497394682a57b7c323d0f4d0a6b1b13c429056e0e234"
dependencies = [
"anyhow",
"base64 0.21.7",
"bstr",
"fancy-regex",
"lazy_static",
"parking_lot",
"rustc-hash",
]
[[package]]
name = "time"
version = "0.3.44"

View File

@@ -32,4 +32,6 @@ png = "0.17"
base64 = "0.21"
image = "0.24"
regex = "1"
chrono = "0.4"
tiktoken-rs = "0.5"

File diff suppressed because it is too large Load Diff

View File

@@ -93,6 +93,7 @@
<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="persona">Persona</button>
<button class="roleplay-tab-btn" data-tab="presets">Prompt Preset</button>
</div>
<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;">
Create entries that inject context when keywords are mentioned.
</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%;">
+ Add Entry
</button>
@@ -124,6 +137,15 @@
placeholder="Write in present tense. Focus on sensory details..."
rows="6"
></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 class="form-group">
<label>
@@ -169,6 +191,85 @@
</button>
</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>
<!-- Settings overlay backdrop -->
@@ -536,6 +637,53 @@
</form>
<div class="status-bar">
<span id="status-text" class="status-text">Ready</span>
<div id="token-counter" class="token-counter" style="display: none;">
<span id="token-count-total" class="token-count">0 tokens</span>
<button id="token-details-btn" class="token-details-btn" title="Show breakdown">
<svg width="12" height="12" viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="6" stroke="currentColor" stroke-width="1.5"/>
<path d="M8 7v4M8 5h.01" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</button>
</div>
</div>
<!-- Token Breakdown Tooltip -->
<div id="token-breakdown" class="token-breakdown" style="display: none;">
<div class="token-breakdown-header">Token Breakdown</div>
<div class="token-breakdown-list">
<div class="token-breakdown-item">
<span>System Prompt:</span>
<span id="token-system">0</span>
</div>
<div class="token-breakdown-item">
<span>Preset Instructions:</span>
<span id="token-preset">0</span>
</div>
<div class="token-breakdown-item">
<span>Persona:</span>
<span id="token-persona">0</span>
</div>
<div class="token-breakdown-item">
<span>World Info:</span>
<span id="token-worldinfo">0</span>
</div>
<div class="token-breakdown-item">
<span>Author's Note:</span>
<span id="token-authorsnote">0</span>
</div>
<div class="token-breakdown-item">
<span>Message History:</span>
<span id="token-history">0</span>
</div>
<div class="token-breakdown-item">
<span>Current Input:</span>
<span id="token-input">0</span>
</div>
<div class="token-breakdown-total">
<span>Total:</span>
<span id="token-total-detail">0</span>
</div>
</div>
</div>
</footer>
</div>

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
@@ -1351,9 +1366,67 @@ function setupKeyboardShortcuts() {
messageInput.addEventListener('input', () => {
autoResize(messageInput);
updateTokenCount();
});
}
// Token Counter
let tokenUpdateTimeout = null;
async function updateTokenCount() {
// Debounce token count updates
if (tokenUpdateTimeout) {
clearTimeout(tokenUpdateTimeout);
}
tokenUpdateTimeout = setTimeout(async () => {
try {
const currentInput = messageInput.value;
const tokenData = await invoke('get_token_count', {
characterId: null, // Use active character
currentInput
});
// Update total display
const tokenCounter = document.getElementById('token-counter');
const tokenCountTotal = document.getElementById('token-count-total');
tokenCountTotal.textContent = `${tokenData.total} tokens`;
tokenCounter.style.display = 'flex';
// Update breakdown
document.getElementById('token-system').textContent = tokenData.system_prompt;
document.getElementById('token-preset').textContent = tokenData.preset_instructions;
document.getElementById('token-persona').textContent = tokenData.persona;
document.getElementById('token-worldinfo').textContent = tokenData.world_info;
document.getElementById('token-authorsnote').textContent = tokenData.authors_note;
document.getElementById('token-history').textContent = tokenData.message_history;
document.getElementById('token-input').textContent = tokenData.current_input;
document.getElementById('token-total-detail').textContent = tokenData.total;
} catch (error) {
console.error('Failed to update token count:', error);
// Hide token counter on error
document.getElementById('token-counter').style.display = 'none';
}
}, 300); // Update after 300ms of no typing
}
// Toggle token breakdown display
document.getElementById('token-details-btn').addEventListener('click', (e) => {
e.stopPropagation();
const breakdown = document.getElementById('token-breakdown');
breakdown.style.display = breakdown.style.display === 'none' ? 'block' : 'none';
});
// Close breakdown when clicking outside
document.addEventListener('click', (e) => {
const breakdown = document.getElementById('token-breakdown');
const detailsBtn = document.getElementById('token-details-btn');
if (!breakdown.contains(e.target) && !detailsBtn.contains(e.target)) {
breakdown.style.display = 'none';
}
});
// Load characters and populate dropdown
async function loadCharacters() {
console.log('Loading characters...');
@@ -1520,6 +1593,9 @@ async function loadChatHistory() {
messagesContainer.innerHTML = '';
addMessage('API configured. Ready to chat.', false, true);
}
// Update token count after loading history
updateTokenCount();
}
// Clear chat history
@@ -1678,6 +1754,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 +1765,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 +1997,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...');

View File

@@ -276,10 +276,10 @@ body {
}
.message.user .message-content {
background: linear-gradient(135deg, #6366f1 0%, #4f46e5 100%);
background: linear-gradient(135deg, var(--accent) 0%, var(--user-msg) 100%);
color: white;
border-bottom-right-radius: 4px;
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.3);
box-shadow: 0 2px 8px color-mix(in srgb, var(--user-msg) 30%, transparent);
}
.message.assistant .message-content {
@@ -761,6 +761,97 @@ body {
color: #22c55e;
}
/* Token Counter */
.token-counter {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--text-secondary);
}
.token-count {
padding: 4px 8px;
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 6px;
}
.token-details-btn {
width: 20px;
height: 20px;
border: none;
background: transparent;
color: var(--text-secondary);
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
padding: 0;
}
.token-details-btn:hover {
background: var(--bg-tertiary);
color: var(--accent);
}
.token-breakdown {
position: absolute;
bottom: 52px;
right: 16px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
z-index: 100;
min-width: 250px;
}
.token-breakdown-header {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 8px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border);
}
.token-breakdown-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.token-breakdown-item {
display: flex;
justify-content: space-between;
font-size: 12px;
color: var(--text-secondary);
}
.token-breakdown-item span:first-child {
color: var(--text-secondary);
}
.token-breakdown-item span:last-child {
color: var(--text-primary);
font-weight: 500;
}
.token-breakdown-total {
display: flex;
justify-content: space-between;
font-size: 13px;
font-weight: 600;
color: var(--accent);
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--border);
}
@keyframes pulse {
0%, 100% {
opacity: 1;