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

@@ -30,7 +30,63 @@ struct Character {
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
struct Message { struct Message {
role: String, role: String,
content: String, #[serde(default)]
content: String, // Keep for backward compatibility
#[serde(default)]
swipes: Vec<String>,
#[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)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -144,7 +200,12 @@ fn save_config(config: &ApiConfig) -> Result<(), String> {
fn load_history(character_id: &str) -> ChatHistory { fn load_history(character_id: &str) -> ChatHistory {
let path = get_character_history_path(character_id); let path = get_character_history_path(character_id);
if let Ok(contents) = fs::read_to_string(path) { 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 { } else {
ChatHistory { messages: vec![] } ChatHistory { messages: vec![] }
} }
@@ -350,10 +411,7 @@ async fn chat(message: String) -> Result<String, String> {
let mut history = load_history(&character.id); let mut history = load_history(&character.id);
// Add user message to history // Add user message to history
history.messages.push(Message { history.messages.push(Message::new_user(message.clone()));
role: "user".to_string(),
content: message.clone(),
});
let client = reqwest::Client::new(); let client = reqwest::Client::new();
let base = config.base_url.trim_end_matches('/'); let base = config.base_url.trim_end_matches('/');
@@ -363,12 +421,16 @@ async fn chat(message: String) -> Result<String, String> {
format!("{}/v1/chat/completions", base) format!("{}/v1/chat/completions", base)
}; };
// Build messages with system prompt first // Build messages with system prompt first - use simple Message for API
let mut api_messages = vec![Message { let mut api_messages = vec![Message::new_user(character.system_prompt.clone())];
role: "system".to_string(), api_messages[0].role = "system".to_string();
content: character.system_prompt.clone(),
}]; // Add history messages with current swipe content
api_messages.extend(history.messages.clone()); 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 { let request = ChatRequest {
model: config.model.clone(), model: config.model.clone(),
@@ -401,10 +463,7 @@ async fn chat(message: String) -> Result<String, String> {
.ok_or_else(|| "No response content".to_string())?; .ok_or_else(|| "No response content".to_string())?;
// Add assistant message to history // Add assistant message to history
history.messages.push(Message { history.messages.push(Message::new_assistant(assistant_message.clone()));
role: "assistant".to_string(),
content: assistant_message.clone(),
});
// Save history // Save history
save_history(&character.id, &history).ok(); save_history(&character.id, &history).ok();
@@ -419,10 +478,7 @@ async fn chat_stream(app_handle: tauri::AppHandle, message: String) -> Result<St
let mut history = load_history(&character.id); let mut history = load_history(&character.id);
// Add user message to history // Add user message to history
history.messages.push(Message { history.messages.push(Message::new_user(message.clone()));
role: "user".to_string(),
content: message.clone(),
});
let client = reqwest::Client::new(); let client = reqwest::Client::new();
let base = config.base_url.trim_end_matches('/'); let base = config.base_url.trim_end_matches('/');
@@ -432,12 +488,16 @@ async fn chat_stream(app_handle: tauri::AppHandle, message: String) -> Result<St
format!("{}/v1/chat/completions", base) format!("{}/v1/chat/completions", base)
}; };
// Build messages with system prompt first // Build messages with system prompt first - use simple Message for API
let mut api_messages = vec![Message { let mut api_messages = vec![Message::new_user(character.system_prompt.clone())];
role: "system".to_string(), api_messages[0].role = "system".to_string();
content: character.system_prompt.clone(),
}]; // Add history messages with current swipe content
api_messages.extend(history.messages.clone()); 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 { let request = StreamChatRequest {
model: config.model.clone(), model: config.model.clone(),
@@ -499,10 +559,7 @@ async fn chat_stream(app_handle: tauri::AppHandle, message: String) -> Result<St
} }
// Add assistant message to history // Add assistant message to history
history.messages.push(Message { history.messages.push(Message::new_assistant(full_content.clone()));
role: "assistant".to_string(),
content: full_content.clone(),
});
// Save history // Save history
save_history(&character.id, &history).ok(); save_history(&character.id, &history).ok();
@@ -526,6 +583,292 @@ fn clear_chat_history() -> Result<(), String> {
save_history(&character.id, &history) 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<String, String> {
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<String, String> {
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<String, String> {
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<String, String> {
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::<StreamResponse>(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<SwipeInfo, String> {
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<SwipeInfo, String> {
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<SwipeInfo, String> {
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] #[tauri::command]
fn create_character(name: String, system_prompt: String) -> Result<Character, String> { fn create_character(name: String, system_prompt: String) -> Result<Character, String> {
let new_id = Uuid::new_v4().to_string(); let new_id = Uuid::new_v4().to_string();
@@ -625,11 +968,19 @@ pub fn run() {
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
chat, chat,
chat_stream, chat_stream,
generate_response_only,
generate_response_stream,
validate_api, validate_api,
save_api_config, save_api_config,
get_api_config, get_api_config,
get_chat_history, get_chat_history,
clear_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, get_character,
update_character, update_character,
upload_avatar, upload_avatar,

View File

@@ -80,7 +80,7 @@ function autoResize(textarea) {
} }
// Add message to chat // Add message to chat
function addMessage(content, isUser = false) { function addMessage(content, isUser = false, skipActions = false) {
const messageDiv = document.createElement('div'); const messageDiv = document.createElement('div');
messageDiv.className = `message ${isUser ? 'user' : 'assistant'}`; messageDiv.className = `message ${isUser ? 'user' : 'assistant'}`;
@@ -143,12 +143,605 @@ function addMessage(content, isUser = false) {
}); });
} }
if (!isUser) { // Build message structure
messageDiv.appendChild(avatar); 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(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.appendChild(messageDiv);
messagesContainer.scrollTop = messagesContainer.scrollHeight; 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 // Show typing indicator
@@ -223,159 +816,10 @@ async function handleSubmit(e) {
const message = messageInput.value.trim(); const message = messageInput.value.trim();
if (!message) return; if (!message) return;
addMessage(message, true);
messageInput.value = ''; messageInput.value = '';
autoResize(messageInput); autoResize(messageInput);
sendBtn.disabled = true; await sendMessage(message);
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();
}
}
} }
// Settings functionality // Settings functionality
@@ -452,7 +896,7 @@ async function handleSaveSettings(e) {
setTimeout(() => { setTimeout(() => {
hideSettings(); hideSettings();
messagesContainer.innerHTML = ''; messagesContainer.innerHTML = '';
addMessage('API configured. Ready to chat.', false); addMessage('API configured. Ready to chat.', false, true);
}, 1000); }, 1000);
} catch (error) { } catch (error) {
validationMsg.textContent = `Failed to save: ${error}`; validationMsg.textContent = `Failed to save: ${error}`;
@@ -641,19 +1085,24 @@ async function loadChatHistory() {
if (history.length === 0) { if (history.length === 0) {
if (currentCharacter && currentCharacter.greeting) { if (currentCharacter && currentCharacter.greeting) {
addMessage(currentCharacter.greeting, false); addMessage(currentCharacter.greeting, false, true);
} else { } else {
addMessage('API configured. Ready to chat.', false); addMessage('API configured. Ready to chat.', false, true);
} }
} else { } else {
history.forEach(msg => { history.forEach((msg, index) => {
addMessage(msg.content, msg.role === 'user'); 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) { } catch (error) {
console.error('Failed to load chat history:', error); console.error('Failed to load chat history:', error);
messagesContainer.innerHTML = ''; 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'); await invoke('clear_chat_history');
messagesContainer.innerHTML = ''; messagesContainer.innerHTML = '';
if (currentCharacter && currentCharacter.greeting) { if (currentCharacter && currentCharacter.greeting) {
addMessage(currentCharacter.greeting, false); addMessage(currentCharacter.greeting, false, true);
} else { } else {
addMessage('Conversation cleared. Ready to chat.', false); addMessage('Conversation cleared. Ready to chat.', false, true);
} }
} catch (error) { } catch (error) {
addMessage(`Failed to clear history: ${error}`, false); addMessage(`Failed to clear history: ${error}`, false);

View File

@@ -231,6 +231,7 @@ body {
.message { .message {
display: flex; display: flex;
animation: slideIn 0.3s ease; animation: slideIn 0.3s ease;
position: relative;
} }
@keyframes slideIn { @keyframes slideIn {
@@ -286,6 +287,182 @@ body {
margin-top: 12px; 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 { .message-content code {
background: rgba(0, 0, 0, 0.3); background: rgba(0, 0, 0, 0.3);
padding: 2px 6px; padding: 2px 6px;