Compare commits

...

7 Commits

Author SHA1 Message Date
86a9d54e70 feat: replace character creation prompts with themed modal
Replace native prompt() dialogs with a themed inline form modal for character creation:
- Modal displays with name input and system prompt textarea
- Includes Cancel and Create buttons
- Escape key closes the modal
- Auto-focuses name input when opened
- Proper event listener cleanup
- Matches existing UI theme and styling

This provides a much better user experience with the application's dark theme, compared to the browser's default prompt dialogs which looked out of place.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-16 22:28:42 -07:00
600b50f239 feat: add active features indicator badges in header
Add visual badges in header showing which roleplay features are currently active:
- World Info: Shows count of enabled entries (e.g., "WI: 3")
- Persona: Shows persona name when enabled
- Preset: Shows active preset name
- Examples: Shows when message examples are enabled
- Author's Note: Shows when author's note is enabled (displays as "A/N")

Badges update dynamically when features are toggled, providing at-a-glance visibility of active roleplay features without opening the Roleplay Tools panel.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-16 22:26:39 -07:00
a7c9657ff1 feat: make context limit configurable in API settings
Add context limit field to API settings that:
- Stores context limit in config (defaults to 200000)
- Allows users to set custom limits for different models
- Uses configured limit in token counter display
- Shows format like "2.5k / 200k tokens" using actual limit

This allows proper token tracking for non-Claude models with
different context windows.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-16 22:20:13 -07:00
e47bd3bf87 refactor: remove color coding from token counter
Keep simple clean format: '2.5k / 200k tokens' without color changes
2025-10-16 22:16:25 -07:00
41437e1751 feat: make token counter visible by default with color coding
Changes:
- Remove display:none from token counter (always visible)
- Add compact formatting (2.5k / 200k tokens instead of 2500 tokens)
- Color coding based on usage percentage:
  - Green: < 50% usage
  - Yellow: 50-80% usage
  - Red: > 80% usage
- Keep counter visible even on error (shows 0 / 200k)
- Improve discoverability of token tracking feature

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-16 22:13:15 -07:00
8c70e0558f feat: add keyboard shortcuts and copy message buttons
Keyboard shortcuts:
- Up Arrow: Edit last user message (when input at start)
- Left/Right Arrow: Navigate swipes (when not in input)
- Escape: Close panels/modals, cancel editing
- Ctrl+K: Focus message input
- Ctrl+/: Toggle Roleplay Tools
- Ctrl+Enter: Send message (alternative)

Message controls:
- Add Copy Message button for both user and assistant messages
- Visual feedback (checkmark) after copying
- 2-second feedback duration

Updated README to reflect actual implemented shortcuts.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-16 22:12:14 -07:00
0bd1590681 feat: replace preset prompts with inline forms
Replace popup dialogs with themed inline forms for preset creation and
duplication. Both operations now use consistent UI styling with proper
theme colors, validation, and cancel/save workflows.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-16 22:03:37 -07:00
6 changed files with 4311 additions and 79 deletions

3631
CELIA 3.8.json Normal file

File diff suppressed because one or more lines are too long

View File

