Files
Claudia-rust/src/main.js
matt e364ecfc51 feat: add World Info/Lorebook, Author's Note, and Persona systems
Backend changes:
- Add RoleplaySettings and WorldInfoEntry data structures
- Implement per-character roleplay settings storage in ~/.config/claudia/roleplay_{id}.json
- Add Tauri commands for CRUD operations on World Info entries
- Add commands for saving Author's Note and Persona settings

Frontend changes:
- Add World Info entry management UI with add/edit/delete functionality
- Implement keyword-triggered context injection system with priority ordering
- Add Author's Note textarea with enable toggle
- Add Persona name and description fields with enable toggle
- Load roleplay settings when opening the roleplay panel
- Add CSS styles for World Info entry cards with hover effects

Features:
- World Info entries support multiple keywords, priority levels, and enable/disable
- Settings are per-character and persist across sessions
- Entries sorted by priority (higher priority injected first)
- Clean UI with edit/delete buttons and visual feedback
2025-10-14 19:55:08 -07:00

1994 lines
67 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);
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);
// 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 regenerateBtn = messageDiv.querySelector('.message-action-btn');
regenerateBtn.disabled = true;
regenerateBtn.classList.add('loading');
try {
// Get the last user message
const lastUserMessage = await invoke('get_last_user_message');
// Generate new response
await generateSwipe(messageDiv, lastUserMessage);
} catch (error) {
console.error('Failed to regenerate message:', error);
regenerateBtn.disabled = false;
regenerateBtn.classList.remove('loading');
addMessage(`Error regenerating message: ${error}`, false);
}
}
// 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);
}
}
// 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);
}
// Keyboard shortcuts
function setupKeyboardShortcuts() {
messageInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit(e);
}
});
messageInput.addEventListener('input', () => {
autoResize(messageInput);
});
}
// 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);
// 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);
}
}
// 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 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;
} 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 keys = prompt('Enter keywords (comma-separated):\nExample: John, John Smith');
if (!keys) return;
const content = prompt('Enter the content to inject when keywords are found:');
if (!content) return;
const priorityStr = prompt('Enter priority (higher = injected first, default 0):', '0');
const priority = parseInt(priorityStr) || 0;
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.trim(),
priority,
caseSensitive: false
});
// Reload settings
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 keys = prompt('Edit keywords (comma-separated):', entry.keys.join(', '));
if (keys === null) return;
const content = prompt('Edit content:', entry.content);
if (content === null) return;
const priorityStr = prompt('Edit priority:', entry.priority.toString());
if (priorityStr === null) return;
const priority = parseInt(priorityStr) || 0;
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: content.trim(),
enabled: entry.enabled,
priority,
caseSensitive: entry.case_sensitive
});
// Reload settings
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');
}
}
// 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
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && avatarModal.style.display !== 'none') {
hideAvatarModal();
}
});
messageInput.focus();
setStatus('Ready');
// Load saved preferences before anything else
loadSavedTheme();
loadSavedViewMode();
loadSavedFontSize();
loadExistingConfig();
});