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
- **Shift+Enter** - New line
- **Up Arrow** - Edit last user message
- **Left/Right Arrow** - Swipe between responses
- **Ctrl+Enter** - Send message (alternative)
- **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

View File

@@ -20,6 +20,12 @@ struct ApiConfig {
active_character_id: Option<String>,
#[serde(default)]
stream: bool,
#[serde(default = "default_context_limit")]
context_limit: u32,
}
fn default_context_limit() -> u32 {
200000
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -1161,7 +1167,7 @@ async fn validate_api(base_url: String, api_key: String) -> Result<Vec<String>,
}
#[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
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,
active_character_id,
stream,
context_limit,
};
save_config(&config)
}

View File

@@ -26,6 +26,9 @@
<div class="avatar-circle"></div>
<span id="character-header-name"></span>
</div>
<div id="feature-badges" class="feature-badges">
<!-- Feature badges will be added here dynamically -->
</div>
<div class="character-controls">
<div class="select-wrapper">
<select id="character-select" class="character-select"></select>
@@ -350,6 +353,19 @@
</select>
</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">
<label>
<input type="checkbox" id="stream-toggle" />
@@ -662,7 +678,7 @@
</form>
<div class="status-bar">
<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>
<button id="token-details-btn" class="token-details-btn" title="Show breakdown">
<svg width="12" height="12" viewBox="0 0 16 16" fill="none">
@@ -724,5 +740,50 @@
<img id="avatar-modal-img" src="" alt="Avatar" />
</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>
</html>

View File

