From f82ec6f6a860a3da929927e415f81af1c51a488b Mon Sep 17 00:00:00 2001 From: matt Date: Mon, 13 Oct 2025 22:29:58 -0700 Subject: [PATCH] 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 --- src-tauri/src/lib.rs | 411 +++++++++++++++++++++-- src/main.js | 773 ++++++++++++++++++++++++++++++++++--------- src/styles.css | 177 ++++++++++ 3 files changed, 1169 insertions(+), 192 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 12f23a1..1238e22 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -30,7 +30,63 @@ struct Character { #[derive(Debug, Clone, Serialize, Deserialize)] struct Message { role: String, - content: String, + #[serde(default)] + content: String, // Keep for backward compatibility + #[serde(default)] + swipes: Vec, + #[serde(default)] + current_swipe: usize, +} + +impl Message { + fn new_user(content: String) -> Self { + Self { + role: "user".to_string(), + content: content.clone(), + swipes: vec![content], + current_swipe: 0, + } + } + + fn new_assistant(content: String) -> Self { + Self { + role: "assistant".to_string(), + content: content.clone(), + swipes: vec![content], + current_swipe: 0, + } + } + + fn get_content(&self) -> &str { + if !self.swipes.is_empty() { + &self.swipes[self.current_swipe] + } else { + &self.content + } + } + + fn add_swipe(&mut self, content: String) { + self.swipes.push(content.clone()); + self.current_swipe = self.swipes.len() - 1; + self.content = content; + } + + fn set_swipe(&mut self, index: usize) -> Result<(), String> { + if index >= self.swipes.len() { + return Err("Invalid swipe index".to_string()); + } + self.current_swipe = index; + self.content = self.swipes[index].clone(); + Ok(()) + } + + // Migrate old format to new format + fn migrate(&mut self) { + if self.swipes.is_empty() && !self.content.is_empty() { + self.swipes = vec![self.content.clone()]; + self.current_swipe = 0; + } + } } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -144,7 +200,12 @@ fn save_config(config: &ApiConfig) -> Result<(), String> { fn load_history(character_id: &str) -> ChatHistory { let path = get_character_history_path(character_id); if let Ok(contents) = fs::read_to_string(path) { - serde_json::from_str(&contents).unwrap_or(ChatHistory { messages: vec![] }) + let mut history: ChatHistory = serde_json::from_str(&contents).unwrap_or(ChatHistory { messages: vec![] }); + // Migrate old messages to new format + for msg in &mut history.messages { + msg.migrate(); + } + history } else { ChatHistory { messages: vec![] } } @@ -350,10 +411,7 @@ async fn chat(message: String) -> Result { let mut history = load_history(&character.id); // Add user message to history - history.messages.push(Message { - role: "user".to_string(), - content: message.clone(), - }); + history.messages.push(Message::new_user(message.clone())); let client = reqwest::Client::new(); let base = config.base_url.trim_end_matches('/'); @@ -363,12 +421,16 @@ async fn chat(message: String) -> Result { format!("{}/v1/chat/completions", base) }; - // Build messages with system prompt first - let mut api_messages = vec![Message { - role: "system".to_string(), - content: character.system_prompt.clone(), - }]; - api_messages.extend(history.messages.clone()); + // Build messages with system prompt first - use simple Message for API + let mut api_messages = vec![Message::new_user(character.system_prompt.clone())]; + api_messages[0].role = "system".to_string(); + + // Add history messages with current swipe content + for msg in &history.messages { + let mut api_msg = Message::new_user(msg.get_content().to_string()); + api_msg.role = msg.role.clone(); + api_messages.push(api_msg); + } let request = ChatRequest { model: config.model.clone(), @@ -401,10 +463,7 @@ async fn chat(message: String) -> Result { .ok_or_else(|| "No response content".to_string())?; // Add assistant message to history - history.messages.push(Message { - role: "assistant".to_string(), - content: assistant_message.clone(), - }); + history.messages.push(Message::new_assistant(assistant_message.clone())); // Save history save_history(&character.id, &history).ok(); @@ -419,10 +478,7 @@ async fn chat_stream(app_handle: tauri::AppHandle, message: String) -> Result Result Result Result<(), String> { save_history(&character.id, &history) } +#[tauri::command] +fn truncate_history_from(index: usize) -> Result<(), String> { + let character = get_active_character(); + let mut history = load_history(&character.id); + + if index < history.messages.len() { + history.messages.truncate(index); + save_history(&character.id, &history)?; + } + + Ok(()) +} + +#[tauri::command] +fn remove_last_assistant_message() -> Result { + let character = get_active_character(); + let mut history = load_history(&character.id); + + // Find and remove the last assistant message + if let Some(pos) = history.messages.iter().rposition(|m| m.role == "assistant") { + history.messages.remove(pos); + save_history(&character.id, &history)?; + } + + // Get the last user message + let last_user_msg = history.messages + .iter() + .rev() + .find(|m| m.role == "user") + .map(|m| m.get_content().to_string()) + .ok_or_else(|| "No user message found".to_string())?; + + Ok(last_user_msg) +} + +#[tauri::command] +fn get_last_user_message() -> Result { + let character = get_active_character(); + let history = load_history(&character.id); + + // Get the last user message without removing anything + let last_user_msg = history.messages + .iter() + .rev() + .find(|m| m.role == "user") + .map(|m| m.get_content().to_string()) + .ok_or_else(|| "No user message found".to_string())?; + + Ok(last_user_msg) +} + +#[tauri::command] +async fn generate_response_only() -> Result { + let config = load_config().ok_or_else(|| "API not configured".to_string())?; + let character = get_active_character(); + let history = load_history(&character.id); + + let client = reqwest::Client::new(); + let base = config.base_url.trim_end_matches('/'); + let url = if base.ends_with("/v1") { + format!("{}/chat/completions", base) + } else { + format!("{}/v1/chat/completions", base) + }; + + // Build messages with system prompt first + let mut api_messages = vec![Message::new_user(character.system_prompt.clone())]; + api_messages[0].role = "system".to_string(); + + // Add existing history (which already includes the user message) + for msg in &history.messages { + let mut api_msg = Message::new_user(msg.get_content().to_string()); + api_msg.role = msg.role.clone(); + api_messages.push(api_msg); + } + + let request = ChatRequest { + model: config.model.clone(), + max_tokens: 4096, + messages: api_messages, + }; + + let response = client + .post(&url) + .header("authorization", format!("Bearer {}", &config.api_key)) + .header("content-type", "application/json") + .json(&request) + .send() + .await + .map_err(|e| format!("Request failed: {}", e))?; + + if !response.status().is_success() { + return Err(format!("API error: {}", response.status())); + } + + let chat_response: ChatResponse = response + .json() + .await + .map_err(|e| format!("Parse error: {}", e))?; + + let assistant_message = chat_response + .choices + .first() + .map(|c| c.message.content.clone()) + .ok_or_else(|| "No response content".to_string())?; + + Ok(assistant_message) +} + +#[tauri::command] +async fn generate_response_stream(app_handle: tauri::AppHandle) -> Result { + let config = load_config().ok_or_else(|| "API not configured".to_string())?; + let character = get_active_character(); + let history = load_history(&character.id); + + let client = reqwest::Client::new(); + let base = config.base_url.trim_end_matches('/'); + let url = if base.ends_with("/v1") { + format!("{}/chat/completions", base) + } else { + format!("{}/v1/chat/completions", base) + }; + + // Build messages with system prompt first + let mut api_messages = vec![Message::new_user(character.system_prompt.clone())]; + api_messages[0].role = "system".to_string(); + + // Add existing history (which already includes the user message) + for msg in &history.messages { + let mut api_msg = Message::new_user(msg.get_content().to_string()); + api_msg.role = msg.role.clone(); + api_messages.push(api_msg); + } + + let request = StreamChatRequest { + model: config.model.clone(), + max_tokens: 4096, + messages: api_messages, + stream: true, + }; + + let response = client + .post(&url) + .header("authorization", format!("Bearer {}", &config.api_key)) + .header("content-type", "application/json") + .json(&request) + .send() + .await + .map_err(|e| format!("Request failed: {}", e))?; + + if !response.status().is_success() { + return Err(format!("API error: {}", response.status())); + } + + // Process streaming response + let mut full_content = String::new(); + let mut stream = response.bytes_stream(); + + let mut buffer = String::new(); + while let Some(chunk_result) = stream.next().await { + let chunk = chunk_result.map_err(|e| format!("Stream error: {}", e))?; + let chunk_str = String::from_utf8_lossy(&chunk); + buffer.push_str(&chunk_str); + + // Process complete lines + while let Some(line_end) = buffer.find('\n') { + let line = buffer[..line_end].trim().to_string(); + buffer = buffer[line_end + 1..].to_string(); + + // Parse SSE data lines + if line.starts_with("data: ") { + let data = &line[6..]; + + // Check for stream end + if data == "[DONE]" { + break; + } + + // Parse JSON and extract content + if let Ok(stream_response) = serde_json::from_str::(data) { + if let Some(choice) = stream_response.choices.first() { + if let Some(content) = &choice.delta.content { + full_content.push_str(content); + + // Emit token to frontend + let _ = app_handle.emit_to("main", "chat-token", content.clone()); + } + } + } + } + } + } + + // Emit completion event + let _ = app_handle.emit_to("main", "chat-complete", ()); + + Ok(full_content) +} + +#[derive(Debug, Serialize)] +struct SwipeInfo { + current: usize, + total: usize, + content: String, +} + +#[tauri::command] +fn add_swipe_to_last_assistant(content: String) -> Result { + let character = get_active_character(); + let mut history = load_history(&character.id); + + // Find the last assistant message + if let Some(pos) = history.messages.iter().rposition(|m| m.role == "assistant") { + history.messages[pos].add_swipe(content); + save_history(&character.id, &history)?; + + Ok(SwipeInfo { + current: history.messages[pos].current_swipe, + total: history.messages[pos].swipes.len(), + content: history.messages[pos].get_content().to_string(), + }) + } else { + Err("No assistant message found".to_string()) + } +} + +#[tauri::command] +fn navigate_swipe(message_index: usize, direction: i32) -> Result { + let character = get_active_character(); + let mut history = load_history(&character.id); + + if message_index >= history.messages.len() { + return Err("Invalid message index".to_string()); + } + + let msg = &mut history.messages[message_index]; + + if msg.swipes.is_empty() { + return Err("No swipes available".to_string()); + } + + let new_index = if direction > 0 { + // Swipe right (next) + (msg.current_swipe + 1).min(msg.swipes.len() - 1) + } else { + // Swipe left (previous) + msg.current_swipe.saturating_sub(1) + }; + + msg.set_swipe(new_index)?; + + // Extract values before saving + let current = msg.current_swipe; + let total = msg.swipes.len(); + let content = msg.get_content().to_string(); + + save_history(&character.id, &history)?; + + Ok(SwipeInfo { + current, + total, + content, + }) +} + +#[tauri::command] +fn get_swipe_info(message_index: usize) -> Result { + let character = get_active_character(); + let history = load_history(&character.id); + + if message_index >= history.messages.len() { + return Err("Invalid message index".to_string()); + } + + let msg = &history.messages[message_index]; + let current = msg.current_swipe; + let total = msg.swipes.len(); + let content = msg.get_content().to_string(); + + Ok(SwipeInfo { + current, + total, + content, + }) +} + #[tauri::command] fn create_character(name: String, system_prompt: String) -> Result { let new_id = Uuid::new_v4().to_string(); @@ -625,11 +968,19 @@ pub fn run() { .invoke_handler(tauri::generate_handler![ chat, chat_stream, + generate_response_only, + generate_response_stream, validate_api, save_api_config, get_api_config, get_chat_history, clear_chat_history, + truncate_history_from, + remove_last_assistant_message, + get_last_user_message, + add_swipe_to_last_assistant, + navigate_swipe, + get_swipe_info, get_character, update_character, upload_avatar, diff --git a/src/main.js b/src/main.js index 1039265..4101d7e 100644 --- a/src/main.js +++ b/src/main.js @@ -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 = ` + + `; + 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 = ` + + + `; + 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 = ` + + `; + 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 = ` + + `; + 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 = ` + + + `; + copyBtn.title = 'Copy code'; + copyBtn.addEventListener('click', () => { + navigator.clipboard.writeText(block.textContent); + copyBtn.innerHTML = ` + + `; + copyBtn.classList.add('copied'); + setTimeout(() => { + copyBtn.innerHTML = ` + + + `; + 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 = ` + + + `; + copyBtn.title = 'Copy code'; + copyBtn.addEventListener('click', () => { + navigator.clipboard.writeText(block.textContent); + copyBtn.innerHTML = ` + + `; + copyBtn.classList.add('copied'); + setTimeout(() => { + copyBtn.innerHTML = ` + + + `; + 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 = ` + + + `; + copyBtn.title = 'Copy code'; + copyBtn.addEventListener('click', () => { + navigator.clipboard.writeText(block.textContent); + copyBtn.innerHTML = ` + + `; + copyBtn.classList.add('copied'); + setTimeout(() => { + copyBtn.innerHTML = ` + + + `; + 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 = ` + + + `; + 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 = ` - - - `; - copyBtn.title = 'Copy code'; - copyBtn.addEventListener('click', () => { - navigator.clipboard.writeText(block.textContent); - copyBtn.innerHTML = ` - - `; - copyBtn.classList.add('copied'); - setTimeout(() => { - copyBtn.innerHTML = ` - - - `; - 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); diff --git a/src/styles.css b/src/styles.css index f6603ec..3f136f1 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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;