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>
3509 lines
124 KiB
JavaScript
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();
|
|
});
|