@@ -481,6 +481,27 @@ function addMessage(content, isUser = false, skipActions = false, timestamp = nu
editBtn.addEventListener('click', () => handleEditMessage(messageDiv, content));
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
const pinBtn = document.createElement('button');
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));
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
const deleteBtn = document.createElement('button');
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 model = document.getElementById('model-select').value;
const stream = document.getElementById('stream-toggle').checked;
const contextLimit = parseInt(document.getElementById('context-limit').value) || 200000;
const saveBtn = document.getElementById('save-settings-btn');
const validationMsg = document.getElementById('validation-message');
@@ -1430,7 +1473,7 @@ async function handleSaveSettings(e) {
setStatus('Saving configuration...', 'default');
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.className = 'validation-message success';
setStatus('Configuration saved', 'success');
@@ -1597,6 +1640,14 @@ function setupKeyboardShortcuts() {
// Token Counter
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() {
// Debounce token count updates
if (tokenUpdateTimeout) {
@@ -1611,11 +1662,21 @@ async function updateTokenCount() {
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
const tokenCounter = document.getElementById('token-counter');
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
document.getElementById('token-system').textContent = tokenData.system_prompt;
@@ -1629,8 +1690,9 @@ async function updateTokenCount() {
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';
// Keep counter visible, just show 0
const tokenCountTotal = document.getElementById('token-count-total');
tokenCountTotal.textContent = '0 / 200k tokens';
}
}, 300); // Update after 300ms of no typing
}
@@ -1711,20 +1773,66 @@ async function handleCharacterSwitch() {
// Handle new character creation
async function handleNewCharacter() {
const name = prompt('Enter a name for the new character:');
if (!name) return;
const modal = document.getElementById('new-character-modal');
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.');
if (!systemPrompt) return;
// Reset form
form.reset();
systemPromptInput.value = 'You are a helpful AI assistant.';
try {
const newCharacter = await invoke('create_character', { name, systemPrompt });
await loadCharacters();
characterSelect.value = newCharacter.id;
} catch (error) {
console.error('Failed to create character:', error);
addMessage(`Failed to create character: ${error}`, false);
}
// Show modal
modal.style.display = 'flex';
// Focus name input after a brief delay to ensure it's visible
setTimeout(() => nameInput.focus(), 100);
// 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
@@ -2020,11 +2128,101 @@ async function loadRoleplaySettings() {
// Load Presets
await loadPresets();
// Update feature badges
updateFeatureBadges();
} catch (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
function renderWorldInfoList(entries) {
const listContainer = document.getElementById('worldinfo-list');
@@ -2272,6 +2470,9 @@ async function handleToggleWorldInfoEntry(entryId, enabled) {
// Update local settings
entry.enabled = enabled;
// Update feature badges
updateFeatureBadges();
} catch (error) {
console.error('Failed to toggle World Info entry:', error);
alert(`Failed to toggle entry: ${error}`);
@@ -2310,6 +2511,15 @@ async function handleSaveAuthorsNote() {
enabled
});
// Update currentRoleplaySettings
if (currentRoleplaySettings) {
currentRoleplaySettings.authors_note = content;
currentRoleplaySettings.authors_note_enabled = enabled;
}
// Update feature badges
updateFeatureBadges();
// Show success message
setStatus('Author\'s Note saved', 'success');
setTimeout(() => setStatus('Ready'), 2000);
@@ -2335,6 +2545,16 @@ async function handleSavePersona() {
enabled
});
// Update currentRoleplaySettings
if (currentRoleplaySettings) {
currentRoleplaySettings.persona_name = name;
currentRoleplaySettings.persona_description = description;
currentRoleplaySettings.persona_enabled = enabled;
}
// Update feature badges
updateFeatureBadges();
// Show success message
setStatus('Persona saved', 'success');
setTimeout(() => setStatus('Ready'), 2000);
@@ -2358,6 +2578,15 @@ async function handleSaveExamples() {
position
});
// Update currentRoleplaySettings
if (currentRoleplaySettings) {
currentRoleplaySettings.examples_enabled = enabled;
currentRoleplaySettings.examples_position = position;
}
// Update feature badges
updateFeatureBadges();
// Show success message
setStatus('Message Examples settings saved', 'success');
setTimeout(() => setStatus('Ready'), 2000);
@@ -2543,6 +2772,9 @@ async function handleApplyPreset() {
currentRoleplaySettings.active_preset_id = presetId;
}
// Update feature badges
updateFeatureBadges();
setStatus(presetId ? 'Preset applied' : 'Preset removed', 'success');
setTimeout(() => setStatus('Ready'), 2000);
} catch (error) {
@@ -2554,50 +2786,104 @@ async function handleApplyPreset() {
// Create custom preset
async function handleCreatePreset() {
const name = prompt('Enter a name for your custom preset:');
if (!name || !name.trim()) return;
// Check if form already exists
if (document.getElementById('preset-create-form')) return;
const description = prompt('Enter a description for your preset:');
if (!description || !description.trim()) return;
const container = document.getElementById('presets-tab').querySelector('.roleplay-content');
const createBtn = document.getElementById('create-preset-btn');
const systemAdditions = prompt('Enter system additions (press Cancel to skip):', '');
const authorsNoteDefault = prompt('Enter default Author\'s Note (press Cancel to skip):', '');
// Create inline form
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 {
// Generate a simple ID from the name
const id = name.toLowerCase().replace(/[^a-z0-9]+/g, '-');
formDiv.innerHTML = `
<div style="display: flex; flex-direction: column; gap: 12px;">
<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 = {
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}]'
}
};
container.insertBefore(formDiv, createBtn);
document.getElementById('preset-create-name').focus();
await invoke('save_custom_preset', { preset });
// Handle cancel
document.getElementById('preset-create-cancel').addEventListener('click', () => {
formDiv.remove();
});
setStatus('Custom preset created', 'success');
setTimeout(() => setStatus('Ready'), 2000);
// Handle save
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
await loadPresets();
if (!name || !description) {
alert('Name and description are required');
return;
}
// 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);
}
try {
// Generate a simple ID from the name
const id = name.toLowerCase().replace(/[^a-z0-9]+/g, '-');
const preset = {
id: id,
name,
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
@@ -3118,30 +3404,74 @@ async function deletePreset() {
async function duplicatePreset() {
if (!currentEditingPreset) return;
const newName = prompt(`Enter a name for the duplicated preset:`, `${currentEditingPreset.name} (Copy)`);
if (!newName || !newName.trim()) return;
// Check if form already exists
if (document.getElementById('preset-duplicate-form')) return;
try {
const duplicatedPreset = await invoke('duplicate_preset', {
sourcePresetId: currentEditingPreset.id,
newName: newName.trim()
});
const presetInfo = document.getElementById('preset-info');
const formDiv = document.createElement('div');
formDiv.id = 'preset-duplicate-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';
setStatus('Preset duplicated successfully', 'success');
setTimeout(() => setStatus('Ready'), 2000);
formDiv.innerHTML = `
<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
await loadPresets();
presetInfo.parentNode.insertBefore(formDiv, presetInfo.nextSibling);
document.getElementById('preset-duplicate-name').focus();
document.getElementById('preset-duplicate-name').select();
// 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);
}
// Cancel button
document.getElementById('preset-duplicate-cancel').addEventListener('click', () => {
formDiv.remove();
});
// Duplicate button
document.getElementById('preset-duplicate-save').addEventListener('click', async () => {
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
@@ -3188,6 +3518,7 @@ async function loadExistingConfig() {
document.getElementById('api-base-url').value = config.base_url;
document.getElementById('api-key').value = config.api_key;
document.getElementById('stream-toggle').checked = config.stream || false;
document.getElementById('context-limit').value = config.context_limit || 200000;
const modelSelect = document.getElementById('model-select');
modelSelect.innerHTML = ''; // Clear existing options
@@ -3239,9 +3570,105 @@ window.addEventListener('DOMContentLoaded', () => {
avatarModalOverlay.addEventListener('click', hideAvatarModal);
// ESC key to close modal
// Global keyboard shortcuts
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && avatarModal.style.display !== 'none') {
hideAvatarModal();
// Escape key handling
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;
}
/* 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 {
width: 28px;
height: 28px;
@@ -1411,6 +1450,69 @@ body {
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-container {
margin-top: 20px;