feat: add swipe functionality for multiple response alternatives

- Added swipe system to Message struct with backward compatibility
- Implemented swipe navigation UI with left/right arrows and counter
- Added generate_response_only and generate_response_stream commands
- Swipes persist properly when navigating between alternatives
- Updated message rendering to support swipe controls
This commit is contained in:
2025-10-13 22:29:58 -07:00
parent 90bbeb4468
commit f82ec6f6a8
3 changed files with 1169 additions and 192 deletions

View File

@@ -80,7 +80,7 @@ function autoResize(textarea) {
}
// Add message to chat
function addMessage(content, isUser = false) {
function addMessage(content, isUser = false, skipActions = false) {
const messageDiv = document.createElement('div');
messageDiv.className = `message ${isUser ? 'user' : 'assistant'}`;
@@ -143,12 +143,605 @@ function addMessage(content, isUser = false) {
});
}
if (!isUser) {
messageDiv.appendChild(avatar);
// 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);
}
messageDiv.appendChild(contentDiv);
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);
contentDiv.innerHTML = marked.parse(swipeInfo.content);
// Apply syntax highlighting to code blocks
contentDiv.querySelectorAll('pre code').forEach((block) => {
hljs.highlightElement(block);
// Add copy button
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);
}
});
// 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;
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;
addMessage(`Error regenerating message: ${error}`, false);
}
}
// Generate a new swipe for an existing assistant message
async function generateSwipe(messageDiv, userMessage) {
setStatus('Regenerating...');
// 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');
contentDiv.innerHTML = marked.parse(swipeInfo.content);
// Apply syntax highlighting
contentDiv.querySelectorAll('pre code').forEach((block) => {
hljs.highlightElement(block);
addCopyButtonToCode(block);
});
// Update swipe controls
updateSwipeControls(messageDiv, swipeInfo.current, swipeInfo.total);
setStatus('Ready');
const regenerateBtn = messageDiv.querySelector('.message-action-btn');
if (regenerateBtn) regenerateBtn.disabled = false;
} catch (error) {
setStatus('Error');
const regenerateBtn = messageDiv.querySelector('.message-action-btn');
if (regenerateBtn) regenerateBtn.disabled = false;
addMessage(`Error regenerating message: ${error}`, false);
}
}
// Generate swipe using streaming
async function generateSwipeStream(messageDiv, userMessage) {
setStatus('Streaming...');
statusText.classList.add('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
contentDiv.innerHTML = marked.parse(fullContent);
// Apply syntax highlighting
contentDiv.querySelectorAll('pre code').forEach((block) => {
hljs.highlightElement(block);
addCopyButtonToCode(block);
});
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('Ready');
statusText.classList.remove('streaming');
const regenerateBtn = messageDiv.querySelector('.message-action-btn');
if (regenerateBtn) regenerateBtn.disabled = false;
tokenUnlisten();
completeUnlisten();
});
try {
await invoke('generate_response_stream');
} catch (error) {
tokenUnlisten();
completeUnlisten();
statusText.classList.remove('streaming');
setStatus('Error');
const regenerateBtn = messageDiv.querySelector('.message-action-btn');
if (regenerateBtn) regenerateBtn.disabled = false;
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);
}
sendBtn.disabled = true;
messageInput.disabled = true;
setStatus('Thinking...');
// 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...');
statusText.classList.add('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
streamingContentDiv.innerHTML = marked.parse(fullContent);
// Apply syntax highlighting
streamingContentDiv.querySelectorAll('pre code').forEach((block) => {
hljs.highlightElement(block);
// Add copy button
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);
}
});
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('Ready');
statusText.classList.remove('streaming');
sendBtn.disabled = false;
messageInput.disabled = false;
messageInput.focus();
tokenUnlisten();
completeUnlisten();
});
try {
await invoke('chat_stream', { message });
} catch (error) {
tokenUnlisten();
completeUnlisten();
statusText.classList.remove('streaming');
if (streamingMessageDiv) {
streamingMessageDiv.remove();
}
if (error.includes('not configured')) {
addMessage('API not configured. Please configure your API settings.', false);
setTimeout(showSettings, 1000);
} else {
addMessage(`Error: ${error}`, false);
}
setStatus('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('Ready');
} catch (error) {
removeTypingIndicator();
if (error.includes('not configured')) {
addMessage('API not configured. Please configure your API settings.', false);
setTimeout(showSettings, 1000);
} else {
addMessage(`Error: ${error}`, false);
}
setStatus('Error');
} finally {
sendBtn.disabled = false;
messageInput.disabled = false;
messageInput.focus();
}
}
}
// Show typing indicator
@@ -223,159 +816,10 @@ async function handleSubmit(e) {
const message = messageInput.value.trim();
if (!message) return;
addMessage(message, true);
messageInput.value = '';
autoResize(messageInput);
sendBtn.disabled = true;
messageInput.disabled = true;
setStatus('Thinking...');
// 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...');
statusText.classList.add('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';
messageDiv.appendChild(avatar);
messageDiv.appendChild(contentDiv);
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
streamingContentDiv.innerHTML = marked.parse(fullContent);
// Apply syntax highlighting
streamingContentDiv.querySelectorAll('pre code').forEach((block) => {
hljs.highlightElement(block);
// Add copy button
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);
}
});
messagesContainer.scrollTop = messagesContainer.scrollHeight;
});
const completeUnlisten = await listen('chat-complete', () => {
setStatus('Ready');
statusText.classList.remove('streaming');
sendBtn.disabled = false;
messageInput.disabled = false;
messageInput.focus();
tokenUnlisten();
completeUnlisten();
});
try {
await invoke('chat_stream', { message });
} catch (error) {
tokenUnlisten();
completeUnlisten();
statusText.classList.remove('streaming');
if (streamingMessageDiv) {
streamingMessageDiv.remove();
}
if (error.includes('not configured')) {
addMessage('API not configured. Please configure your API settings.', false);
setTimeout(showSettings, 1000);
} else {
addMessage(`Error: ${error}`, false);
}
setStatus('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('Ready');
} catch (error) {
removeTypingIndicator();
if (error.includes('not configured')) {
addMessage('API not configured. Please configure your API settings.', false);
setTimeout(showSettings, 1000);
} else {
addMessage(`Error: ${error}`, false);
}
setStatus('Error');
} finally {
sendBtn.disabled = false;
messageInput.disabled = false;
messageInput.focus();
}
}
await sendMessage(message);
}
// Settings functionality
@@ -452,7 +896,7 @@ async function handleSaveSettings(e) {
setTimeout(() => {
hideSettings();
messagesContainer.innerHTML = '';
addMessage('API configured. Ready to chat.', false);
addMessage('API configured. Ready to chat.', false, true);
}, 1000);
} catch (error) {
validationMsg.textContent = `Failed to save: ${error}`;
@@ -641,19 +1085,24 @@ async function loadChatHistory() {
if (history.length === 0) {
if (currentCharacter && currentCharacter.greeting) {
addMessage(currentCharacter.greeting, false);
addMessage(currentCharacter.greeting, false, true);
} else {
addMessage('API configured. Ready to chat.', false);
addMessage('API configured. Ready to chat.', false, true);
}
} else {
history.forEach(msg => {
addMessage(msg.content, msg.role === 'user');
history.forEach((msg, index) => {
const messageDiv = addMessage(msg.content, msg.role === 'user');
// 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);
addMessage('API configured. Ready to chat.', false, true);
}
}
@@ -667,9 +1116,9 @@ async function clearHistory() {
await invoke('clear_chat_history');
messagesContainer.innerHTML = '';
if (currentCharacter && currentCharacter.greeting) {
addMessage(currentCharacter.greeting, false);
addMessage(currentCharacter.greeting, false, true);
} else {
addMessage('Conversation cleared. Ready to chat.', false);
addMessage('Conversation cleared. Ready to chat.', false, true);
}
} catch (error) {
addMessage(`Failed to clear history: ${error}`, false);

View File

@@ -231,6 +231,7 @@ body {
.message {
display: flex;
animation: slideIn 0.3s ease;
position: relative;
}
@keyframes slideIn {
@@ -286,6 +287,182 @@ body {
margin-top: 12px;
}
/* Message action buttons */
.message-actions {
position: absolute;
top: 8px;
display: flex;
gap: 4px;
opacity: 0;
transition: opacity 0.2s ease;
}
.message.user .message-actions {
right: 8px;
}
.message.assistant .message-actions {
right: 8px;
}
.message:hover .message-actions {
opacity: 1;
}
.message-action-btn {
width: 24px;
height: 24px;
border: none;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border-radius: 6px;
color: var(--text-secondary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
padding: 0;
}
.message-action-btn:hover {
background: rgba(0, 0, 0, 0.7);
color: var(--text-primary);
transform: scale(1.1);
}
.message-action-btn:active {
transform: scale(0.9);
}
.message-action-btn svg {
width: 14px;
height: 14px;
}
/* Swipe navigation */
.swipe-controls {
display: flex;
align-items: center;
gap: 6px;
margin-top: 8px;
padding: 4px 8px;
background: rgba(0, 0, 0, 0.3);
border-radius: 12px;
width: fit-content;
opacity: 0;
transition: opacity 0.2s ease;
}
.message.assistant:hover .swipe-controls {
opacity: 1;
}
.swipe-controls.always-visible {
opacity: 1;
}
.swipe-btn {
width: 20px;
height: 20px;
border: none;
background: rgba(255, 255, 255, 0.1);
color: var(--text-secondary);
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
padding: 0;
}
.swipe-btn:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.2);
color: var(--text-primary);
}
.swipe-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.swipe-btn svg {
width: 12px;
height: 12px;
}
.swipe-counter {
font-size: 11px;
color: var(--text-secondary);
min-width: 32px;
text-align: center;
user-select: none;
}
/* Edit mode for messages */
.message-edit-form {
width: 100%;
display: flex;
flex-direction: column;
gap: 8px;
}
.message-edit-textarea {
width: 100%;
background: var(--bg-tertiary);
border: 1px solid var(--accent);
border-radius: 8px;
padding: 12px;
color: var(--text-primary);
font-size: 14px;
font-family: inherit;
resize: vertical;
min-height: 60px;
}
.message-edit-textarea:focus {
outline: none;
border-color: var(--accent-hover);
}
.message-edit-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
.message-edit-btn {
padding: 6px 12px;
border: none;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
font-family: inherit;
}
.message-edit-btn.save {
background: var(--accent);
color: white;
}
.message-edit-btn.save:hover {
background: var(--accent-hover);
}
.message-edit-btn.cancel {
background: var(--bg-tertiary);
color: var(--text-primary);
border: 1px solid var(--border);
}
.message-edit-btn.cancel:hover {
background: var(--border);
}
.message-content code {
background: rgba(0, 0, 0, 0.3);
padding: 2px 6px;