@@ -57,8 +57,12 @@ Config stored in `~/.config/claudia/config.json`
- **Enter** - Send message - **Enter** - Send message
- **Shift+Enter** - New line - **Shift+Enter** - New line
- **Up Arrow** - Edit last user message - **Ctrl+Enter** - Send message (alternative)
- **Left/Right Arrow** - Swipe between responses - **Up Arrow** - Edit last user message (when input is at start)
- **Left/Right Arrow** - Navigate between response alternatives
- **Escape** - Close panels/modals, cancel editing
- **Ctrl+K** - Focus message input
- **Ctrl+/** - Toggle Roleplay Tools panel
## Roadmap ## Roadmap

View File

@@ -20,6 +20,12 @@ struct ApiConfig {
active_character_id: Option<String>, active_character_id: Option<String>,
#[serde(default)] #[serde(default)]
stream: bool, stream: bool,
#[serde(default = "default_context_limit")]
context_limit: u32,
}
fn default_context_limit() -> u32 {
200000
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -1161,7 +1167,7 @@ async fn validate_api(base_url: String, api_key: String) -> Result<Vec<String>,
} }
#[tauri::command] #[tauri::command]
async fn save_api_config(base_url: String, api_key: String, model: String, stream: bool) -> Result<(), String> { async fn save_api_config(base_url: String, api_key: String, model: String, stream: bool, context_limit: u32) -> Result<(), String> {
// Preserve existing active_character_id if it exists // Preserve existing active_character_id if it exists
let active_character_id = load_config().and_then(|c| c.active_character_id); let active_character_id = load_config().and_then(|c| c.active_character_id);
@@ -1171,6 +1177,7 @@ async fn save_api_config(base_url: String, api_key: String, model: String, strea
model, model,
active_character_id, active_character_id,
stream, stream,
context_limit,
}; };
save_config(&config) save_config(&config)
} }

View File

@@ -26,6 +26,9 @@
<div class="avatar-circle"></div> <div class="avatar-circle"></div>
<span id="character-header-name"></span> <span id="character-header-name"></span>
</div> </div>
<div id="feature-badges" class="feature-badges">
<!-- Feature badges will be added here dynamically -->
</div>
<div class="character-controls"> <div class="character-controls">
<div class="select-wrapper"> <div class="select-wrapper">
<select id="character-select" class="character-select"></select> <select id="character-select" class="character-select"></select>
@@ -350,6 +353,19 @@
</select> </select>
</div> </div>
<div class="form-group">
<label for="context-limit">Context Limit (tokens)</label>
<input
type="number"
id="context-limit"
placeholder="200000"
value="200000"
min="1000"
step="1000"
/>
<small style="color: var(--text-secondary); margin-top: 4px; display: block;">Maximum tokens for model context (e.g., 200000 for Claude)</small>
</div>
<div class="form-group"> <div class="form-group">
<label> <label>
<input type="checkbox" id="stream-toggle" /> <input type="checkbox" id="stream-toggle" />
@@ -662,7 +678,7 @@
</form> </form>
<div class="status-bar"> <div class="status-bar">
<span id="status-text" class="status-text">Ready</span> <span id="status-text" class="status-text">Ready</span>
<div id="token-counter" class="token-counter" style="display: none;"> <div id="token-counter" class="token-counter">
<span id="token-count-total" class="token-count">0 tokens</span> <span id="token-count-total" class="token-count">0 tokens</span>
<button id="token-details-btn" class="token-details-btn" title="Show breakdown"> <button id="token-details-btn" class="token-details-btn" title="Show breakdown">
<svg width="12" height="12" viewBox="0 0 16 16" fill="none"> <svg width="12" height="12" viewBox="0 0 16 16" fill="none">
@@ -724,5 +740,50 @@
<img id="avatar-modal-img" src="" alt="Avatar" /> <img id="avatar-modal-img" src="" alt="Avatar" />
</div> </div>
</div> </div>
<!-- New Character modal -->
<div id="new-character-modal" class="new-character-modal" style="display: none;">
<div class="new-character-overlay"></div>
<div class="new-character-content">
<div class="new-character-header">
<h3>Create New Character</h3>
<button id="close-new-character-btn" class="icon-btn">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<line x1="4" y1="4" x2="12" y2="12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<line x1="12" y1="4" x2="4" y2="12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</button>
</div>
<form id="new-character-form">
<div class="form-group">
<label for="new-character-name">Character Name</label>
<input
type="text"
id="new-character-name"
placeholder="Enter a name for the new character"
required
autofocus
/>
</div>
<div class="form-group">
<label for="new-character-system-prompt">System Prompt</label>
<textarea
id="new-character-system-prompt"
placeholder="You are a helpful AI assistant..."
rows="6"
required
></textarea>
</div>
<div class="new-character-actions">
<button type="button" id="cancel-new-character-btn" class="btn-secondary">
Cancel
</button>
<button type="submit" class="btn-primary">
Create
</button>
</div>
</form>
</div>
</div>
</body> </body>
</html> </html>

View File

@@ -481,6 +481,27 @@ function addMessage(content, isUser = false, skipActions = false, timestamp = nu
editBtn.addEventListener('click', () => handleEditMessage(messageDiv, content)); editBtn.addEventListener('click', () => handleEditMessage(messageDiv, content));
actionsDiv.appendChild(editBtn); actionsDiv.appendChild(editBtn);
// Copy message button
const copyBtn = document.createElement('button');
copyBtn.className = 'message-action-btn';
copyBtn.innerHTML = `<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<rect x="3" y="3" width="7" height="7" rx="1" stroke="currentColor" stroke-width="1.5" fill="none"/>
<path d="M5 3V2C5 1.44772 5.44772 1 6 1H12C12.5523 1 13 1.44772 13 2V8C13 8.55228 12.5523 9 12 9H11" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>`;
copyBtn.title = 'Copy message';
copyBtn.addEventListener('click', () => {
navigator.clipboard.writeText(content);
// Visual feedback
const originalHTML = copyBtn.innerHTML;
copyBtn.innerHTML = `<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M2 7l3 3 7-7" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>`;
setTimeout(() => {
copyBtn.innerHTML = originalHTML;
}, 2000);
});
actionsDiv.appendChild(copyBtn);
// Pin button // Pin button
const pinBtn = document.createElement('button'); const pinBtn = document.createElement('button');
pinBtn.className = 'message-action-btn message-pin-btn'; pinBtn.className = 'message-action-btn message-pin-btn';
@@ -557,6 +578,27 @@ function addMessage(content, isUser = false, skipActions = false, timestamp = nu
hideBtn.addEventListener('click', () => handleToggleHidden(messageDiv)); hideBtn.addEventListener('click', () => handleToggleHidden(messageDiv));
actionsDiv.appendChild(hideBtn); actionsDiv.appendChild(hideBtn);
// Copy message button
const copyMsgBtn = document.createElement('button');
copyMsgBtn.className = 'message-action-btn';
copyMsgBtn.innerHTML = `<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<rect x="3" y="3" width="7" height="7" rx="1" stroke="currentColor" stroke-width="1.5" fill="none"/>
<path d="M5 3V2C5 1.44772 5.44772 1 6 1H12C12.5523 1 13 1.44772 13 2V8C13 8.55228 12.5523 9 12 9H11" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>`;
copyMsgBtn.title = 'Copy message';
copyMsgBtn.addEventListener('click', () => {
navigator.clipboard.writeText(content);
// Visual feedback
const originalHTML = copyMsgBtn.innerHTML;
copyMsgBtn.innerHTML = `<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M2 7l3 3 7-7" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>`;
setTimeout(() => {
copyMsgBtn.innerHTML = originalHTML;
}, 2000);
});
actionsDiv.appendChild(copyMsgBtn);
// Delete button // Delete button
const deleteBtn = document.createElement('button'); const deleteBtn = document.createElement('button');
deleteBtn.className = 'message-action-btn message-delete-btn'; deleteBtn.className = 'message-action-btn message-delete-btn';
@@ -1415,6 +1457,7 @@ async function handleSaveSettings(e) {
const apiKey = document.getElementById('api-key').value.trim(); const apiKey = document.getElementById('api-key').value.trim();
const model = document.getElementById('model-select').value; const model = document.getElementById('model-select').value;
const stream = document.getElementById('stream-toggle').checked; const stream = document.getElementById('stream-toggle').checked;
const contextLimit = parseInt(document.getElementById('context-limit').value) || 200000;
const saveBtn = document.getElementById('save-settings-btn'); const saveBtn = document.getElementById('save-settings-btn');
const validationMsg = document.getElementById('validation-message'); const validationMsg = document.getElementById('validation-message');
@@ -1430,7 +1473,7 @@ async function handleSaveSettings(e) {
setStatus('Saving configuration...', 'default'); setStatus('Saving configuration...', 'default');
try { try {
await invoke('save_api_config', { baseUrl, apiKey, model, stream }); await invoke('save_api_config', { baseUrl, apiKey, model, stream, contextLimit });
validationMsg.textContent = 'Configuration saved successfully'; validationMsg.textContent = 'Configuration saved successfully';
validationMsg.className = 'validation-message success'; validationMsg.className = 'validation-message success';
setStatus('Configuration saved', 'success'); setStatus('Configuration saved', 'success');
@@ -1597,6 +1640,14 @@ function setupKeyboardShortcuts() {
// Token Counter // Token Counter
let tokenUpdateTimeout = null; let tokenUpdateTimeout = null;
// Helper function to format token counts
function formatTokenCount(count) {
if (count >= 1000) {
return (count / 1000).toFixed(1) + 'k';
}
return count.toString();
}
async function updateTokenCount() { async function updateTokenCount() {
// Debounce token count updates // Debounce token count updates
if (tokenUpdateTimeout) { if (tokenUpdateTimeout) {
@@ -1611,11 +1662,21 @@ async function updateTokenCount() {
currentInput currentInput
}); });
// Get context limit from config
let contextLimit = 200000; // Default
try {
const config = await invoke('get_api_config');
contextLimit = config.context_limit || 200000;
} catch (e) {
// Use default if config not available
}
// Update total display // Update total display
const tokenCounter = document.getElementById('token-counter'); const tokenCounter = document.getElementById('token-counter');
const tokenCountTotal = document.getElementById('token-count-total'); const tokenCountTotal = document.getElementById('token-count-total');
tokenCountTotal.textContent = `${tokenData.total} tokens`;
tokenCounter.style.display = 'flex'; // Format: "2.5k / 200k tokens"
tokenCountTotal.textContent = `${formatTokenCount(tokenData.total)} / ${formatTokenCount(contextLimit)} tokens`;
// Update breakdown // Update breakdown
document.getElementById('token-system').textContent = tokenData.system_prompt; document.getElementById('token-system').textContent = tokenData.system_prompt;
@@ -1629,8 +1690,9 @@ async function updateTokenCount() {
document.getElementById('token-total-detail').textContent = tokenData.total; document.getElementById('token-total-detail').textContent = tokenData.total;
} catch (error) { } catch (error) {
console.error('Failed to update token count:', error); console.error('Failed to update token count:', error);
// Hide token counter on error // Keep counter visible, just show 0
document.getElementById('token-counter').style.display = 'none'; const tokenCountTotal = document.getElementById('token-count-total');
tokenCountTotal.textContent = '0 / 200k tokens';
} }
}, 300); // Update after 300ms of no typing }, 300); // Update after 300ms of no typing
} }
@@ -1711,20 +1773,66 @@ async function handleCharacterSwitch() {
// Handle new character creation // Handle new character creation
async function handleNewCharacter() { async function handleNewCharacter() {
const name = prompt('Enter a name for the new character:'); const modal = document.getElementById('new-character-modal');
if (!name) return; const overlay = modal.querySelector('.new-character-overlay');
const form = document.getElementById('new-character-form');
const nameInput = document.getElementById('new-character-name');
const systemPromptInput = document.getElementById('new-character-system-prompt');
const closeBtn = document.getElementById('close-new-character-btn');
const cancelBtn = document.getElementById('cancel-new-character-btn');
const systemPrompt = prompt('Enter the system prompt for the new character:', 'You are a helpful AI assistant.'); // Reset form
if (!systemPrompt) return; form.reset();
systemPromptInput.value = 'You are a helpful AI assistant.';
try { // Show modal
const newCharacter = await invoke('create_character', { name, systemPrompt }); modal.style.display = 'flex';
await loadCharacters();
characterSelect.value = newCharacter.id; // Focus name input after a brief delay to ensure it's visible
} catch (error) { setTimeout(() => nameInput.focus(), 100);
console.error('Failed to create character:', error);
addMessage(`Failed to create character: ${error}`, false); // Handle form submission
} const handleSubmit = async (e) => {
e.preventDefault();
const name = nameInput.value.trim();
const systemPrompt = systemPromptInput.value.trim();
if (!name || !systemPrompt) return;
try {
const newCharacter = await invoke('create_character', { name, systemPrompt });
await loadCharacters();
characterSelect.value = newCharacter.id;
// Hide modal
modal.style.display = 'none';
// Remove event listeners
form.removeEventListener('submit', handleSubmit);
overlay.removeEventListener('click', handleClose);
closeBtn.removeEventListener('click', handleClose);
cancelBtn.removeEventListener('click', handleClose);
} catch (error) {
console.error('Failed to create character:', error);
addMessage(`Failed to create character: ${error}`, false);
}
};
// Handle close
const handleClose = () => {
modal.style.display = 'none';
form.removeEventListener('submit', handleSubmit);
overlay.removeEventListener('click', handleClose);
closeBtn.removeEventListener('click', handleClose);
cancelBtn.removeEventListener('click', handleClose);
};
// Attach event listeners
form.addEventListener('submit', handleSubmit);
overlay.addEventListener('click', handleClose);
closeBtn.addEventListener('click', handleClose);
cancelBtn.addEventListener('click', handleClose);
} }
// Handle character deletion // Handle character deletion
@@ -2020,11 +2128,101 @@ async function loadRoleplaySettings() {
// Load Presets // Load Presets
await loadPresets(); await loadPresets();
// Update feature badges
updateFeatureBadges();
} catch (error) { } catch (error) {
console.error('Failed to load roleplay settings:', error); console.error('Failed to load roleplay settings:', error);
} }
} }
// Update feature badges in header
function updateFeatureBadges() {
const badgesContainer = document.getElementById('feature-badges');
if (!badgesContainer || !currentRoleplaySettings) {
if (badgesContainer) badgesContainer.innerHTML = '';
return;
}
badgesContainer.innerHTML = '';
// World Info badge - show count of enabled entries
const worldInfoEntries = (currentRoleplaySettings.world_info || []).filter(entry => entry.enabled);
if (worldInfoEntries.length > 0) {
const badge = document.createElement('div');
badge.className = 'feature-badge';
badge.title = `${worldInfoEntries.length} World Info ${worldInfoEntries.length === 1 ? 'entry' : 'entries'} active`;
badge.innerHTML = `
<svg class="feature-badge-icon" viewBox="0 0 16 16" fill="none">
<path d="M2 4h12M2 8h12M2 12h8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<span>WI: <span class="feature-badge-count">${worldInfoEntries.length}</span></span>
`;
badgesContainer.appendChild(badge);
}
// Persona badge
if (currentRoleplaySettings.persona_enabled && currentRoleplaySettings.persona_name) {
const badge = document.createElement('div');
badge.className = 'feature-badge';
badge.title = `Persona: ${currentRoleplaySettings.persona_name}`;
badge.innerHTML = `
<svg class="feature-badge-icon" viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="5" r="2.5" stroke="currentColor" stroke-width="1.5"/>
<path d="M3 13c0-2.5 2-4 5-4s5 1.5 5 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<span>${currentRoleplaySettings.persona_name}</span>
`;
badgesContainer.appendChild(badge);
}
// Preset badge
const presetSelect = document.getElementById('preset-select');
if (presetSelect && presetSelect.value) {
const presetName = presetSelect.options[presetSelect.selectedIndex]?.text || presetSelect.value;
const badge = document.createElement('div');
badge.className = 'feature-badge';
badge.title = `Active Preset: ${presetName}`;
badge.innerHTML = `
<svg class="feature-badge-icon" viewBox="0 0 16 16" fill="none">
<rect x="3" y="3" width="10" height="10" rx="1" stroke="currentColor" stroke-width="1.5"/>
<path d="M6 7h4M6 9h4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<span>${presetName}</span>
`;
badgesContainer.appendChild(badge);
}
// Message Examples badge
if (currentRoleplaySettings.examples_enabled) {
const badge = document.createElement('div');
badge.className = 'feature-badge';
badge.title = 'Message Examples enabled';
badge.innerHTML = `
<svg class="feature-badge-icon" viewBox="0 0 16 16" fill="none">
<path d="M3 6l2 2-2 2M7 10h5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<span>Examples</span>
`;
badgesContainer.appendChild(badge);
}
// Author's Note badge
if (currentRoleplaySettings.authors_note_enabled && currentRoleplaySettings.authors_note) {
const badge = document.createElement('div');
badge.className = 'feature-badge';
badge.title = "Author's Note enabled";
badge.innerHTML = `
<svg class="feature-badge-icon" viewBox="0 0 16 16" fill="none">
<path d="M3 3h10v10H3z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/>
<path d="M6 6h4M6 9h3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<span>A/N</span>
`;
badgesContainer.appendChild(badge);
}
}
// Render World Info entries // Render World Info entries
function renderWorldInfoList(entries) { function renderWorldInfoList(entries) {
const listContainer = document.getElementById('worldinfo-list'); const listContainer = document.getElementById('worldinfo-list');
@@ -2272,6 +2470,9 @@ async function handleToggleWorldInfoEntry(entryId, enabled) {
// Update local settings // Update local settings
entry.enabled = enabled; entry.enabled = enabled;
// Update feature badges
updateFeatureBadges();
} catch (error) { } catch (error) {
console.error('Failed to toggle World Info entry:', error); console.error('Failed to toggle World Info entry:', error);
alert(`Failed to toggle entry: ${error}`); alert(`Failed to toggle entry: ${error}`);
@@ -2310,6 +2511,15 @@ async function handleSaveAuthorsNote() {
enabled enabled
}); });
// Update currentRoleplaySettings
if (currentRoleplaySettings) {
currentRoleplaySettings.authors_note = content;
currentRoleplaySettings.authors_note_enabled = enabled;
}
// Update feature badges
updateFeatureBadges();
// Show success message // Show success message
setStatus('Author\'s Note saved', 'success'); setStatus('Author\'s Note saved', 'success');
setTimeout(() => setStatus('Ready'), 2000); setTimeout(() => setStatus('Ready'), 2000);
@@ -2335,6 +2545,16 @@ async function handleSavePersona() {
enabled enabled
}); });
// Update currentRoleplaySettings
if (currentRoleplaySettings) {
currentRoleplaySettings.persona_name = name;
currentRoleplaySettings.persona_description = description;
currentRoleplaySettings.persona_enabled = enabled;
}
// Update feature badges
updateFeatureBadges();
// Show success message // Show success message
setStatus('Persona saved', 'success'); setStatus('Persona saved', 'success');
setTimeout(() => setStatus('Ready'), 2000); setTimeout(() => setStatus('Ready'), 2000);
@@ -2358,6 +2578,15 @@ async function handleSaveExamples() {
position position
}); });
// Update currentRoleplaySettings
if (currentRoleplaySettings) {
currentRoleplaySettings.examples_enabled = enabled;
currentRoleplaySettings.examples_position = position;
}
// Update feature badges
updateFeatureBadges();
// Show success message // Show success message
setStatus('Message Examples settings saved', 'success'); setStatus('Message Examples settings saved', 'success');
setTimeout(() => setStatus('Ready'), 2000); setTimeout(() => setStatus('Ready'), 2000);
@@ -2543,6 +2772,9 @@ async function handleApplyPreset() {
currentRoleplaySettings.active_preset_id = presetId; currentRoleplaySettings.active_preset_id = presetId;
} }
// Update feature badges
updateFeatureBadges();
setStatus(presetId ? 'Preset applied' : 'Preset removed', 'success'); setStatus(presetId ? 'Preset applied' : 'Preset removed', 'success');
setTimeout(() => setStatus('Ready'), 2000); setTimeout(() => setStatus('Ready'), 2000);
} catch (error) { } catch (error) {
@@ -2554,50 +2786,104 @@ async function handleApplyPreset() {
// Create custom preset // Create custom preset
async function handleCreatePreset() { async function handleCreatePreset() {
const name = prompt('Enter a name for your custom preset:'); // Check if form already exists
if (!name || !name.trim()) return; if (document.getElementById('preset-create-form')) return;
const description = prompt('Enter a description for your preset:'); const container = document.getElementById('presets-tab').querySelector('.roleplay-content');
if (!description || !description.trim()) return; const createBtn = document.getElementById('create-preset-btn');
const systemAdditions = prompt('Enter system additions (press Cancel to skip):', ''); // Create inline form
const authorsNoteDefault = prompt('Enter default Author\'s Note (press Cancel to skip):', ''); const formDiv = document.createElement('div');
formDiv.id = 'preset-create-form';
formDiv.style.background = 'var(--bg-secondary)';
formDiv.style.border = '2px solid var(--accent)';
formDiv.style.borderRadius = '8px';
formDiv.style.padding = '16px';
formDiv.style.marginBottom = '16px';
try { formDiv.innerHTML = `
// Generate a simple ID from the name <div style="display: flex; flex-direction: column; gap: 12px;">
const id = name.toLowerCase().replace(/[^a-z0-9]+/g, '-'); <div style="font-weight: 600; color: var(--text-primary); margin-bottom: 4px;">Create Custom Preset</div>
<div>
<label style="font-size: 12px; color: var(--text-secondary); display: block; margin-bottom: 4px;">Name *</label>
<input type="text" id="preset-create-name" placeholder="My Custom Preset" style="width: 100%;" />
</div>
<div>
<label style="font-size: 12px; color: var(--text-secondary); display: block; margin-bottom: 4px;">Description *</label>
<textarea id="preset-create-desc" placeholder="What this preset does..." rows="3" style="width: 100%;"></textarea>
</div>
<div>
<label style="font-size: 12px; color: var(--text-secondary); display: block; margin-bottom: 4px;">System Additions (optional)</label>
<textarea id="preset-create-system" placeholder="Additional text to prepend to system prompt..." rows="3" style="width: 100%;"></textarea>
</div>
<div>
<label style="font-size: 12px; color: var(--text-secondary); display: block; margin-bottom: 4px;">Default Author's Note (optional)</label>
<textarea id="preset-create-note" placeholder="Default Author's Note if user hasn't set one..." rows="3" style="width: 100%;"></textarea>
</div>
<div style="display: flex; gap: 8px; justify-content: flex-end;">
<button type="button" class="worldinfo-btn" id="preset-create-cancel">Cancel</button>
<button type="button" class="worldinfo-btn" id="preset-create-save" style="background: var(--accent); color: white;">Create</button>
</div>
</div>
`;
const preset = { container.insertBefore(formDiv, createBtn);
id: id, document.getElementById('preset-create-name').focus();
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 }); // Handle cancel
document.getElementById('preset-create-cancel').addEventListener('click', () => {
formDiv.remove();
});
setStatus('Custom preset created', 'success'); // Handle save
setTimeout(() => setStatus('Ready'), 2000); document.getElementById('preset-create-save').addEventListener('click', async () => {
const name = document.getElementById('preset-create-name').value.trim();
const description = document.getElementById('preset-create-desc').value.trim();
const systemAdditions = document.getElementById('preset-create-system').value.trim();
const authorsNoteDefault = document.getElementById('preset-create-note').value.trim();
// Reload presets if (!name || !description) {
await loadPresets(); alert('Name and description are required');
return;
}
// Select the new preset try {
document.getElementById('preset-select').value = id; // Generate a simple ID from the name
await handlePresetSelect(id); const id = name.toLowerCase().replace(/[^a-z0-9]+/g, '-');
} catch (error) {
console.error('Failed to create preset:', error); const preset = {
alert(`Failed to create preset: ${error}`); id: id,
setStatus('Failed to create preset', 'error'); name,
setTimeout(() => setStatus('Ready'), 2000); description,
} 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 });
formDiv.remove();
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 // Render instruction blocks list
@@ -3118,30 +3404,74 @@ async function deletePreset() {
async function duplicatePreset() { async function duplicatePreset() {
if (!currentEditingPreset) return; if (!currentEditingPreset) return;
const newName = prompt(`Enter a name for the duplicated preset:`, `${currentEditingPreset.name} (Copy)`); // Check if form already exists
if (!newName || !newName.trim()) return; if (document.getElementById('preset-duplicate-form')) return;
try { const presetInfo = document.getElementById('preset-info');
const duplicatedPreset = await invoke('duplicate_preset', { const formDiv = document.createElement('div');
sourcePresetId: currentEditingPreset.id, formDiv.id = 'preset-duplicate-form';
newName: newName.trim() formDiv.style.background = 'var(--bg-secondary)';
}); formDiv.style.border = '2px solid var(--accent)';
formDiv.style.borderRadius = '8px';
formDiv.style.padding = '16px';
formDiv.style.marginBottom = '16px';
setStatus('Preset duplicated successfully', 'success'); formDiv.innerHTML = `
setTimeout(() => setStatus('Ready'), 2000); <div style="display: flex; flex-direction: column; gap: 12px;">
<div style="font-weight: 600; color: var(--text-primary); margin-bottom: 4px;">Duplicate Preset</div>
<div>
<label style="font-size: 12px; color: var(--text-secondary); display: block; margin-bottom: 4px;">New Preset Name *</label>
<input type="text" id="preset-duplicate-name" placeholder="My Preset (Copy)" value="${currentEditingPreset.name} (Copy)" style="width: 100%;" />
</div>
<div style="display: flex; gap: 8px; justify-content: flex-end;">
<button type="button" class="worldinfo-btn" id="preset-duplicate-cancel">Cancel</button>
<button type="button" class="worldinfo-btn" id="preset-duplicate-save" style="background: var(--accent); color: white;">Duplicate</button>
</div>
</div>
`;
// Reload presets presetInfo.parentNode.insertBefore(formDiv, presetInfo.nextSibling);
await loadPresets(); document.getElementById('preset-duplicate-name').focus();
document.getElementById('preset-duplicate-name').select();
// Select the new preset // Cancel button
document.getElementById('preset-select').value = duplicatedPreset.id; document.getElementById('preset-duplicate-cancel').addEventListener('click', () => {
await handlePresetSelect(duplicatedPreset.id); formDiv.remove();
} catch (error) { });
console.error('Failed to duplicate preset:', error);
alert(`Failed to duplicate preset: ${error}`); // Duplicate button
setStatus('Failed to duplicate preset', 'error'); document.getElementById('preset-duplicate-save').addEventListener('click', async () => {
setTimeout(() => setStatus('Ready'), 2000); const newName = document.getElementById('preset-duplicate-name').value.trim();
} if (!newName) {
alert('Please enter a preset name');
return;
}
try {
const duplicatedPreset = await invoke('duplicate_preset', {
sourcePresetId: currentEditingPreset.id,
newName: newName
});
setStatus('Preset duplicated successfully', 'success');
setTimeout(() => setStatus('Ready'), 2000);
// Remove form
formDiv.remove();
// 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 // Restore built-in preset to default
@@ -3188,6 +3518,7 @@ async function loadExistingConfig() {
document.getElementById('api-base-url').value = config.base_url; document.getElementById('api-base-url').value = config.base_url;
document.getElementById('api-key').value = config.api_key; document.getElementById('api-key').value = config.api_key;
document.getElementById('stream-toggle').checked = config.stream || false; document.getElementById('stream-toggle').checked = config.stream || false;
document.getElementById('context-limit').value = config.context_limit || 200000;
const modelSelect = document.getElementById('model-select'); const modelSelect = document.getElementById('model-select');
modelSelect.innerHTML = ''; // Clear existing options modelSelect.innerHTML = ''; // Clear existing options
@@ -3239,9 +3570,105 @@ window.addEventListener('DOMContentLoaded', () => {
avatarModalOverlay.addEventListener('click', hideAvatarModal); avatarModalOverlay.addEventListener('click', hideAvatarModal);
// ESC key to close modal // ESC key to close modal
// Global keyboard shortcuts
document.addEventListener('keydown', (e) => { document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && avatarModal.style.display !== 'none') { // Escape key handling
hideAvatarModal(); if (e.key === 'Escape') {
// Close new character modal
const newCharacterModal = document.getElementById('new-character-modal');
if (newCharacterModal && newCharacterModal.style.display !== 'none') {
document.getElementById('cancel-new-character-btn').click();
return;
}
// Close avatar modal
if (avatarModal.style.display !== 'none') {
hideAvatarModal();
return;
}
// Close roleplay panel
const roleplayPanel = document.getElementById('roleplay-panel');
if (roleplayPanel && roleplayPanel.classList.contains('active')) {
document.getElementById('close-roleplay-btn').click();
return;
}
// Close settings panel
const settingsPanel = document.getElementById('settings-panel');
if (settingsPanel && settingsPanel.classList.contains('active')) {
document.getElementById('close-settings-btn').click();
return;
}
// Cancel message editing
const editActions = document.querySelector('.message-edit-actions');
if (editActions) {
const cancelBtn = editActions.querySelector('.message-edit-cancel');
if (cancelBtn) cancelBtn.click();
return;
}
}
// Up Arrow - Edit last user message (when input is focused and empty/at start)
if (e.key === 'ArrowUp' && e.target === messageInput && messageInput.selectionStart === 0) {
const messages = document.querySelectorAll('.message.user');
if (messages.length > 0) {
const lastUserMessage = messages[messages.length - 1];
const editBtn = lastUserMessage.querySelector('.message-action-btn[title="Edit message"]');
if (editBtn) {
e.preventDefault();
editBtn.click();
}
}
return;
}
// Left Arrow - Previous swipe (when not in input)
if (e.key === 'ArrowLeft' && e.target !== messageInput && !e.target.matches('input, textarea, select')) {
const lastAssistantMessage = [...document.querySelectorAll('.message.assistant')].pop();
if (lastAssistantMessage) {
const prevBtn = lastAssistantMessage.querySelector('.swipe-prev');
if (prevBtn && !prevBtn.disabled) {
e.preventDefault();
prevBtn.click();
}
}
return;
}
// Right Arrow - Next swipe (when not in input)
if (e.key === 'ArrowRight' && e.target !== messageInput && !e.target.matches('input, textarea, select')) {
const lastAssistantMessage = [...document.querySelectorAll('.message.assistant')].pop();
if (lastAssistantMessage) {
const nextBtn = lastAssistantMessage.querySelector('.swipe-next');
if (nextBtn && !nextBtn.disabled) {
e.preventDefault();
nextBtn.click();
}
}
return;
}
// Ctrl/Cmd + K - Focus message input
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault();
messageInput.focus();
return;
}
// Ctrl/Cmd + / - Toggle roleplay panel
if ((e.ctrlKey || e.metaKey) && e.key === '/') {
e.preventDefault();
document.getElementById('roleplay-btn').click();
return;
}
// Ctrl/Cmd + Enter - Send message (alternative to Enter)
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter' && e.target === messageInput) {
e.preventDefault();
handleSubmit(e);
return;
} }
}); });

View File

@@ -172,6 +172,45 @@ body {
gap: 8px; gap: 8px;
} }
/* Feature Badges */
.feature-badges {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.feature-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
background: rgba(99, 102, 241, 0.1);
border: 1px solid rgba(99, 102, 241, 0.3);
border-radius: 12px;
font-size: 11px;
font-weight: 500;
color: var(--accent);
white-space: nowrap;
transition: all 0.2s ease;
}
.feature-badge:hover {
background: rgba(99, 102, 241, 0.15);
border-color: rgba(99, 102, 241, 0.5);
}
.feature-badge-icon {
width: 10px;
height: 10px;
opacity: 0.8;
}
.feature-badge-count {
font-weight: 600;
color: var(--accent);
}
.icon-btn { .icon-btn {
width: 28px; width: 28px;
height: 28px; height: 28px;
@@ -1411,6 +1450,69 @@ body {
transform: scale(0.95); transform: scale(0.95);
} }
/* New Character Modal */
.new-character-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 10001;
display: flex;
align-items: center;
justify-content: center;
}
.new-character-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
cursor: pointer;
}
.new-character-content {
position: relative;
z-index: 1;
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 12px;
padding: 24px;
width: 90%;
max-width: 500px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
}
.new-character-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 16px;
border-bottom: 1px solid var(--border);
}
.new-character-header h3 {
font-size: 20px;
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
.new-character-actions {
display: flex;
gap: 12px;
margin-top: 20px;
}
.new-character-actions button {
flex: 1;
}
/* Theme Preview */ /* Theme Preview */
.theme-preview-container { .theme-preview-container {
margin-top: 20px; margin-top: 20px;