Files
Claudia-rust/src/main.js
matt 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

3509 lines
124 KiB
JavaScript

const { invoke } = window.__TAURI__.core;
let messageInput;
let messagesContainer;
let chatForm;
let sendBtn;
let statusText;
let settingsPanel;
let chatView;
let characterSelect;
let characterHeaderName;
let newCharacterBtn;
let currentCharacter = null;
let pendingAvatarPath = null;
// Theme definitions
const themes = {
dark: {
name: 'Dark (Default)',
bgPrimary: '#1a1a1a',
bgSecondary: '#252525',
bgTertiary: '#2f2f2f',
textPrimary: '#e8e8e8',
textSecondary: '#a0a0a0',
accent: '#6366f1',
accentHover: '#4f46e5',
userMsg: '#4f46e5',
assistantMsg: '#2f2f2f',
border: '#3a3a3a',
gradient: 'linear-gradient(135deg, #1a1a1a 0%, #2a1a2a 100%)',
glow: 'rgba(99, 102, 241, 0.1)'
},
darker: {
name: 'Darker',
bgPrimary: '#0a0a0a',
bgSecondary: '#141414',
bgTertiary: '#1a1a1a',
textPrimary: '#e0e0e0',
textSecondary: '#909090',
accent: '#7c3aed',
accentHover: '#6d28d9',
userMsg: '#6d28d9',
assistantMsg: '#1a1a1a',
border: '#2a2a2a',
gradient: 'linear-gradient(135deg, #0a0a0a 0%, #1a0a1a 100%)',
glow: 'rgba(124, 58, 237, 0.1)'
},
midnight: {
name: 'Midnight Blue',
bgPrimary: '#0f1419',
bgSecondary: '#1a2332',
bgTertiary: '#243447',
textPrimary: '#e6f1ff',
textSecondary: '#8892a0',
accent: '#3b82f6',
accentHover: '#2563eb',
userMsg: '#1e40af',
assistantMsg: '#243447',
border: '#2d3e54',
gradient: 'linear-gradient(135deg, #0f1419 0%, #1a2845 100%)',
glow: 'rgba(59, 130, 246, 0.1)'
},
forest: {
name: 'Forest',
bgPrimary: '#0d1b14',
bgSecondary: '#162820',
bgTertiary: '#1f352b',
textPrimary: '#e8f5e9',
textSecondary: '#90a89f',
accent: '#10b981',
accentHover: '#059669',
userMsg: '#047857',
assistantMsg: '#1f352b',
border: '#2d4a3a',
gradient: 'linear-gradient(135deg, #0d1b14 0%, #1a2820 100%)',
glow: 'rgba(16, 185, 129, 0.1)'
},
sunset: {
name: 'Sunset',
bgPrimary: '#1a1214',
bgSecondary: '#261a1e',
bgTertiary: '#332228',
textPrimary: '#fde8e8',
textSecondary: '#b89090',
accent: '#f97316',
accentHover: '#ea580c',
userMsg: '#c2410c',
assistantMsg: '#332228',
border: '#4a3238',
gradient: 'linear-gradient(135deg, #1a1214 0%, #2a1a1e 100%)',
glow: 'rgba(249, 115, 22, 0.1)'
},
light: {
name: 'Light',
bgPrimary: '#ffffff',
bgSecondary: '#f5f5f5',
bgTertiary: '#e8e8e8',
textPrimary: '#1a1a1a',
textSecondary: '#666666',
accent: '#6366f1',
accentHover: '#4f46e5',
userMsg: '#6366f1',
assistantMsg: '#f0f0f0',
border: '#d0d0d0',
gradient: 'linear-gradient(135deg, #ffffff 0%, #f5f0ff 100%)',
glow: 'rgba(99, 102, 241, 0.05)'
}
};
// Apply theme
function applyTheme(themeName) {
const theme = themes[themeName];
if (!theme) return;
const root = document.documentElement;
root.style.setProperty('--bg-primary', theme.bgPrimary);
root.style.setProperty('--bg-secondary', theme.bgSecondary);
root.style.setProperty('--bg-tertiary', theme.bgTertiary);
root.style.setProperty('--text-primary', theme.textPrimary);
root.style.setProperty('--text-secondary', theme.textSecondary);
root.style.setProperty('--accent', theme.accent);
root.style.setProperty('--accent-hover', theme.accentHover);
root.style.setProperty('--user-msg', theme.userMsg);
root.style.setProperty('--assistant-msg', theme.assistantMsg);
root.style.setProperty('--border', theme.border);
// Update gradient and glow
const appContainer = document.querySelector('.app-container');
if (appContainer) {
appContainer.style.background = theme.gradient;
const glow = appContainer.querySelector('::before');
}
// Store preference
localStorage.setItem('claudia-theme', themeName);
}
// Load saved theme
function loadSavedTheme() {
const savedTheme = localStorage.getItem('claudia-theme') || 'dark';
const themeSelect = document.getElementById('theme-select');
if (themeSelect) {
themeSelect.value = savedTheme;
}
applyTheme(savedTheme);
}
// Apply view mode
function applyViewMode(mode) {
const body = document.body;
// Remove all view mode classes
body.classList.remove('view-compact', 'view-cozy', 'view-comfortable');
// Add the selected mode
body.classList.add(`view-${mode}`);
// Store preference
localStorage.setItem('claudia-view-mode', mode);
}
// Load saved view mode
function loadSavedViewMode() {
const savedMode = localStorage.getItem('claudia-view-mode') || 'cozy';
const viewModeSelect = document.getElementById('view-mode-select');
if (viewModeSelect) {
viewModeSelect.value = savedMode;
}
applyViewMode(savedMode);
}
// Apply font size
function applyFontSize(scale) {
const root = document.documentElement;
// Calculate font size based on scale (80-140%)
const baseFontSize = 14; // Default base size in px
const newFontSize = (baseFontSize * scale) / 100;
root.style.setProperty('--base-font-size', `${newFontSize}px`);
root.style.fontSize = `${newFontSize}px`;
// Update the display value
const fontSizeValue = document.getElementById('font-size-value');
if (fontSizeValue) {
fontSizeValue.textContent = `${scale}%`;
}
// Store preference
localStorage.setItem('claudia-font-size', scale.toString());
}
// Load saved font size
function loadSavedFontSize() {
const savedSize = parseInt(localStorage.getItem('claudia-font-size') || '100');
const fontSizeSlider = document.getElementById('font-size-slider');
if (fontSizeSlider) {
fontSizeSlider.value = savedSize;
}
applyFontSize(savedSize);
}
// Export chat history
async function exportChatHistory() {
try {
setStatus('Exporting chat...', 'default');
const filePath = await invoke('export_chat_history');
setStatus('Chat exported successfully!', 'success');
setTimeout(() => setStatus('Ready'), 2000);
console.log('Chat exported to:', filePath);
} catch (error) {
console.error('Export failed:', error);
if (error && !error.toString().includes('cancelled')) {
setStatus(`Export failed: ${error}`, 'error');
setTimeout(() => setStatus('Ready'), 3000);
} else {
setStatus('Ready');
}
}
}
// Import chat history
async function importChatHistory() {
try {
setStatus('Importing chat...', 'default');
const messageCount = await invoke('import_chat_history');
// Reload the chat history
await loadChatHistory();
setStatus(`Imported ${messageCount} messages successfully!`, 'success');
setTimeout(() => setStatus('Ready'), 2000);
} catch (error) {
console.error('Import failed:', error);
if (error === 'No file selected' || error.toString().includes('cancelled')) {
setStatus('Ready');
} else {
setStatus(`Import failed: ${error}`, 'error');
setTimeout(() => setStatus('Ready'), 3000);
}
}
}
// Helper function to get avatar URL
async function getAvatarUrl(avatarFilename) {
if (!avatarFilename) return null;
try {
const fullPath = await invoke('get_avatar_full_path', { avatarFilename });
console.log('Avatar full path:', fullPath);
// Try to use convertFileSrc if available
if (window.__TAURI__ && window.__TAURI__.core && window.__TAURI__.core.convertFileSrc) {
const url = window.__TAURI__.core.convertFileSrc(fullPath);
console.log('Converted URL:', url);
return url;
} else {
// Fallback to using the path directly with proper protocol
const url = `asset://localhost/${fullPath}`;
console.log('Using asset protocol URL:', url);
return url;
}
} catch (error) {
console.error('Failed to get avatar URL for', avatarFilename, ':', error);
return null;
}
}
// Show avatar in modal
function showAvatarModal(avatarUrl) {
const modal = document.getElementById('avatar-modal');
const modalImg = document.getElementById('avatar-modal-img');
modalImg.src = avatarUrl;
modal.style.display = 'flex';
// Fade in animation
modal.style.opacity = '0';
setTimeout(() => {
modal.style.opacity = '1';
modal.style.transition = 'opacity 0.2s ease';
}, 10);
}
// Hide avatar modal
function hideAvatarModal() {
const modal = document.getElementById('avatar-modal');
modal.style.opacity = '0';
setTimeout(() => {
modal.style.display = 'none';
}, 200);
}
// Make avatar clickable
function makeAvatarClickable(avatarElement, avatarUrl) {
if (!avatarUrl) return;
avatarElement.addEventListener('click', (e) => {
e.stopPropagation();
showAvatarModal(avatarUrl);
});
}
// Format timestamp for display
function formatTimestamp(timestamp) {
if (!timestamp) return '';
const date = new Date(timestamp);
const now = new Date();
const diff = now - date;
const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
// Just now (less than 1 minute)
if (seconds < 60) {
return 'Just now';
}
// Minutes ago (less than 1 hour)
if (minutes < 60) {
return `${minutes}m ago`;
}
// Today (show time)
if (days === 0) {
return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true });
}
// Yesterday
if (days === 1) {
return `Yesterday at ${date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true })}`;
}
// This week (show day name)
if (days < 7) {
return date.toLocaleDateString('en-US', { weekday: 'short', hour: 'numeric', minute: '2-digit', hour12: true });
}
// Older (show date)
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', hour12: true });
}
// Auto-resize textarea
function autoResize(textarea) {
textarea.style.height = 'auto';
textarea.style.height = Math.min(textarea.scrollHeight, 120) + 'px';
}
// Helper function to render assistant message content with character name
function renderAssistantContent(contentDiv, messageText) {
// Clear existing content
contentDiv.innerHTML = '';
// Add character name indicator
if (currentCharacter && currentCharacter.name) {
const nameIndicator = document.createElement('div');
nameIndicator.className = 'character-name-indicator';
nameIndicator.textContent = currentCharacter.name;
contentDiv.appendChild(nameIndicator);
}
// Add message content
const messageContent = document.createElement('div');
messageContent.innerHTML = marked.parse(messageText);
contentDiv.appendChild(messageContent);
// Apply syntax highlighting to code blocks
messageContent.querySelectorAll('pre code').forEach((block) => {
hljs.highlightElement(block);
addCopyButtonToCode(block);
});
return messageContent;
}
// Add message to chat
function addMessage(content, isUser = false, skipActions = false, timestamp = null) {
const messageDiv = document.createElement('div');
messageDiv.className = `message ${isUser ? 'user' : 'assistant'}`;
const avatar = document.createElement('div');
avatar.className = 'avatar-circle';
// Set avatar image for assistant messages
if (!isUser && currentCharacter && currentCharacter.avatar_path) {
getAvatarUrl(currentCharacter.avatar_path).then(url => {
if (url) {
avatar.style.backgroundImage = `url('${url}')`;
makeAvatarClickable(avatar, url);
}
});
}
const contentDiv = document.createElement('div');
contentDiv.className = 'message-content';
if (isUser) {
// User messages: plain text
const p = document.createElement('p');
p.textContent = content;
contentDiv.appendChild(p);
// Add timestamp if provided
if (timestamp) {
const timestampDiv = document.createElement('div');
timestampDiv.className = 'message-timestamp';
timestampDiv.textContent = formatTimestamp(timestamp);
contentDiv.appendChild(timestampDiv);
}
} else {
// Assistant messages: render as markdown
// Add character name indicator if character exists
if (currentCharacter && currentCharacter.name) {
const nameIndicator = document.createElement('div');
nameIndicator.className = 'character-name-indicator';
nameIndicator.textContent = currentCharacter.name;
contentDiv.appendChild(nameIndicator);
}
const messageContent = document.createElement('div');
messageContent.innerHTML = marked.parse(content);
contentDiv.appendChild(messageContent);
// Apply syntax highlighting to code blocks
messageContent.querySelectorAll('pre code').forEach((block) => {
hljs.highlightElement(block);
// Add copy button to code blocks
const pre = block.parentElement;
if (!pre.querySelector('.copy-btn')) {
const copyBtn = document.createElement('button');
copyBtn.className = 'copy-btn';
copyBtn.innerHTML = `<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<rect x="5" y="5" width="9" height="9" stroke="currentColor" stroke-width="1.5" fill="none" rx="1"/>
<path d="M3 11V3a1 1 0 0 1 1-1h8" stroke="currentColor" stroke-width="1.5" fill="none"/>
</svg>`;
copyBtn.title = 'Copy code';
copyBtn.addEventListener('click', () => {
navigator.clipboard.writeText(block.textContent);
copyBtn.innerHTML = `<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M3 8l3 3 7-7" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>`;
copyBtn.classList.add('copied');
setTimeout(() => {
copyBtn.innerHTML = `<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<rect x="5" y="5" width="9" height="9" stroke="currentColor" stroke-width="1.5" fill="none" rx="1"/>
<path d="M3 11V3a1 1 0 0 1 1-1h8" stroke="currentColor" stroke-width="1.5" fill="none"/>
</svg>`;
copyBtn.classList.remove('copied');
}, 2000);
});
pre.style.position = 'relative';
pre.appendChild(copyBtn);
}
});
// Add timestamp if provided
if (timestamp) {
const timestampDiv = document.createElement('div');
timestampDiv.className = 'message-timestamp';
timestampDiv.textContent = formatTimestamp(timestamp);
contentDiv.appendChild(timestampDiv);
}
}
// Build message structure
if (!skipActions) {
const actionsDiv = document.createElement('div');
actionsDiv.className = 'message-actions';
if (isUser) {
// User message: simple structure with edit button
const editBtn = document.createElement('button');
editBtn.className = 'message-action-btn';
editBtn.innerHTML = `<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M10 1L13 4L5 12H2V9L10 1Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>`;
editBtn.title = 'Edit message';
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';
pinBtn.innerHTML = `<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M7 11V5M4.5 5L7 2.5L9.5 5M7 11L7 13" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>`;
pinBtn.title = 'Pin message';
pinBtn.addEventListener('click', () => handleTogglePin(messageDiv));
actionsDiv.appendChild(pinBtn);
// Hide button
const hideBtn = document.createElement('button');
hideBtn.className = 'message-action-btn message-hide-btn';
hideBtn.innerHTML = `<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M1 7C1 7 3 3 7 3C11 3 13 7 13 7C13 7 11 11 7 11C3 11 1 7 1 7Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="7" cy="7" r="2" stroke="currentColor" stroke-width="1.5"/>
</svg>`;
hideBtn.title = 'Hide message';
hideBtn.addEventListener('click', () => handleToggleHidden(messageDiv));
actionsDiv.appendChild(hideBtn);
// Delete button
const deleteBtn = document.createElement('button');
deleteBtn.className = 'message-action-btn message-delete-btn';
deleteBtn.innerHTML = `<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M2 4H12M5 4V3C5 2.44772 5.44772 2 6 2H8C8.55228 2 9 2.44772 9 3V4M11 4L10.5 11C10.5 11.5523 10.0523 12 9.5 12H4.5C3.94772 12 3.5 11.5523 3.5 11L3 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>`;
deleteBtn.title = 'Delete message';
deleteBtn.addEventListener('click', () => handleDeleteMessage(messageDiv));
actionsDiv.appendChild(deleteBtn);
messageDiv.appendChild(contentDiv);
messageDiv.appendChild(actionsDiv);
} else {
// Assistant message: structure with swipe controls
const regenerateBtn = document.createElement('button');
regenerateBtn.className = 'message-action-btn';
regenerateBtn.innerHTML = `<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M1 7C1 3.68629 3.68629 1 7 1C10.3137 1 13 3.68629 13 7C13 10.3137 10.3137 13 7 13C5.5 13 4.16667 12.3333 3 11.3333" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<path d="M1 10V7H4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>`;
regenerateBtn.title = 'Regenerate response';
regenerateBtn.addEventListener('click', () => handleRegenerateMessage(messageDiv));
actionsDiv.appendChild(regenerateBtn);
// Continue button
const continueBtn = document.createElement('button');
continueBtn.className = 'message-action-btn message-continue-btn';
continueBtn.innerHTML = `<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M3 7H11M11 7L7 3M11 7L7 11" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>`;
continueBtn.title = 'Continue message';
continueBtn.addEventListener('click', () => handleContinueMessage(messageDiv));
actionsDiv.appendChild(continueBtn);
// Pin button
const pinBtn = document.createElement('button');
pinBtn.className = 'message-action-btn message-pin-btn';
pinBtn.innerHTML = `<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M7 11V5M4.5 5L7 2.5L9.5 5M7 11L7 13" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>`;
pinBtn.title = 'Pin message';
pinBtn.addEventListener('click', () => handleTogglePin(messageDiv));
actionsDiv.appendChild(pinBtn);
// Hide button
const hideBtn = document.createElement('button');
hideBtn.className = 'message-action-btn message-hide-btn';
hideBtn.innerHTML = `<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M1 7C1 7 3 3 7 3C11 3 13 7 13 7C13 7 11 11 7 11C3 11 1 7 1 7Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="7" cy="7" r="2" stroke="currentColor" stroke-width="1.5"/>
</svg>`;
hideBtn.title = 'Hide message';
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';
deleteBtn.innerHTML = `<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M2 4H12M5 4V3C5 2.44772 5.44772 2 6 2H8C8.55228 2 9 2.44772 9 3V4M11 4L10.5 11C10.5 11.5523 10.0523 12 9.5 12H4.5C3.94772 12 3.5 11.5523 3.5 11L3 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>`;
deleteBtn.title = 'Delete message';
deleteBtn.addEventListener('click', () => handleDeleteMessage(messageDiv));
actionsDiv.appendChild(deleteBtn);
// Create swipe wrapper
const swipeWrapper = document.createElement('div');
swipeWrapper.style.display = 'flex';
swipeWrapper.style.flexDirection = 'column';
swipeWrapper.appendChild(contentDiv);
const swipeControls = createSwipeControls(messageDiv);
swipeWrapper.appendChild(swipeControls);
messageDiv.appendChild(swipeWrapper);
messageDiv.appendChild(actionsDiv);
}
} else {
messageDiv.appendChild(contentDiv);
}
if (!isUser) {
messageDiv.insertBefore(avatar, messageDiv.firstChild);
}
messagesContainer.appendChild(messageDiv);
messagesContainer.scrollTop = messagesContainer.scrollHeight;
return messageDiv;
}
// Create swipe controls for assistant messages
function createSwipeControls(messageDiv) {
const swipeControls = document.createElement('div');
swipeControls.className = 'swipe-controls';
const prevBtn = document.createElement('button');
prevBtn.className = 'swipe-btn swipe-prev';
prevBtn.innerHTML = `<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
<path d="M7.5 2L3.5 6L7.5 10" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>`;
prevBtn.title = 'Previous response';
prevBtn.addEventListener('click', () => handleSwipeNavigation(messageDiv, -1));
const counter = document.createElement('span');
counter.className = 'swipe-counter';
counter.textContent = '1/1';
const nextBtn = document.createElement('button');
nextBtn.className = 'swipe-btn swipe-next';
nextBtn.innerHTML = `<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
<path d="M4.5 2L8.5 6L4.5 10" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>`;
nextBtn.title = 'Next response';
nextBtn.addEventListener('click', () => handleSwipeNavigation(messageDiv, 1));
swipeControls.appendChild(prevBtn);
swipeControls.appendChild(counter);
swipeControls.appendChild(nextBtn);
// Initially hide if only one swipe
updateSwipeControls(messageDiv, 0, 1);
return swipeControls;
}
// Update swipe controls state
function updateSwipeControls(messageDiv, current, total) {
const swipeControls = messageDiv.querySelector('.swipe-controls');
if (!swipeControls) return;
const counter = swipeControls.querySelector('.swipe-counter');
const prevBtn = swipeControls.querySelector('.swipe-prev');
const nextBtn = swipeControls.querySelector('.swipe-next');
counter.textContent = `${current + 1}/${total}`;
prevBtn.disabled = current === 0;
nextBtn.disabled = current === total - 1;
// Show controls if more than one swipe
if (total > 1) {
swipeControls.classList.add('always-visible');
} else {
swipeControls.classList.remove('always-visible');
}
}
// Handle swipe navigation
async function handleSwipeNavigation(messageDiv, direction) {
const allMessages = Array.from(messagesContainer.querySelectorAll('.message'));
const messageIndex = allMessages.indexOf(messageDiv);
console.log('handleSwipeNavigation called:', { messageIndex, direction });
try {
const swipeInfo = await invoke('navigate_swipe', { messageIndex, direction });
console.log('Received swipeInfo:', swipeInfo);
// Update message content
const contentDiv = messageDiv.querySelector('.message-content');
console.log('Found contentDiv:', contentDiv);
console.log('Setting content to:', swipeInfo.content);
renderAssistantContent(contentDiv, swipeInfo.content);
// Update swipe controls
updateSwipeControls(messageDiv, swipeInfo.current, swipeInfo.total);
} catch (error) {
console.error('Failed to navigate swipe:', error);
}
}
// Handle editing a user message
async function handleEditMessage(messageDiv, originalContent) {
const contentDiv = messageDiv.querySelector('.message-content');
const actionsDiv = messageDiv.querySelector('.message-actions');
// Hide action buttons during edit
actionsDiv.style.display = 'none';
// Create edit form
const editForm = document.createElement('form');
editForm.className = 'message-edit-form';
const textarea = document.createElement('textarea');
textarea.className = 'message-edit-textarea';
textarea.value = originalContent;
textarea.rows = 3;
autoResize(textarea);
const editActions = document.createElement('div');
editActions.className = 'message-edit-actions';
const saveBtn = document.createElement('button');
saveBtn.type = 'submit';
saveBtn.className = 'message-edit-btn';
saveBtn.textContent = 'Save & Resend';
const cancelBtn = document.createElement('button');
cancelBtn.type = 'button';
cancelBtn.className = 'message-edit-btn';
cancelBtn.textContent = 'Cancel';
editActions.appendChild(saveBtn);
editActions.appendChild(cancelBtn);
editForm.appendChild(textarea);
editForm.appendChild(editActions);
// Auto-resize on input
textarea.addEventListener('input', () => autoResize(textarea));
// Replace content with edit form
const originalHTML = contentDiv.innerHTML;
contentDiv.innerHTML = '';
contentDiv.appendChild(editForm);
textarea.focus();
textarea.setSelectionRange(textarea.value.length, textarea.value.length);
// Handle cancel
cancelBtn.addEventListener('click', () => {
contentDiv.innerHTML = originalHTML;
actionsDiv.style.display = 'flex';
});
// Handle save
editForm.addEventListener('submit', async (e) => {
e.preventDefault();
const newContent = textarea.value.trim();
if (!newContent || newContent === originalContent) {
contentDiv.innerHTML = originalHTML;
actionsDiv.style.display = 'flex';
return;
}
// Get the index of this message
const allMessages = Array.from(messagesContainer.querySelectorAll('.message'));
const messageIndex = allMessages.indexOf(messageDiv);
// Disable form
saveBtn.disabled = true;
cancelBtn.disabled = true;
saveBtn.textContent = 'Saving...';
try {
// Truncate history from this point
await invoke('truncate_history_from', { index: messageIndex });
// Remove all messages from this point forward in UI
while (messagesContainer.children[messageIndex]) {
messagesContainer.children[messageIndex].remove();
}
// Send the edited message
await sendMessage(newContent);
} catch (error) {
console.error('Failed to edit message:', error);
contentDiv.innerHTML = originalHTML;
actionsDiv.style.display = 'flex';
addMessage(`Error editing message: ${error}`, false);
}
});
}
// Handle regenerating an assistant message
async function handleRegenerateMessage(messageDiv) {
const allMessages = Array.from(messagesContainer.querySelectorAll('.message'));
const messageIndex = allMessages.indexOf(messageDiv);
if (messageIndex === -1) {
console.error('Message not found in list');
return;
}
const regenerateBtn = messageDiv.querySelector('.message-action-btn');
regenerateBtn.disabled = true;
regenerateBtn.classList.add('loading');
try {
setStatus('Regenerating response...', 'default');
// Use the new regenerate_at_index command which works on any message
const swipeInfo = await invoke('regenerate_at_index', { messageIndex });
// Update the message content
const contentDiv = messageDiv.querySelector('.message-content');
renderAssistantContent(contentDiv, swipeInfo.content);
// Update swipe controls
updateSwipeControls(messageDiv, swipeInfo.current, swipeInfo.total);
setStatus('Regeneration complete', 'success');
setTimeout(() => setStatus('Ready'), 2000);
} catch (error) {
console.error('Failed to regenerate message:', error);
setStatus(`Regeneration failed: ${error}`, 'error');
} finally {
regenerateBtn.disabled = false;
regenerateBtn.classList.remove('loading');
}
}
// Generate a new swipe for an existing assistant message
async function generateSwipe(messageDiv, userMessage) {
setStatus('Regenerating response...', 'default');
// Check if streaming is enabled
let streamEnabled = false;
try {
const config = await invoke('get_api_config');
streamEnabled = config.stream || false;
} catch (error) {
console.error('Failed to get config:', error);
}
if (streamEnabled) {
await generateSwipeStream(messageDiv, userMessage);
} else {
await generateSwipeNonStream(messageDiv, userMessage);
}
}
// Generate swipe using non-streaming
async function generateSwipeNonStream(messageDiv, userMessage) {
try {
const response = await invoke('generate_response_only');
// Add as a swipe
const swipeInfo = await invoke('add_swipe_to_last_assistant', { content: response });
// Update the message content
const contentDiv = messageDiv.querySelector('.message-content');
renderAssistantContent(contentDiv, swipeInfo.content);
// Update swipe controls
updateSwipeControls(messageDiv, swipeInfo.current, swipeInfo.total);
setStatus('Regeneration complete', 'success');
setTimeout(() => setStatus('Ready'), 2000);
const regenerateBtn = messageDiv.querySelector('.message-action-btn');
if (regenerateBtn) {
regenerateBtn.disabled = false;
regenerateBtn.classList.remove('loading');
}
} catch (error) {
setStatus(`Regeneration failed: ${error.substring(0, 40)}...`, 'error');
const regenerateBtn = messageDiv.querySelector('.message-action-btn');
if (regenerateBtn) {
regenerateBtn.disabled = false;
regenerateBtn.classList.remove('loading');
}
addMessage(`Error regenerating message: ${error}`, false);
}
}
// Generate swipe using streaming
async function generateSwipeStream(messageDiv, userMessage) {
setStatus('Streaming regeneration...', 'streaming');
let fullContent = '';
const contentDiv = messageDiv.querySelector('.message-content');
// Set up event listeners for streaming
const { listen } = window.__TAURI__.event;
const tokenUnlisten = await listen('chat-token', (event) => {
const token = event.payload;
fullContent += token;
// Update content with markdown rendering
renderAssistantContent(contentDiv, fullContent);
messagesContainer.scrollTop = messagesContainer.scrollHeight;
});
const completeUnlisten = await listen('chat-complete', async () => {
// Add as a swipe
try {
const swipeInfo = await invoke('add_swipe_to_last_assistant', { content: fullContent });
// Update swipe controls
updateSwipeControls(messageDiv, swipeInfo.current, swipeInfo.total);
} catch (error) {
console.error('Failed to add swipe:', error);
}
setStatus('Regeneration complete', 'success');
setTimeout(() => setStatus('Ready'), 2000);
const regenerateBtn = messageDiv.querySelector('.message-action-btn');
if (regenerateBtn) {
regenerateBtn.disabled = false;
regenerateBtn.classList.remove('loading');
}
tokenUnlisten();
completeUnlisten();
});
try {
await invoke('generate_response_stream');
} catch (error) {
tokenUnlisten();
completeUnlisten();
setStatus(`Regeneration failed: ${error.substring(0, 40)}...`, 'error');
const regenerateBtn = messageDiv.querySelector('.message-action-btn');
if (regenerateBtn) {
regenerateBtn.disabled = false;
regenerateBtn.classList.remove('loading');
}
addMessage(`Error: ${error}`, false);
}
}
// Helper to add copy button to code blocks
function addCopyButtonToCode(block) {
const pre = block.parentElement;
if (pre && !pre.querySelector('.copy-btn')) {
const copyBtn = document.createElement('button');
copyBtn.className = 'copy-btn';
copyBtn.innerHTML = `<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<rect x="5" y="5" width="9" height="9" stroke="currentColor" stroke-width="1.5" fill="none" rx="1"/>
<path d="M3 11V3a1 1 0 0 1 1-1h8" stroke="currentColor" stroke-width="1.5" fill="none"/>
</svg>`;
copyBtn.title = 'Copy code';
copyBtn.addEventListener('click', () => {
navigator.clipboard.writeText(block.textContent);
copyBtn.innerHTML = `<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M3 8l3 3 7-7" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>`;
copyBtn.classList.add('copied');
setTimeout(() => {
copyBtn.innerHTML = `<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<rect x="5" y="5" width="9" height="9" stroke="currentColor" stroke-width="1.5" fill="none" rx="1"/>
<path d="M3 11V3a1 1 0 0 1 1-1h8" stroke="currentColor" stroke-width="1.5" fill="none"/>
</svg>`;
copyBtn.classList.remove('copied');
}, 2000);
});
pre.style.position = 'relative';
pre.appendChild(copyBtn);
}
}
// Handle deleting a message
async function handleDeleteMessage(messageDiv) {
const allMessages = Array.from(messagesContainer.querySelectorAll('.message'));
const messageIndex = allMessages.indexOf(messageDiv);
if (messageIndex === -1) {
console.error('Message not found in list');
return;
}
// Confirm deletion
if (!confirm('Are you sure you want to delete this message? This cannot be undone.')) {
return;
}
try {
await invoke('delete_message_at_index', { messageIndex });
messageDiv.remove();
await updateTokenCount();
setStatus('Message deleted', 'success');
setTimeout(() => setStatus('Ready'), 2000);
} catch (error) {
console.error('Failed to delete message:', error);
setStatus(`Delete failed: ${error}`, 'error');
}
}
// Handle toggling message pin status
async function handleTogglePin(messageDiv) {
const allMessages = Array.from(messagesContainer.querySelectorAll('.message'));
const messageIndex = allMessages.indexOf(messageDiv);
if (messageIndex === -1) {
console.error('Message not found in list');
return;
}
try {
const isPinned = await invoke('toggle_message_pin', { messageIndex });
// Update visual indicator
const pinBtn = messageDiv.querySelector('.message-pin-btn');
if (isPinned) {
messageDiv.classList.add('pinned');
pinBtn.classList.add('active');
pinBtn.title = 'Unpin message';
} else {
messageDiv.classList.remove('pinned');
pinBtn.classList.remove('active');
pinBtn.title = 'Pin message';
}
} catch (error) {
console.error('Failed to toggle pin:', error);
setStatus(`Pin toggle failed: ${error}`, 'error');
}
}
// Handle toggling message hidden status
async function handleToggleHidden(messageDiv) {
const allMessages = Array.from(messagesContainer.querySelectorAll('.message'));
const messageIndex = allMessages.indexOf(messageDiv);
if (messageIndex === -1) {
console.error('Message not found in list');
return;
}
try {
const isHidden = await invoke('toggle_message_hidden', { messageIndex });
// Update visual indicator
const hideBtn = messageDiv.querySelector('.message-hide-btn');
if (isHidden) {
messageDiv.classList.add('hidden-message');
hideBtn.classList.add('active');
hideBtn.title = 'Unhide message';
// Update icon to "eye-off"
hideBtn.innerHTML = `<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M10 5L11.5 3.5M3.5 10.5L5 9M1 13L13 1M5.5 6C5.19 6.31 5 6.74 5 7.22C5 8.2 5.8 9 6.78 9C7.26 9 7.69 8.81 8 8.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>`;
} else {
messageDiv.classList.remove('hidden-message');
hideBtn.classList.remove('active');
hideBtn.title = 'Hide message';
// Update icon back to "eye"
hideBtn.innerHTML = `<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M1 7C1 7 3 3 7 3C11 3 13 7 13 7C13 7 11 11 7 11C3 11 1 7 1 7Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="7" cy="7" r="2" stroke="currentColor" stroke-width="1.5"/>
</svg>`;
}
await updateTokenCount();
} catch (error) {
console.error('Failed to toggle hidden:', error);
setStatus(`Hide toggle failed: ${error}`, 'error');
}
}
// Handle continuing an incomplete message
async function handleContinueMessage(messageDiv) {
const allMessages = Array.from(messagesContainer.querySelectorAll('.message'));
const messageIndex = allMessages.indexOf(messageDiv);
if (messageIndex === -1) {
console.error('Message not found in list');
return;
}
const continueBtn = messageDiv.querySelector('.message-continue-btn');
continueBtn.disabled = true;
continueBtn.classList.add('loading');
try {
setStatus('Continuing message...', 'default');
const continuedText = await invoke('continue_message', { messageIndex });
// The backend appends to the message, so we just need to reload the content
const contentDiv = messageDiv.querySelector('.message-content');
const swipeInfo = await invoke('get_swipe_info', { messageIndex });
renderAssistantContent(contentDiv, swipeInfo.content);
setStatus('Message continued', 'success');
setTimeout(() => setStatus('Ready'), 2000);
await updateTokenCount();
} catch (error) {
console.error('Failed to continue message:', error);
setStatus(`Continue failed: ${error}`, 'error');
} finally {
continueBtn.disabled = false;
continueBtn.classList.remove('loading');
}
}
// Extract message sending logic into separate function
async function sendMessage(message, isRegenerate = false) {
if (!isRegenerate) {
addMessage(message, true, false, Date.now());
}
sendBtn.disabled = true;
messageInput.disabled = true;
setStatus('Connecting to API...', 'default');
// Check if streaming is enabled
let streamEnabled = false;
try {
const config = await invoke('get_api_config');
streamEnabled = config.stream || false;
} catch (error) {
console.error('Failed to get config:', error);
}
if (streamEnabled) {
// Use streaming
setStatus('Streaming response...', 'streaming');
let streamingMessageDiv = null;
let streamingContentDiv = null;
let fullContent = '';
// Create streaming message container
const messageDiv = document.createElement('div');
messageDiv.className = 'message assistant';
const avatar = document.createElement('div');
avatar.className = 'avatar-circle';
// Set avatar image for streaming messages
if (currentCharacter && currentCharacter.avatar_path) {
getAvatarUrl(currentCharacter.avatar_path).then(url => {
if (url) {
avatar.style.backgroundImage = `url('${url}')`;
makeAvatarClickable(avatar, url);
}
});
}
const contentDiv = document.createElement('div');
contentDiv.className = 'message-content';
// Create swipe wrapper for assistant messages
const swipeWrapper = document.createElement('div');
swipeWrapper.style.display = 'flex';
swipeWrapper.style.flexDirection = 'column';
swipeWrapper.appendChild(contentDiv);
const swipeControls = createSwipeControls(messageDiv);
swipeWrapper.appendChild(swipeControls);
messageDiv.appendChild(avatar);
messageDiv.appendChild(swipeWrapper);
messagesContainer.appendChild(messageDiv);
streamingMessageDiv = messageDiv;
streamingContentDiv = contentDiv;
// Set up event listeners for streaming
const { listen } = window.__TAURI__.event;
const tokenUnlisten = await listen('chat-token', (event) => {
const token = event.payload;
fullContent += token;
// Update content with markdown rendering
renderAssistantContent(streamingContentDiv, fullContent);
messagesContainer.scrollTop = messagesContainer.scrollHeight;
});
const completeUnlisten = await listen('chat-complete', () => {
// Add regenerate button after streaming completes
const actionsDiv = document.createElement('div');
actionsDiv.className = 'message-actions';
const regenerateBtn = document.createElement('button');
regenerateBtn.className = 'message-action-btn';
regenerateBtn.innerHTML = `<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M1 7C1 3.68629 3.68629 1 7 1C10.3137 1 13 3.68629 13 7C13 10.3137 10.3137 13 7 13C5.5 13 4.16667 12.3333 3 11.3333" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<path d="M1 10V7H4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>`;
regenerateBtn.title = 'Regenerate response';
regenerateBtn.addEventListener('click', () => handleRegenerateMessage(streamingMessageDiv));
actionsDiv.appendChild(regenerateBtn);
streamingMessageDiv.appendChild(actionsDiv);
setStatus('Response complete', 'success');
setTimeout(() => setStatus('Ready'), 2000);
sendBtn.disabled = false;
messageInput.disabled = false;
messageInput.focus();
tokenUnlisten();
completeUnlisten();
});
try {
await invoke('chat_stream', { message });
} catch (error) {
tokenUnlisten();
completeUnlisten();
if (streamingMessageDiv) {
streamingMessageDiv.remove();
}
if (error.includes('not configured')) {
addMessage('API not configured. Please configure your API settings.', false);
setStatus('API not configured', 'error');
setTimeout(showSettings, 1000);
} else {
addMessage(`Error: ${error}`, false);
setStatus(`Error: ${error.substring(0, 50)}...`, 'error');
}
sendBtn.disabled = false;
messageInput.disabled = false;
messageInput.focus();
}
} else {
// Use non-streaming
showTypingIndicator();
try {
const response = await invoke('chat', { message });
removeTypingIndicator();
addMessage(response, false);
setStatus('Response complete', 'success');
setTimeout(() => setStatus('Ready'), 2000);
} catch (error) {
removeTypingIndicator();
if (error.includes('not configured')) {
addMessage('API not configured. Please configure your API settings.', false);
setStatus('API not configured', 'error');
setTimeout(showSettings, 1000);
} else {
addMessage(`Error: ${error}`, false);
setStatus(`Error: ${error.substring(0, 50)}...`, 'error');
}
} finally {
sendBtn.disabled = false;
messageInput.disabled = false;
messageInput.focus();
}
}
}
// Show typing indicator
function showTypingIndicator() {
const typingDiv = document.createElement('div');
typingDiv.className = 'message assistant';
typingDiv.id = 'typing-indicator';
const indicatorDiv = document.createElement('div');
indicatorDiv.className = 'typing-indicator';
for (let i = 0; i < 3; i++) {
const dot = document.createElement('div');
dot.className = 'typing-dot';
indicatorDiv.appendChild(dot);
}
typingDiv.appendChild(indicatorDiv);
messagesContainer.appendChild(typingDiv);
messagesContainer.scrollTop = messagesContainer.scrollHeight;
}
// Remove typing indicator
function removeTypingIndicator() {
const indicator = document.getElementById('typing-indicator');
if (indicator) {
indicator.remove();
}
}
// Update status with optional styling
function setStatus(text, type = 'default') {
statusText.textContent = text;
// Remove all status classes
statusText.classList.remove('streaming', 'error', 'success');
// Add appropriate class based on type
if (type === 'streaming') {
statusText.classList.add('streaming');
} else if (type === 'error') {
statusText.classList.add('error');
} else if (type === 'success') {
statusText.classList.add('success');
}
}
// Show/hide settings
async function showSettings() {
const overlay = document.getElementById('settings-overlay');
settingsPanel.classList.add('open');
overlay.classList.add('show');
await loadCharacterSettings();
}
function hideSettings() {
const overlay = document.getElementById('settings-overlay');
settingsPanel.classList.remove('open');
overlay.classList.remove('show');
}
// Show/hide roleplay panel
async function showRoleplayPanel() {
const panel = document.getElementById('roleplay-panel');
const overlay = document.getElementById('roleplay-overlay');
panel.classList.add('open');
overlay.classList.add('show');
// Load roleplay settings when panel opens
await loadRoleplaySettings();
}
function hideRoleplayPanel() {
const panel = document.getElementById('roleplay-panel');
const overlay = document.getElementById('roleplay-overlay');
panel.classList.remove('open');
overlay.classList.remove('show');
}
// Tab switching
function setupTabs() {
// Settings tabs
const tabBtns = document.querySelectorAll('.tab-btn');
const tabContents = document.querySelectorAll('.tab-content');
tabBtns.forEach(btn => {
btn.addEventListener('click', () => {
const targetTab = btn.getAttribute('data-tab');
// Remove active class from all tabs and contents
tabBtns.forEach(b => b.classList.remove('active'));
tabContents.forEach(c => c.classList.remove('active'));
// Add active class to clicked tab and corresponding content
btn.classList.add('active');
document.getElementById(`${targetTab}-tab`).classList.add('active');
});
});
// Roleplay tabs
const roleplayTabBtns = document.querySelectorAll('.roleplay-tab-btn');
const roleplayTabContents = document.querySelectorAll('.roleplay-tab-content');
roleplayTabBtns.forEach(btn => {
btn.addEventListener('click', () => {
const targetTab = btn.getAttribute('data-tab');
// Remove active class from all roleplay tabs and contents
roleplayTabBtns.forEach(b => b.classList.remove('active'));
roleplayTabContents.forEach(c => c.classList.remove('active'));
// Add active class to clicked tab and corresponding content
btn.classList.add('active');
document.getElementById(`${targetTab}-tab`).classList.add('active');
});
});
}
// Handle form submission
async function handleSubmit(e) {
e.preventDefault();
const message = messageInput.value.trim();
if (!message) return;
messageInput.value = '';
autoResize(messageInput);
await sendMessage(message);
}
// Settings functionality
async function handleValidate() {
const baseUrl = document.getElementById('api-base-url').value.trim();
const apiKey = document.getElementById('api-key').value.trim();
const validateBtn = document.getElementById('validate-btn');
const modelsGroup = document.getElementById('models-group');
const modelSelect = document.getElementById('model-select');
const saveBtn = document.getElementById('save-settings-btn');
const validationMsg = document.getElementById('validation-message');
if (!baseUrl || !apiKey) {
validationMsg.textContent = 'Please fill in all fields';
validationMsg.className = 'validation-message error';
return;
}
validateBtn.disabled = true;
validateBtn.classList.add('loading');
validateBtn.textContent = 'Validating...';
validationMsg.style.display = 'none';
setStatus('Validating API...', 'default');
try {
const models = await invoke('validate_api', { baseUrl, apiKey });
validationMsg.textContent = `Found ${models.length} models`;
validationMsg.className = 'validation-message success';
setStatus('API validated successfully', 'success');
setTimeout(() => setStatus('Ready'), 2000);
modelSelect.innerHTML = '<option value="">Select a model</option>';
models.forEach(model => {
const option = document.createElement('option');
option.value = model;
option.textContent = model;
modelSelect.appendChild(option);
});
modelsGroup.style.display = 'flex';
modelsGroup.classList.add('fade-in');
saveBtn.disabled = false;
} catch (error) {
validationMsg.textContent = `Validation failed: ${error}`;
validationMsg.className = 'validation-message error';
setStatus('API validation failed', 'error');
modelsGroup.style.display = 'none';
saveBtn.disabled = true;
} finally {
validateBtn.disabled = false;
validateBtn.classList.remove('loading');
validateBtn.textContent = 'Validate';
}
}
async function handleSaveSettings(e) {
e.preventDefault();
const baseUrl = document.getElementById('api-base-url').value.trim();
const apiKey = document.getElementById('api-key').value.trim();
const model = document.getElementById('model-select').value;
const stream = document.getElementById('stream-toggle').checked;
const saveBtn = document.getElementById('save-settings-btn');
const validationMsg = document.getElementById('validation-message');
if (!model) {
validationMsg.textContent = 'Please select a model';
validationMsg.className = 'validation-message error';
return;
}
saveBtn.disabled = true;
saveBtn.classList.add('loading');
saveBtn.textContent = 'Saving...';
setStatus('Saving configuration...', 'default');
try {
await invoke('save_api_config', { baseUrl, apiKey, model, stream });
validationMsg.textContent = 'Configuration saved successfully';
validationMsg.className = 'validation-message success';
setStatus('Configuration saved', 'success');
setTimeout(() => {
hideSettings();
messagesContainer.innerHTML = '';
addMessage('API configured. Ready to chat.', false, true);
setStatus('Ready');
}, 1000);
} catch (error) {
validationMsg.textContent = `Failed to save: ${error}`;
validationMsg.className = 'validation-message error';
setStatus('Failed to save configuration', 'error');
} finally {
saveBtn.disabled = false;
saveBtn.classList.remove('loading');
saveBtn.textContent = 'Save Configuration';
}
}
// Avatar upload handling
async function handleAvatarUpload() {
const characterMsg = document.getElementById('character-message');
try {
const characterId = document.getElementById('character-settings-select').value;
const avatarFilename = await invoke('select_and_upload_avatar', {
characterId: characterId
});
pendingAvatarPath = avatarFilename;
// Update preview
const avatarPreview = document.querySelector('.avatar-circle-large');
const avatarUrl = await getAvatarUrl(avatarFilename);
if (avatarUrl) {
avatarPreview.style.backgroundImage = `url('${avatarUrl}')`;
}
document.getElementById('remove-avatar-btn').style.display = 'inline-block';
characterMsg.textContent = 'Avatar uploaded. Click "Save Character" to apply.';
characterMsg.className = 'validation-message success';
setTimeout(() => {
characterMsg.style.display = 'none';
}, 3000);
} catch (error) {
console.error('Avatar upload error:', error);
// Don't show error if user just cancelled the dialog
if (error && !error.toString().includes('No file selected')) {
characterMsg.textContent = `Failed to upload avatar: ${error}`;
characterMsg.className = 'validation-message error';
}
}
}
function handleAvatarRemove() {
pendingAvatarPath = null;
const avatarPreview = document.querySelector('.avatar-circle-large');
avatarPreview.style.backgroundImage = '';
document.getElementById('remove-avatar-btn').style.display = 'none';
const characterMsg = document.getElementById('character-message');
characterMsg.textContent = 'Avatar removed. Click "Save Character" to apply.';
characterMsg.className = 'validation-message success';
setTimeout(() => {
characterMsg.style.display = 'none';
}, 3000);
}
// App controls
function setupAppControls() {
document.getElementById('settings-btn').addEventListener('click', showSettings);
document.getElementById('close-settings-btn').addEventListener('click', hideSettings);
document.getElementById('settings-overlay').addEventListener('click', hideSettings);
document.getElementById('roleplay-btn').addEventListener('click', showRoleplayPanel);
document.getElementById('close-roleplay-btn').addEventListener('click', hideRoleplayPanel);
document.getElementById('roleplay-overlay').addEventListener('click', hideRoleplayPanel);
document.getElementById('clear-btn').addEventListener('click', clearHistory);
document.getElementById('export-chat-btn').addEventListener('click', exportChatHistory);
document.getElementById('import-chat-btn').addEventListener('click', importChatHistory);
characterSelect.addEventListener('change', handleCharacterSwitch);
newCharacterBtn.addEventListener('click', handleNewCharacter);
document.getElementById('delete-character-btn').addEventListener('click', handleDeleteCharacter);
document.getElementById('character-settings-select').addEventListener('change', async () => {
const characterId = document.getElementById('character-settings-select').value;
await invoke('set_active_character', { characterId });
await loadCharacterSettings();
});
document.getElementById('upload-avatar-btn').addEventListener('click', handleAvatarUpload);
document.getElementById('remove-avatar-btn').addEventListener('click', handleAvatarRemove);
document.getElementById('import-character-btn').addEventListener('click', handleImportCharacter);
document.getElementById('export-character-btn').addEventListener('click', handleExportCharacter);
// Setup collapsible sections
document.querySelectorAll('.settings-section-header').forEach(header => {
header.addEventListener('click', () => {
const section = header.parentElement;
section.classList.toggle('collapsed');
});
});
// Setup theme selector
const themeSelect = document.getElementById('theme-select');
if (themeSelect) {
themeSelect.addEventListener('change', (e) => {
applyTheme(e.target.value);
});
}
// Setup view mode selector
const viewModeSelect = document.getElementById('view-mode-select');
if (viewModeSelect) {
viewModeSelect.addEventListener('change', (e) => {
applyViewMode(e.target.value);
});
}
// Setup font size slider
const fontSizeSlider = document.getElementById('font-size-slider');
if (fontSizeSlider) {
fontSizeSlider.addEventListener('input', (e) => {
applyFontSize(parseInt(e.target.value));
});
}
// Setup roleplay panel buttons
document.getElementById('add-worldinfo-btn').addEventListener('click', handleAddWorldInfoEntry);
document.getElementById('save-authors-note-btn').addEventListener('click', handleSaveAuthorsNote);
document.getElementById('save-persona-btn').addEventListener('click', handleSavePersona);
document.getElementById('save-examples-btn').addEventListener('click', handleSaveExamples);
// 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
function setupKeyboardShortcuts() {
messageInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit(e);
}
});
messageInput.addEventListener('input', () => {
autoResize(messageInput);
updateTokenCount();
});
}
// 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) {
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 with color coding
const tokenCounter = document.getElementById('token-counter');
const tokenCountTotal = document.getElementById('token-count-total');
const contextLimit = 200000; // Claude 200k context
const percentage = (tokenData.total / contextLimit) * 100;
// Format: "2.5k / 200k tokens"
tokenCountTotal.textContent = `${formatTokenCount(tokenData.total)} / ${formatTokenCount(contextLimit)} tokens`;
// Apply color coding based on usage
if (percentage < 50) {
tokenCountTotal.style.color = '#4ade80'; // Green
} else if (percentage < 80) {
tokenCountTotal.style.color = '#facc15'; // Yellow
} else {
tokenCountTotal.style.color = '#f87171'; // Red
}
// 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-examples').textContent = tokenData.message_examples;
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);
// Keep counter visible, just show 0
const tokenCountTotal = document.getElementById('token-count-total');
tokenCountTotal.textContent = '0 / 200k tokens';
tokenCountTotal.style.color = 'var(--text-secondary)';
}
}, 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...');
try {
const characters = await invoke('list_characters');
console.log('Loaded characters:', characters);
characterSelect.innerHTML = '';
characters.forEach(char => {
const option = document.createElement('option');
option.value = char.id;
option.textContent = char.name;
characterSelect.appendChild(option);
});
const activeCharacter = await invoke('get_character');
console.log('Active character:', activeCharacter);
characterSelect.value = activeCharacter.id;
characterHeaderName.textContent = activeCharacter.name;
currentCharacter = activeCharacter;
// Update header avatar
const headerAvatar = document.querySelector('.avatar-circle');
if (headerAvatar && activeCharacter.avatar_path) {
getAvatarUrl(activeCharacter.avatar_path).then(url => {
if (url) {
headerAvatar.style.backgroundImage = `url('${url}')`;
makeAvatarClickable(headerAvatar, url);
}
});
} else if (headerAvatar) {
headerAvatar.style.backgroundImage = '';
}
await loadChatHistory();
} catch (error) {
console.error('Failed to load characters:', error);
addMessage(`Failed to load characters: ${error}`, false);
}
}
// Handle character switching
async function handleCharacterSwitch() {
const characterId = characterSelect.value;
setStatus('Switching character...', 'default');
try {
await invoke('set_active_character', { characterId });
messagesContainer.innerHTML = '';
await loadCharacters();
setStatus('Character switched', 'success');
setTimeout(() => setStatus('Ready'), 2000);
} catch (error) {
console.error('Failed to switch character:', error);
setStatus('Failed to switch character', 'error');
addMessage(`Failed to switch character: ${error}`, false);
}
}
// Handle new character creation
async function handleNewCharacter() {
const name = prompt('Enter a name for the new character:');
if (!name) return;
const systemPrompt = prompt('Enter the system prompt for the new character:', 'You are a helpful AI assistant.');
if (!systemPrompt) return;
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);
}
}
// Handle character deletion
async function handleDeleteCharacter() {
if (!currentCharacter || currentCharacter.id === 'default') {
addMessage('Cannot delete the default character.', false);
return;
}
if (confirm(`Are you sure you want to delete ${currentCharacter.name}? This cannot be undone.`)) {
try {
await invoke('delete_character', { characterId: currentCharacter.id });
await loadCharacters();
hideSettings();
} catch (error) {
console.error('Failed to delete character:', error);
addMessage(`Failed to delete character: ${error}`, false);
}
}
}
// Handle character card import
async function handleImportCharacter() {
const characterMsg = document.getElementById('character-message');
try {
const importedCharacter = await invoke('import_character_card');
characterMsg.textContent = `Successfully imported ${importedCharacter.name}!`;
characterMsg.className = 'validation-message success';
// Reload characters and switch to the imported one
await loadCharacters();
await loadCharacterSettings();
setTimeout(() => {
characterMsg.style.display = 'none';
}, 3000);
} catch (error) {
console.error('Failed to import character:', error);
if (error && !error.toString().includes('No file selected') && !error.toString().includes('cancelled')) {
characterMsg.textContent = `Failed to import: ${error}`;
characterMsg.className = 'validation-message error';
}
}
}
// Handle character card export
async function handleExportCharacter() {
const characterMsg = document.getElementById('character-message');
try {
const characterId = document.getElementById('character-settings-select').value;
const outputPath = await invoke('export_character_card', { characterId });
characterMsg.textContent = `Successfully exported to ${outputPath}`;
characterMsg.className = 'validation-message success';
setTimeout(() => {
characterMsg.style.display = 'none';
}, 3000);
} catch (error) {
console.error('Failed to export character:', error);
if (error && !error.toString().includes('cancelled')) {
characterMsg.textContent = `Failed to export: ${error}`;
characterMsg.className = 'validation-message error';
}
}
}
// Load chat history
async function loadChatHistory() {
try {
const history = await invoke('get_chat_history');
messagesContainer.innerHTML = '';
if (history.length === 0) {
if (currentCharacter && currentCharacter.greeting) {
addMessage(currentCharacter.greeting, false, true);
} else {
addMessage('API configured. Ready to chat.', false, true);
}
} else {
history.forEach((msg, index) => {
const messageDiv = addMessage(msg.content, msg.role === 'user', false, msg.timestamp);
// Apply pinned state
if (msg.pinned && messageDiv) {
messageDiv.classList.add('pinned');
const pinBtn = messageDiv.querySelector('.message-pin-btn');
if (pinBtn) {
pinBtn.classList.add('active');
pinBtn.title = 'Unpin message';
}
}
// Apply hidden state
if (msg.hidden && messageDiv) {
messageDiv.classList.add('hidden-message');
const hideBtn = messageDiv.querySelector('.message-hide-btn');
if (hideBtn) {
hideBtn.classList.add('active');
hideBtn.title = 'Unhide message';
hideBtn.innerHTML = `<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M10 5L11.5 3.5M3.5 10.5L5 9M1 13L13 1M5.5 6C5.19 6.31 5 6.74 5 7.22C5 8.2 5.8 9 6.78 9C7.26 9 7.69 8.81 8 8.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>`;
}
}
// Update swipe controls for assistant messages with swipe info
if (msg.role === 'assistant' && messageDiv && msg.swipes && msg.swipes.length > 0) {
updateSwipeControls(messageDiv, msg.current_swipe || 0, msg.swipes.length);
}
});
}
} catch (error) {
console.error('Failed to load chat history:', error);
messagesContainer.innerHTML = '';
addMessage('API configured. Ready to chat.', false, true);
}
// Update token count after loading history
updateTokenCount();
}
// Clear chat history
async function clearHistory() {
if (!confirm('Clear conversation history? This cannot be undone.')) {
return;
}
setStatus('Clearing history...', 'default');
try {
await invoke('clear_chat_history');
messagesContainer.innerHTML = '';
if (currentCharacter && currentCharacter.greeting) {
addMessage(currentCharacter.greeting, false, true);
} else {
addMessage('Conversation cleared. Ready to chat.', false, true);
}
setStatus('History cleared', 'success');
setTimeout(() => setStatus('Ready'), 2000);
} catch (error) {
setStatus('Failed to clear history', 'error');
addMessage(`Failed to clear history: ${error}`, false);
}
}
// Load character settings
async function loadCharacterSettings() {
try {
const characters = await invoke('list_characters');
const characterSettingsSelect = document.getElementById('character-settings-select');
characterSettingsSelect.innerHTML = '';
characters.forEach(char => {
const option = document.createElement('option');
option.value = char.id;
option.textContent = char.name;
characterSettingsSelect.appendChild(option);
});
const character = await invoke('get_character');
characterSettingsSelect.value = character.id;
document.getElementById('character-name').value = character.name;
document.getElementById('character-system-prompt').value = character.system_prompt;
document.getElementById('character-greeting').value = character.greeting || '';
document.getElementById('character-personality').value = character.personality || '';
document.getElementById('character-description').value = character.description || '';
document.getElementById('character-scenario').value = character.scenario || '';
document.getElementById('character-mes-example').value = character.mes_example || '';
document.getElementById('character-post-history').value = character.post_history_instructions || '';
document.getElementById('character-alt-greetings').value = character.alternate_greetings ? character.alternate_greetings.join('\n') : '';
document.getElementById('character-tags').value = character.tags ? character.tags.join(', ') : '';
document.getElementById('character-creator').value = character.creator || '';
document.getElementById('character-version').value = character.character_version || '';
document.getElementById('character-creator-notes').value = character.creator_notes || '';
// Load avatar preview
const avatarPreview = document.querySelector('.avatar-circle-large');
const removeAvatarBtn = document.getElementById('remove-avatar-btn');
if (character.avatar_path) {
getAvatarUrl(character.avatar_path).then(url => {
if (url) {
avatarPreview.style.backgroundImage = `url('${url}')`;
makeAvatarClickable(avatarPreview, url);
}
});
removeAvatarBtn.style.display = 'inline-block';
pendingAvatarPath = character.avatar_path;
} else {
avatarPreview.style.backgroundImage = '';
removeAvatarBtn.style.display = 'none';
pendingAvatarPath = null;
}
} catch (error) {
console.error('Failed to load character:', error);
}
}
// Save character settings
async function handleSaveCharacter(e) {
e.preventDefault();
const name = document.getElementById('character-name').value.trim();
const systemPrompt = document.getElementById('character-system-prompt').value.trim();
const greeting = document.getElementById('character-greeting').value.trim() || null;
const personality = document.getElementById('character-personality').value.trim() || null;
const description = document.getElementById('character-description').value.trim() || null;
const scenario = document.getElementById('character-scenario').value.trim() || null;
const mesExample = document.getElementById('character-mes-example').value.trim() || null;
const postHistory = document.getElementById('character-post-history').value.trim() || null;
const altGreetingsText = document.getElementById('character-alt-greetings').value.trim();
const altGreetings = altGreetingsText ? altGreetingsText.split('\n').map(s => s.trim()).filter(s => s) : null;
const tagsText = document.getElementById('character-tags').value.trim();
const tags = tagsText ? tagsText.split(',').map(s => s.trim()).filter(s => s) : null;
const creator = document.getElementById('character-creator').value.trim() || null;
const characterVersion = document.getElementById('character-version').value.trim() || null;
const creatorNotes = document.getElementById('character-creator-notes').value.trim() || null;
const saveBtn = document.getElementById('save-character-btn');
const characterMsg = document.getElementById('character-message');
if (!name || !systemPrompt) {
characterMsg.textContent = 'Name and System Prompt are required';
characterMsg.className = 'validation-message error';
return;
}
saveBtn.disabled = true;
saveBtn.classList.add('loading');
saveBtn.textContent = 'Saving...';
try {
await invoke('update_character', {
name,
systemPrompt,
greeting,
personality,
description,
scenario,
mesExample,
postHistory,
altGreetings,
tags,
creator,
characterVersion,
creatorNotes,
avatarPath: pendingAvatarPath
});
characterMsg.textContent = 'Character saved successfully';
characterMsg.className = 'validation-message success';
await loadCharacters();
setTimeout(() => {
characterMsg.style.display = 'none';
}, 3000);
} catch (error) {
characterMsg.textContent = `Failed to save: ${error}`;
characterMsg.className = 'validation-message error';
} finally {
saveBtn.disabled = false;
saveBtn.classList.remove('loading');
saveBtn.textContent = 'Save Character';
}
}
// World Info / Roleplay Settings Management
let currentRoleplaySettings = null;
// Load roleplay settings for current character
async function loadRoleplaySettings() {
if (!currentCharacter) return;
try {
const settings = await invoke('get_roleplay_settings', { characterId: currentCharacter.id });
currentRoleplaySettings = settings;
// Load World Info entries
renderWorldInfoList(settings.world_info || []);
// Load 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;
// Load Persona
document.getElementById('persona-name').value = settings.persona_name || '';
document.getElementById('persona-description').value = settings.persona_description || '';
document.getElementById('persona-enabled').checked = settings.persona_enabled || false;
// Load Message Examples
document.getElementById('examples-enabled').checked = settings.examples_enabled || false;
document.getElementById('examples-position').value = settings.examples_position || 'after_system';
// Load Presets
await loadPresets();
} catch (error) {
console.error('Failed to load roleplay settings:', error);
}
}
// Render World Info entries
function renderWorldInfoList(entries) {
const listContainer = document.getElementById('worldinfo-list');
listContainer.innerHTML = '';
if (entries.length === 0) {
const emptyMsg = document.createElement('p');
emptyMsg.style.color = 'var(--text-secondary)';
emptyMsg.style.fontSize = '14px';
emptyMsg.style.textAlign = 'center';
emptyMsg.style.padding = '20px';
emptyMsg.textContent = 'No entries yet. Click "Add Entry" to create one.';
listContainer.appendChild(emptyMsg);
return;
}
// Sort entries by priority (higher first)
const sortedEntries = [...entries].sort((a, b) => (b.priority || 0) - (a.priority || 0));
sortedEntries.forEach(entry => {
const entryDiv = document.createElement('div');
entryDiv.className = 'worldinfo-entry';
entryDiv.dataset.entryId = entry.id;
const header = document.createElement('div');
header.className = 'worldinfo-entry-header';
const enableCheckbox = document.createElement('input');
enableCheckbox.type = 'checkbox';
enableCheckbox.checked = entry.enabled;
enableCheckbox.addEventListener('change', () => handleToggleWorldInfoEntry(entry.id, enableCheckbox.checked));
const keysText = document.createElement('span');
keysText.className = 'worldinfo-keys';
keysText.textContent = entry.keys.join(', ');
const priority = document.createElement('span');
priority.className = 'worldinfo-priority';
priority.textContent = `Priority: ${entry.priority || 0}`;
const actionsDiv = document.createElement('div');
actionsDiv.className = 'worldinfo-entry-actions';
const editBtn = document.createElement('button');
editBtn.className = 'worldinfo-btn';
editBtn.textContent = 'Edit';
editBtn.addEventListener('click', () => handleEditWorldInfoEntry(entry));
const deleteBtn = document.createElement('button');
deleteBtn.className = 'worldinfo-btn worldinfo-btn-danger';
deleteBtn.textContent = 'Delete';
deleteBtn.addEventListener('click', () => handleDeleteWorldInfoEntry(entry.id));
actionsDiv.appendChild(editBtn);
actionsDiv.appendChild(deleteBtn);
header.appendChild(enableCheckbox);
header.appendChild(keysText);
header.appendChild(priority);
header.appendChild(actionsDiv);
const content = document.createElement('div');
content.className = 'worldinfo-entry-content';
content.textContent = entry.content;
entryDiv.appendChild(header);
entryDiv.appendChild(content);
listContainer.appendChild(entryDiv);
});
}
// Add new World Info entry
async function handleAddWorldInfoEntry() {
const listContainer = document.getElementById('worldinfo-list');
// Check if form already exists
if (document.getElementById('worldinfo-add-form')) return;
// Create inline form
const formDiv = document.createElement('div');
formDiv.id = 'worldinfo-add-form';
formDiv.className = 'worldinfo-entry worldinfo-edit-form';
formDiv.innerHTML = `
<div style="display: flex; flex-direction: column; gap: 12px;">
<div style="font-weight: 600; color: var(--text-primary); margin-bottom: 4px;">Add World Info Entry</div>
<div>
<label style="font-size: 12px; color: var(--text-secondary); display: block; margin-bottom: 4px;">Keywords (comma-separated)</label>
<input type="text" id="wi-add-keys" placeholder="John, John Smith" style="width: 100%;" />
</div>
<div>
<label style="font-size: 12px; color: var(--text-secondary); display: block; margin-bottom: 4px;">Content</label>
<textarea id="wi-add-content" placeholder="Information to inject when keywords are found..." rows="4" style="width: 100%;"></textarea>
</div>
<div>
<label style="font-size: 12px; color: var(--text-secondary); display: block; margin-bottom: 4px;">Priority</label>
<input type="number" id="wi-add-priority" value="0" min="0" style="width: 100px;" />
</div>
<div style="display: flex; gap: 8px; justify-content: flex-end;">
<button type="button" class="worldinfo-btn" id="wi-add-cancel">Cancel</button>
<button type="button" class="worldinfo-btn" id="wi-add-save" style="background: var(--accent); color: white;">Save</button>
</div>
</div>
`;
listContainer.prepend(formDiv);
// Focus first input
document.getElementById('wi-add-keys').focus();
// Handle cancel
document.getElementById('wi-add-cancel').addEventListener('click', () => {
formDiv.remove();
});
// Handle save
document.getElementById('wi-add-save').addEventListener('click', async () => {
const keys = document.getElementById('wi-add-keys').value.trim();
const content = document.getElementById('wi-add-content').value.trim();
const priority = parseInt(document.getElementById('wi-add-priority').value) || 0;
if (!keys || !content) {
alert('Keywords and content are required');
return;
}
try {
const keysArray = keys.split(',').map(k => k.trim()).filter(k => k);
await invoke('add_world_info_entry', {
characterId: currentCharacter.id,
keys: keysArray,
content: content,
priority,
caseSensitive: false
});
formDiv.remove();
await loadRoleplaySettings();
} catch (error) {
console.error('Failed to add World Info entry:', error);
alert(`Failed to add entry: ${error}`);
}
});
}
// Edit World Info entry
async function handleEditWorldInfoEntry(entry) {
const entryDiv = document.querySelector(`.worldinfo-entry[data-entry-id="${entry.id}"]`);
if (!entryDiv) return;
// Check if already editing
if (entryDiv.querySelector('.worldinfo-inline-edit')) return;
// Hide normal content
const header = entryDiv.querySelector('.worldinfo-entry-header');
const content = entryDiv.querySelector('.worldinfo-entry-content');
header.style.display = 'none';
content.style.display = 'none';
// Create inline edit form
const editForm = document.createElement('div');
editForm.className = 'worldinfo-inline-edit';
editForm.innerHTML = `
<div style="display: flex; flex-direction: column; gap: 12px;">
<div style="font-weight: 600; color: var(--text-primary); margin-bottom: 4px;">Edit Entry</div>
<div>
<label style="font-size: 12px; color: var(--text-secondary); display: block; margin-bottom: 4px;">Keywords (comma-separated)</label>
<input type="text" class="wi-edit-keys" value="${entry.keys.join(', ')}" style="width: 100%;" />
</div>
<div>
<label style="font-size: 12px; color: var(--text-secondary); display: block; margin-bottom: 4px;">Content</label>
<textarea class="wi-edit-content" rows="4" style="width: 100%;">${entry.content}</textarea>
</div>
<div>
<label style="font-size: 12px; color: var(--text-secondary); display: block; margin-bottom: 4px;">Priority</label>
<input type="number" class="wi-edit-priority" value="${entry.priority || 0}" min="0" style="width: 100px;" />
</div>
<div style="display: flex; gap: 8px; justify-content: flex-end;">
<button type="button" class="worldinfo-btn wi-edit-cancel">Cancel</button>
<button type="button" class="worldinfo-btn wi-edit-save" style="background: var(--accent); color: white;">Save</button>
</div>
</div>
`;
entryDiv.appendChild(editForm);
// Focus first input
editForm.querySelector('.wi-edit-keys').focus();
// Handle cancel
editForm.querySelector('.wi-edit-cancel').addEventListener('click', () => {
header.style.display = '';
content.style.display = '';
editForm.remove();
});
// Handle save
editForm.querySelector('.wi-edit-save').addEventListener('click', async () => {
const keys = editForm.querySelector('.wi-edit-keys').value.trim();
const contentText = editForm.querySelector('.wi-edit-content').value.trim();
const priority = parseInt(editForm.querySelector('.wi-edit-priority').value) || 0;
if (!keys || !contentText) {
alert('Keywords and content are required');
return;
}
try {
const keysArray = keys.split(',').map(k => k.trim()).filter(k => k);
await invoke('update_world_info_entry', {
characterId: currentCharacter.id,
entryId: entry.id,
keys: keysArray,
content: contentText,
enabled: entry.enabled,
priority,
caseSensitive: entry.case_sensitive
});
await loadRoleplaySettings();
} catch (error) {
console.error('Failed to update World Info entry:', error);
alert(`Failed to update entry: ${error}`);
}
});
}
// Toggle World Info entry enabled state
async function handleToggleWorldInfoEntry(entryId, enabled) {
if (!currentRoleplaySettings) return;
const entry = currentRoleplaySettings.world_info.find(e => e.id === entryId);
if (!entry) return;
try {
await invoke('update_world_info_entry', {
characterId: currentCharacter.id,
entryId: entryId,
keys: entry.keys,
content: entry.content,
enabled: enabled,
priority: entry.priority,
caseSensitive: entry.case_sensitive
});
// Update local settings
entry.enabled = enabled;
} catch (error) {
console.error('Failed to toggle World Info entry:', error);
alert(`Failed to toggle entry: ${error}`);
}
}
// Delete World Info entry
async function handleDeleteWorldInfoEntry(entryId) {
if (!confirm('Delete this World Info entry? This cannot be undone.')) return;
try {
await invoke('delete_world_info_entry', {
characterId: currentCharacter.id,
entryId: entryId
});
// Reload settings
await loadRoleplaySettings();
} catch (error) {
console.error('Failed to delete World Info entry:', error);
alert(`Failed to delete entry: ${error}`);
}
}
// Save Author's Note
async function handleSaveAuthorsNote() {
if (!currentCharacter) return;
const content = document.getElementById('authors-note-text').value.trim() || null;
const enabled = document.getElementById('authors-note-enabled').checked;
try {
await invoke('update_authors_note', {
characterId: currentCharacter.id,
content,
enabled
});
// Show success message
setStatus('Author\'s Note saved', 'success');
setTimeout(() => setStatus('Ready'), 2000);
} catch (error) {
console.error('Failed to save Author\'s Note:', error);
setStatus('Failed to save Author\'s Note', 'error');
}
}
// Save Persona
async function handleSavePersona() {
if (!currentCharacter) return;
const name = document.getElementById('persona-name').value.trim() || null;
const description = document.getElementById('persona-description').value.trim() || null;
const enabled = document.getElementById('persona-enabled').checked;
try {
await invoke('update_persona', {
characterId: currentCharacter.id,
name,
description,
enabled
});
// Show success message
setStatus('Persona saved', 'success');
setTimeout(() => setStatus('Ready'), 2000);
} catch (error) {
console.error('Failed to save Persona:', error);
setStatus('Failed to save Persona', 'error');
}
}
// Save Message Examples Settings
async function handleSaveExamples() {
if (!currentCharacter) return;
const enabled = document.getElementById('examples-enabled').checked;
const position = document.getElementById('examples-position').value;
try {
await invoke('update_examples_settings', {
characterId: currentCharacter.id,
enabled,
position
});
// Show success message
setStatus('Message Examples settings saved', 'success');
setTimeout(() => setStatus('Ready'), 2000);
} catch (error) {
console.error('Failed to save Message Examples settings:', error);
setStatus('Failed to save Message Examples settings', 'error');
}
}
// 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() {
// Check if form already exists
if (document.getElementById('preset-create-form')) return;
const container = document.getElementById('presets-tab').querySelector('.roleplay-content');
const createBtn = document.getElementById('create-preset-btn');
// 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';
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>
`;
container.insertBefore(formDiv, createBtn);
document.getElementById('preset-create-name').focus();
// Handle cancel
document.getElementById('preset-create-cancel').addEventListener('click', () => {
formDiv.remove();
});
// 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();
if (!name || !description) {
alert('Name and description are required');
return;
}
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
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 listContainer = document.getElementById('preset-instructions-list');
// Check if form already exists
if (document.getElementById('instruction-add-form')) return;
// Create inline form
const formDiv = document.createElement('div');
formDiv.id = 'instruction-add-form';
formDiv.style.background = 'var(--bg-secondary)';
formDiv.style.border = '2px solid var(--accent)';
formDiv.style.borderRadius = '6px';
formDiv.style.padding = '12px';
formDiv.style.marginBottom = '8px';
formDiv.innerHTML = `
<div style="display: flex; flex-direction: column; gap: 10px;">
<div style="font-weight: 500; font-size: 11px; color: var(--text-primary);">Add Instruction Block</div>
<div>
<label style="font-size: 11px; color: var(--text-secondary); display: block; margin-bottom: 4px;">Name</label>
<input type="text" id="inst-add-name" placeholder="Block name..." style="width: 100%; padding: 6px; font-size: 11px;" />
</div>
<div>
<label style="font-size: 11px; color: var(--text-secondary); display: block; margin-bottom: 4px;">Content</label>
<textarea id="inst-add-content" placeholder="Instruction content..." rows="4" style="width: 100%; padding: 6px; font-size: 11px;"></textarea>
</div>
<div style="display: flex; gap: 6px; justify-content: flex-end;">
<button type="button" class="worldinfo-btn" id="inst-add-cancel" style="font-size: 11px; padding: 4px 8px;">Cancel</button>
<button type="button" class="worldinfo-btn" id="inst-add-save" style="background: var(--accent); color: white; font-size: 11px; padding: 4px 8px;">Add</button>
</div>
</div>
`;
listContainer.prepend(formDiv);
document.getElementById('inst-add-name').focus();
// Handle cancel
document.getElementById('inst-add-cancel').addEventListener('click', () => {
formDiv.remove();
});
// Handle save
document.getElementById('inst-add-save').addEventListener('click', () => {
const name = document.getElementById('inst-add-name').value.trim();
const content = document.getElementById('inst-add-content').value.trim();
if (!name || !content) {
alert('Name and content are required');
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,
content,
enabled: true,
order: maxOrder + 1
};
currentEditingPreset.instructions.push(newInstruction);
formDiv.remove();
// Re-render
renderInstructionBlocks(currentEditingPreset.instructions, false);
});
}
// Edit instruction block
function editInstruction(instruction) {
// Find the instruction block div
const listContainer = document.getElementById('preset-instructions-list');
const blocks = Array.from(listContainer.children);
const blockDiv = blocks.find(el => {
const header = el.querySelector('[style*="cursor: pointer"]');
return header && header.textContent.includes(instruction.name);
});
if (!blockDiv) return;
// Check if already editing
if (blockDiv.querySelector('.instruction-edit-form')) return;
// Hide original content
const header = blockDiv.querySelector('[style*="cursor: pointer"]');
const content = blockDiv.querySelector('.instruction-content');
header.style.display = 'none';
content.style.display = 'none';
// Create edit form
const editForm = document.createElement('div');
editForm.className = 'instruction-edit-form';
editForm.style.padding = '8px';
editForm.innerHTML = `
<div style="display: flex; flex-direction: column; gap: 10px;">
<div style="font-weight: 500; font-size: 11px; color: var(--text-primary);">Edit Instruction Block</div>
<div>
<label style="font-size: 11px; color: var(--text-secondary); display: block; margin-bottom: 4px;">Name</label>
<input type="text" class="inst-edit-name" value="${instruction.name}" style="width: 100%; padding: 6px; font-size: 11px;" />
</div>
<div>
<label style="font-size: 11px; color: var(--text-secondary); display: block; margin-bottom: 4px;">Content</label>
<textarea class="inst-edit-content" rows="4" style="width: 100%; padding: 6px; font-size: 11px;">${instruction.content}</textarea>
</div>
<div style="display: flex; gap: 6px; justify-content: flex-end;">
<button type="button" class="worldinfo-btn inst-edit-cancel" style="font-size: 11px; padding: 4px 8px;">Cancel</button>
<button type="button" class="worldinfo-btn inst-edit-save" style="background: var(--accent); color: white; font-size: 11px; padding: 4px 8px;">Save</button>
</div>
</div>
`;
blockDiv.appendChild(editForm);
editForm.querySelector('.inst-edit-name').focus();
// Handle cancel
editForm.querySelector('.inst-edit-cancel').addEventListener('click', () => {
header.style.display = '';
content.style.display = '';
editForm.remove();
});
// Handle save
editForm.querySelector('.inst-edit-save').addEventListener('click', () => {
const newName = editForm.querySelector('.inst-edit-name').value.trim();
const newContent = editForm.querySelector('.inst-edit-content').value.trim();
if (!newName || !newContent) {
alert('Name and content are required');
return;
}
instruction.name = newName;
instruction.content = newContent;
// 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;
// Check if form already exists
if (document.getElementById('preset-duplicate-form')) return;
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';
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>
`;
presetInfo.parentNode.insertBefore(formDiv, presetInfo.nextSibling);
document.getElementById('preset-duplicate-name').focus();
document.getElementById('preset-duplicate-name').select();
// 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
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...');
try {
const config = await invoke('get_api_config');
console.log('Loaded config:', config);
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;
const modelSelect = document.getElementById('model-select');
modelSelect.innerHTML = ''; // Clear existing options
const option = document.createElement('option');
option.value = config.model;
option.textContent = config.model;
option.selected = true;
modelSelect.appendChild(option);
// Show the model group since we have a saved model
document.getElementById('models-group').style.display = 'flex';
document.getElementById('save-settings-btn').disabled = false;
// Load characters
await loadCharacters();
} catch (error) {
console.error('Failed to load existing config:', error);
addMessage('API not configured. Please configure your API settings.', false);
showSettings();
}
}
// Initialize app
window.addEventListener('DOMContentLoaded', () => {
messageInput = document.getElementById('message-input');
messagesContainer = document.getElementById('messages');
chatForm = document.getElementById('chat-form');
sendBtn = document.getElementById('send-btn');
statusText = document.getElementById('status-text');
settingsPanel = document.getElementById('settings-panel');
chatView = document.getElementById('chat-view');
characterSelect = document.getElementById('character-select');
characterHeaderName = document.getElementById('character-header-name');
newCharacterBtn = document.getElementById('new-character-btn');
chatForm.addEventListener('submit', handleSubmit);
document.getElementById('settings-form').addEventListener('submit', handleSaveSettings);
document.getElementById('character-form').addEventListener('submit', handleSaveCharacter);
document.getElementById('validate-btn').addEventListener('click', handleValidate);
setupAppControls();
setupKeyboardShortcuts();
setupTabs();
// Avatar modal close handlers
const avatarModal = document.getElementById('avatar-modal');
const avatarModalOverlay = document.querySelector('.avatar-modal-overlay');
avatarModalOverlay.addEventListener('click', hideAvatarModal);
// ESC key to close modal
// Global keyboard shortcuts
document.addEventListener('keydown', (e) => {
// Escape key handling
if (e.key === 'Escape') {
// 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;
}
});
messageInput.focus();
setStatus('Ready');
// Load saved preferences before anything else
loadSavedTheme();
loadSavedViewMode();
loadSavedFontSize();
loadExistingConfig();
});