feat: implement enhanced message controls with regenerate any message

Added comprehensive message control features for fine-grained conversation management:

Backend (Rust):
- Extended Message struct with 'pinned' and 'hidden' boolean fields
- Added delete_message_at_index() command for removing any message
- Added toggle_message_pin() command to mark important messages
- Added toggle_message_hidden() command to temporarily hide messages
- Added continue_message() command to append AI continuations
- Added regenerate_at_index() command to regenerate ANY message (not just last)

Frontend (JavaScript):
- Added delete, pin, hide buttons to all messages (user & assistant)
- Added continue button for assistant messages
- Updated regenerate to work on any message, not just the last one
- Implemented state persistence for pinned/hidden in chat history
- Added dynamic icon changes for hide/unhide states
- Integrated with token counter for real-time updates

UI/UX (CSS):
- Pinned messages: accent-colored left border with glow effect
- Hidden messages: 40% opacity with blur effect (70% on hover)
- Delete button: red hover warning (#ef4444)
- Active state indicators for pin/hide buttons
- Always-visible controls on hidden messages for quick access

Features:
- Delete any message with confirmation dialog
- Pin messages to always keep them in context
- Hide messages with visual blur (still in context but dimmed)
- Continue incomplete assistant responses
- Regenerate any assistant message (creates new swipe)
- All states persist in chat history JSON

This completes Phase 3.2 "Enhanced Message Controls" from the roadmap,
providing users with complete control over their conversation history.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-10-16 17:19:07 -07:00
parent 9b4bc63e1a
commit b9230772ed
4 changed files with 573 additions and 27 deletions

View File

@@ -180,6 +180,10 @@ struct Message {
current_swipe: usize,
#[serde(default)]
timestamp: i64, // Unix timestamp in milliseconds
#[serde(default)]
pinned: bool, // Whether this message is pinned to always stay in context
#[serde(default)]
hidden: bool, // Whether this message is temporarily hidden from view
}
impl Message {
@@ -195,6 +199,8 @@ impl Message {
swipes: vec![content],
current_swipe: 0,
timestamp,
pinned: false,
hidden: false,
}
}
@@ -210,6 +216,8 @@ impl Message {
swipes: vec![content],
current_swipe: 0,
timestamp,
pinned: false,
hidden: false,
}
}
@@ -1632,6 +1640,242 @@ fn get_last_user_message() -> Result<String, String> {
Ok(last_user_msg)
}
#[tauri::command]
fn delete_message_at_index(message_index: usize) -> Result<(), String> {
let character = get_active_character();
let mut history = load_history(&character.id);
if message_index >= history.messages.len() {
return Err(format!("Message index {} out of bounds", message_index));
}
history.messages.remove(message_index);
save_history(&character.id, &history)?;
Ok(())
}
#[tauri::command]
fn toggle_message_pin(message_index: usize) -> Result<bool, String> {
let character = get_active_character();
let mut history = load_history(&character.id);
if message_index >= history.messages.len() {
return Err(format!("Message index {} out of bounds", message_index));
}
history.messages[message_index].pinned = !history.messages[message_index].pinned;
let new_state = history.messages[message_index].pinned;
save_history(&character.id, &history)?;
Ok(new_state)
}
#[tauri::command]
fn toggle_message_hidden(message_index: usize) -> Result<bool, String> {
let character = get_active_character();
let mut history = load_history(&character.id);
if message_index >= history.messages.len() {
return Err(format!("Message index {} out of bounds", message_index));
}
history.messages[message_index].hidden = !history.messages[message_index].hidden;
let new_state = history.messages[message_index].hidden;
save_history(&character.id, &history)?;
Ok(new_state)
}
#[tauri::command]
async fn continue_message(message_index: usize) -> Result<String, String> {
let config = load_config().ok_or_else(|| "API not configured".to_string())?;
let character = get_active_character();
let mut history = load_history(&character.id);
if message_index >= history.messages.len() {
return Err(format!("Message index {} out of bounds", message_index));
}
// Make sure we're continuing an assistant message
if history.messages[message_index].role != "assistant" {
return Err("Can only continue assistant messages".to_string());
}
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)
};
// Load roleplay settings and build context up to the message we're continuing
let roleplay_settings = load_roleplay_settings(&character.id);
let messages_up_to = &history.messages[..=message_index];
let (system_additions, authors_note, note_depth) = build_roleplay_context(&character, messages_up_to, &roleplay_settings);
// Build API messages (same pattern as generate_response_only)
let processed_system_prompt = replace_template_variables(&character.system_prompt, &character, &roleplay_settings);
let enhanced_system_prompt = format!("{}{}", processed_system_prompt, system_additions);
let mut api_messages = vec![Message::new_user(enhanced_system_prompt)];
api_messages[0].role = "system".to_string();
// Add existing history up to the message we're continuing
for msg in messages_up_to {
let mut api_msg = Message::new_user(msg.get_content().to_string());
api_msg.role = msg.role.clone();
api_messages.push(api_msg);
}
// Insert Author's Note
if let Some(note) = authors_note {
if api_messages.len() > (note_depth + 1) {
let insert_pos = api_messages.len().saturating_sub(note_depth);
let mut note_msg = Message::new_user(note);
note_msg.role = "system".to_string();
api_messages.insert(insert_pos, note_msg);
}
}
// Convert to API format
let api_request = ChatRequest {
model: config.model.clone(),
messages: api_messages,
max_tokens: 4096,
};
let response = client
.post(&url)
.header("Content-Type", "application/json")
.header("Authorization", format!("Bearer {}", config.api_key))
.json(&api_request)
.send()
.await
.map_err(|e| format!("Request failed: {}", e))?;
if !response.status().is_success() {
let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
return Err(format!("API error: {}", error_text));
}
let response_json: ChatResponse = response.json().await
.map_err(|e| format!("Failed to parse response: {}", e))?;
let content = response_json.choices.first()
.map(|c| &c.message.content)
.ok_or_else(|| "No content in response".to_string())?
.to_string();
// Append the new content to the existing message
let current_content = history.messages[message_index].get_content().to_string();
let continued_content = format!("{}{}", current_content, content);
// Update the current swipe
let swipe_index = history.messages[message_index].current_swipe;
history.messages[message_index].swipes[swipe_index] = continued_content.clone();
history.messages[message_index].content = continued_content.clone();
save_history(&character.id, &history)?;
Ok(content)
}
#[tauri::command]
async fn regenerate_at_index(message_index: usize) -> Result<SwipeInfo, String> {
let config = load_config().ok_or_else(|| "API not configured".to_string())?;
let character = get_active_character();
let mut history = load_history(&character.id);
if message_index >= history.messages.len() {
return Err(format!("Message index {} out of bounds", message_index));
}
// Make sure we're regenerating an assistant message
if history.messages[message_index].role != "assistant" {
return Err("Can only regenerate assistant messages".to_string());
}
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)
};
// Load roleplay settings and build context up to (but not including) the message we're regenerating
let roleplay_settings = load_roleplay_settings(&character.id);
let messages_before = &history.messages[..message_index];
let (system_additions, authors_note, note_depth) = build_roleplay_context(&character, messages_before, &roleplay_settings);
// Build API messages
let processed_system_prompt = replace_template_variables(&character.system_prompt, &character, &roleplay_settings);
let enhanced_system_prompt = format!("{}{}", processed_system_prompt, system_additions);
let mut api_messages = vec![Message::new_user(enhanced_system_prompt)];
api_messages[0].role = "system".to_string();
// Add existing history up to the message we're regenerating
for msg in messages_before {
let mut api_msg = Message::new_user(msg.get_content().to_string());
api_msg.role = msg.role.clone();
api_messages.push(api_msg);
}
// Insert Author's Note
if let Some(note) = authors_note {
if api_messages.len() > (note_depth + 1) {
let insert_pos = api_messages.len().saturating_sub(note_depth);
let mut note_msg = Message::new_user(note);
note_msg.role = "system".to_string();
api_messages.insert(insert_pos, note_msg);
}
}
// Convert to API format
let api_request = ChatRequest {
model: config.model.clone(),
messages: api_messages,
max_tokens: 4096,
};
let response = client
.post(&url)
.header("Content-Type", "application/json")
.header("Authorization", format!("Bearer {}", config.api_key))
.json(&api_request)
.send()
.await
.map_err(|e| format!("Request failed: {}", e))?;
if !response.status().is_success() {
let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
return Err(format!("API error: {}", error_text));
}
let response_json: ChatResponse = response.json().await
.map_err(|e| format!("Failed to parse response: {}", e))?;
let content = response_json.choices.first()
.map(|c| &c.message.content)
.ok_or_else(|| "No content in response".to_string())?
.to_string();
// Add as a new swipe to this message
history.messages[message_index].swipes.push(content.clone());
let new_swipe_index = history.messages[message_index].swipes.len() - 1;
history.messages[message_index].current_swipe = new_swipe_index;
history.messages[message_index].content = content.clone();
save_history(&character.id, &history)?;
Ok(SwipeInfo {
content,
current: new_swipe_index,
total: history.messages[message_index].swipes.len(),
})
}
#[tauri::command]
async fn generate_response_only() -> Result<String, String> {
let config = load_config().ok_or_else(|| "API not configured".to_string())?;
@@ -2938,6 +3182,11 @@ pub fn run() {
truncate_history_from,
remove_last_assistant_message,
get_last_user_message,
delete_message_at_index,
toggle_message_pin,
toggle_message_hidden,
continue_message,
regenerate_at_index,
add_swipe_to_last_assistant,
navigate_swipe,
get_swipe_info,