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:
@@ -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,
|
||||||
|
|||||||
773
src/main.js
773
src/main.js
@@ -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);
|
||||||
|
|||||||
177
src/styles.css
177
src/styles.css
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user