use serde::{Deserialize, Serialize}; use std::fs; use std::path::PathBuf; use std::io::BufWriter; use uuid::Uuid; use futures::StreamExt; use tauri::Emitter; use base64::Engine; #[derive(Debug, Clone, Serialize, Deserialize)] struct ApiConfig { base_url: String, api_key: String, model: String, #[serde(default)] active_character_id: Option, #[serde(default)] stream: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] struct Character { id: String, name: String, avatar_path: Option, system_prompt: String, greeting: Option, personality: Option, created_at: i64, // V2 character card fields #[serde(default)] description: Option, #[serde(default)] scenario: Option, #[serde(default)] mes_example: Option, #[serde(default)] post_history_instructions: Option, #[serde(default)] alternate_greetings: Vec, #[serde(default)] character_book: Option, #[serde(default)] tags: Vec, #[serde(default)] creator: Option, #[serde(default)] character_version: Option, #[serde(default)] creator_notes: Option, #[serde(default)] extensions: serde_json::Value, } // V2/V3 character card specification structs #[derive(Debug, Serialize, Deserialize)] struct CharacterCardV2 { spec: String, spec_version: String, data: CharacterCardV2Data, } // V3 card format (fields at top level + data object) #[derive(Debug, Serialize, Deserialize)] struct CharacterCardV3 { spec: String, spec_version: String, name: String, #[serde(default)] description: Option, #[serde(default)] personality: Option, #[serde(default)] scenario: Option, #[serde(default)] first_mes: Option, #[serde(default)] mes_example: Option, #[serde(default)] data: serde_json::Value, // V3 has additional data nested here #[serde(default)] tags: Vec, #[serde(default)] extensions: serde_json::Value, } impl From for CharacterCardV2Data { fn from(v3: CharacterCardV3) -> Self { Self { name: v3.name, description: v3.description, personality: v3.personality, scenario: v3.scenario, first_mes: v3.first_mes, mes_example: v3.mes_example, system_prompt: v3.data.get("system_prompt").and_then(|v| v.as_str()).map(String::from), post_history_instructions: v3.data.get("post_history_instructions").and_then(|v| v.as_str()).map(String::from), alternate_greetings: v3.data.get("alternate_greetings") .and_then(|v| v.as_array()) .map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect()) .unwrap_or_default(), character_book: v3.data.get("character_book").cloned(), tags: v3.tags, creator: v3.data.get("creator").and_then(|v| v.as_str()).map(String::from), character_version: v3.data.get("character_version").and_then(|v| v.as_str()).map(String::from), creator_notes: v3.data.get("creator_notes").and_then(|v| v.as_str()).map(String::from), extensions: v3.extensions, } } } #[derive(Debug, Serialize, Deserialize)] struct CharacterCardV2Data { name: String, #[serde(skip_serializing_if = "Option::is_none")] description: Option, #[serde(skip_serializing_if = "Option::is_none")] personality: Option, #[serde(skip_serializing_if = "Option::is_none")] scenario: Option, #[serde(skip_serializing_if = "Option::is_none")] first_mes: Option, #[serde(skip_serializing_if = "Option::is_none")] mes_example: Option, #[serde(skip_serializing_if = "Option::is_none")] system_prompt: Option, #[serde(skip_serializing_if = "Option::is_none")] post_history_instructions: Option, #[serde(skip_serializing_if = "Vec::is_empty", default)] alternate_greetings: Vec, #[serde(skip_serializing_if = "Option::is_none")] character_book: Option, #[serde(skip_serializing_if = "Vec::is_empty", default)] tags: Vec, #[serde(skip_serializing_if = "Option::is_none")] creator: Option, #[serde(skip_serializing_if = "Option::is_none")] character_version: Option, #[serde(skip_serializing_if = "Option::is_none")] creator_notes: Option, #[serde(default)] extensions: serde_json::Value, } impl From for CharacterCardV2Data { fn from(character: Character) -> Self { Self { name: character.name, description: character.description, personality: character.personality, scenario: character.scenario, first_mes: character.greeting, mes_example: character.mes_example, system_prompt: Some(character.system_prompt), post_history_instructions: character.post_history_instructions, alternate_greetings: character.alternate_greetings, character_book: character.character_book, tags: character.tags, creator: character.creator, character_version: character.character_version, creator_notes: character.creator_notes, extensions: character.extensions, } } } #[derive(Debug, Clone, Serialize, Deserialize)] struct Message { role: String, #[serde(default)] content: String, // Keep for backward compatibility #[serde(default)] swipes: Vec, #[serde(default)] current_swipe: usize, #[serde(default)] timestamp: i64, // Unix timestamp in milliseconds } impl Message { fn new_user(content: String) -> Self { let timestamp = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_millis() as i64; Self { role: "user".to_string(), content: content.clone(), swipes: vec![content], current_swipe: 0, timestamp, } } fn new_assistant(content: String) -> Self { let timestamp = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_millis() as i64; Self { role: "assistant".to_string(), content: content.clone(), swipes: vec![content], current_swipe: 0, timestamp, } } 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)] struct ChatHistory { messages: Vec, } #[derive(Debug, Serialize, Deserialize)] struct ChatRequest { model: String, max_tokens: u32, messages: Vec, } #[derive(Debug, Serialize, Deserialize)] struct ChatResponse { choices: Vec, } #[derive(Debug, Serialize, Deserialize)] struct Choice { message: ResponseMessage, } #[derive(Debug, Serialize, Deserialize)] struct ResponseMessage { content: String, } #[derive(Debug, Serialize, Deserialize)] struct Model { id: String, } #[derive(Debug, Serialize, Deserialize)] struct ModelsResponse { data: Vec, } #[derive(Debug, Serialize, Deserialize)] struct StreamChatRequest { model: String, max_tokens: u32, messages: Vec, stream: bool, } #[derive(Debug, Serialize, Deserialize)] struct StreamChoice { delta: Delta, } #[derive(Debug, Serialize, Deserialize)] struct Delta { #[serde(default)] content: Option, } #[derive(Debug, Serialize, Deserialize)] struct StreamResponse { choices: Vec, } fn get_config_path() -> PathBuf { let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()); PathBuf::from(home).join(".config/claudia/config.json") } fn get_characters_dir() -> PathBuf { let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()); PathBuf::from(home).join(".config/claudia/characters") } fn get_character_path(character_id: &str) -> PathBuf { get_characters_dir().join(format!("{}.json", character_id)) } fn get_character_history_path(character_id: &str) -> PathBuf { let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()); PathBuf::from(home).join(format!(".config/claudia/history_{}.json", character_id)) } fn get_avatars_dir() -> PathBuf { let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()); PathBuf::from(home).join(".config/claudia/avatars") } fn get_avatar_path(filename: &str) -> PathBuf { get_avatars_dir().join(filename) } // PNG Character Card Utilities // Manual PNG chunk parser - more reliable than relying on png crate's text chunk exposure fn read_png_text_chunks(png_path: &PathBuf) -> Result, String> { use std::io::Read; let mut file = fs::File::open(png_path) .map_err(|e| format!("Failed to open PNG file: {}", e))?; // Read and verify PNG signature let mut signature = [0u8; 8]; file.read_exact(&mut signature) .map_err(|e| format!("Failed to read PNG signature: {}", e))?; if &signature != b"\x89PNG\r\n\x1a\n" { return Err("Not a valid PNG file".to_string()); } let mut text_chunks = std::collections::HashMap::new(); let mut chunk_buffer = Vec::new(); loop { // Read chunk length (4 bytes, big-endian) let mut length_bytes = [0u8; 4]; if file.read_exact(&mut length_bytes).is_err() { break; // End of file } let length = u32::from_be_bytes(length_bytes) as usize; // Read chunk type (4 bytes) let mut chunk_type = [0u8; 4]; file.read_exact(&mut chunk_type) .map_err(|e| format!("Failed to read chunk type: {}", e))?; // Read chunk data chunk_buffer.clear(); chunk_buffer.resize(length, 0); file.read_exact(&mut chunk_buffer) .map_err(|e| format!("Failed to read chunk data: {}", e))?; // Read CRC (4 bytes, we don't verify it) let mut crc = [0u8; 4]; file.read_exact(&mut crc) .map_err(|e| format!("Failed to read CRC: {}", e))?; // Process tEXt chunks if &chunk_type == b"tEXt" { // tEXt format: keyword\0text if let Some(null_pos) = chunk_buffer.iter().position(|&b| b == 0) { let keyword = String::from_utf8_lossy(&chunk_buffer[..null_pos]).to_string(); let text = String::from_utf8_lossy(&chunk_buffer[null_pos + 1..]).to_string(); eprintln!("Found tEXt chunk: keyword='{}', text_len={}", keyword, text.len()); text_chunks.insert(keyword, text); } } // Stop at IEND chunk if &chunk_type == b"IEND" { break; } } eprintln!("Total tEXt chunks found: {}", text_chunks.len()); Ok(text_chunks) } fn read_character_card_from_png(png_path: &PathBuf) -> Result { eprintln!("Reading character card from: {}", png_path.display()); // Use manual chunk parser - more reliable than png crate let text_chunks = read_png_text_chunks(png_path)?; // Look for "chara" chunk let chara_text = text_chunks.get("chara") .ok_or_else(|| { eprintln!("Available chunks: {:?}", text_chunks.keys().collect::>()); "No character card data found in PNG (missing 'chara' chunk)".to_string() })?; // Base64 decode let json_bytes = base64::engine::general_purpose::STANDARD.decode(&chara_text) .map_err(|e| format!("Failed to decode base64: {}", e))?; // Convert to UTF-8 string let json_str = String::from_utf8(json_bytes) .map_err(|e| format!("Invalid UTF-8 in character data: {}", e))?; // First try to parse as generic value to check spec version let generic: serde_json::Value = serde_json::from_str(&json_str) .map_err(|e| format!("Failed to parse character card JSON: {}", e))?; let spec = generic.get("spec") .and_then(|v| v.as_str()) .ok_or_else(|| "No spec field in character card".to_string())?; match spec { "chara_card_v2" => { // Parse as V2 card let card: CharacterCardV2 = serde_json::from_value(generic) .map_err(|e| format!("Failed to parse V2 card: {}", e))?; Ok(card.data) } "chara_card_v3" => { // Parse as V3 card and convert to V2Data let card: CharacterCardV3 = serde_json::from_value(generic) .map_err(|e| format!("Failed to parse V3 card: {}", e))?; Ok(CharacterCardV2Data::from(card)) } _ => Err(format!("Unsupported character card spec: {}", spec)) } } fn write_character_card_to_png( character: &Character, source_png_path: &PathBuf, output_png_path: &PathBuf, ) -> Result<(), String> { use image::io::Reader as ImageReader; use png::{Encoder, ColorType, BitDepth, Compression}; // Load the source image let img = ImageReader::open(source_png_path) .map_err(|e| format!("Failed to open source image: {}", e))? .decode() .map_err(|e| format!("Failed to decode image: {}", e))?; let rgba = img.to_rgba8(); let (width, height) = (rgba.width(), rgba.height()); // Build V2 card let card = CharacterCardV2 { spec: "chara_card_v2".to_string(), spec_version: "2.0".to_string(), data: CharacterCardV2Data::from(character.clone()), }; // Serialize to JSON let json_str = serde_json::to_string(&card) .map_err(|e| format!("Failed to serialize character card: {}", e))?; // Base64 encode let b64_data = base64::engine::general_purpose::STANDARD.encode(json_str.as_bytes()); // Create output file let file = fs::File::create(output_png_path) .map_err(|e| format!("Failed to create output file: {}", e))?; let w = BufWriter::new(file); // Create PNG encoder let mut encoder = Encoder::new(w, width, height); encoder.set_color(ColorType::Rgba); encoder.set_depth(BitDepth::Eight); encoder.set_compression(Compression::Default); // Add character data as tEXt chunk encoder.add_text_chunk("chara".to_string(), b64_data) .map_err(|e| format!("Failed to add text chunk: {}", e))?; // Write PNG let mut writer = encoder.write_header() .map_err(|e| format!("Failed to write PNG header: {}", e))?; writer.write_image_data(rgba.as_raw()) .map_err(|e| format!("Failed to write image data: {}", e))?; writer.finish() .map_err(|e| format!("Failed to finish writing PNG: {}", e))?; Ok(()) } fn create_placeholder_png(output_path: &PathBuf, character_name: &str) -> Result<(), String> { use image::{ImageBuffer, Rgba}; // Create a 512x512 placeholder image with gradient let width = 512; let height = 512; let mut img = ImageBuffer::new(width, height); for (x, y, pixel) in img.enumerate_pixels_mut() { // Create a simple gradient based on character name hash let name_hash = character_name.bytes().fold(0u32, |acc, b| acc.wrapping_add(b as u32)); let r = ((name_hash % 200) + 55) as u8; let g = ((x + y + name_hash) % 200 + 55) as u8; let b = ((x.wrapping_mul(2) + y.wrapping_mul(3) + name_hash) % 200 + 55) as u8; *pixel = Rgba([r, g, b, 255]); } img.save(output_path) .map_err(|e| format!("Failed to save placeholder image: {}", e))?; Ok(()) } fn load_config() -> Option { let path = get_config_path(); if let Ok(contents) = fs::read_to_string(path) { serde_json::from_str(&contents).ok() } else { None } } fn save_config(config: &ApiConfig) -> Result<(), String> { let path = get_config_path(); if let Some(parent) = path.parent() { fs::create_dir_all(parent).map_err(|e| e.to_string())?; } let contents = serde_json::to_string_pretty(config).map_err(|e| e.to_string())?; fs::write(path, contents).map_err(|e| e.to_string())?; Ok(()) } fn load_history(character_id: &str) -> ChatHistory { let path = get_character_history_path(character_id); if let Ok(contents) = fs::read_to_string(path) { let mut history: ChatHistory = serde_json::from_str(&contents).unwrap_or(ChatHistory { messages: vec![] }); // Migrate old messages to new format for msg in &mut history.messages { msg.migrate(); } history } else { ChatHistory { messages: vec![] } } } fn save_history(character_id: &str, history: &ChatHistory) -> Result<(), String> { let path = get_character_history_path(character_id); if let Some(parent) = path.parent() { fs::create_dir_all(parent).map_err(|e| e.to_string())?; } let contents = serde_json::to_string_pretty(history).map_err(|e| e.to_string())?; fs::write(path, contents).map_err(|e| e.to_string())?; Ok(()) } fn load_character(character_id: &str) -> Option { let path = get_character_path(character_id); if let Ok(contents) = fs::read_to_string(path) { serde_json::from_str(&contents).ok() } else { None } } fn save_character(character: &Character) -> Result<(), String> { let dir = get_characters_dir(); fs::create_dir_all(&dir).map_err(|e| e.to_string())?; let path = get_character_path(&character.id); let contents = serde_json::to_string_pretty(character).map_err(|e| e.to_string())?; fs::write(path, contents).map_err(|e| e.to_string())?; Ok(()) } fn create_default_character() -> Character { Character { id: "default".to_string(), name: "Assistant".to_string(), avatar_path: None, system_prompt: "You are a helpful AI assistant. Be friendly, concise, and informative.".to_string(), greeting: Some("Hello! How can I help you today?".to_string()), personality: Some("helpful, friendly, knowledgeable".to_string()), created_at: std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_secs() as i64, description: None, scenario: None, mes_example: None, post_history_instructions: None, alternate_greetings: Vec::new(), character_book: None, tags: Vec::new(), creator: None, character_version: None, creator_notes: None, extensions: serde_json::Value::Object(serde_json::Map::new()), } } fn get_active_character() -> Character { // Try to load active character from config if let Some(config) = load_config() { if let Some(character_id) = config.active_character_id { if let Some(character) = load_character(&character_id) { return character; } } } // Try to load default character if let Some(character) = load_character("default") { return character; } // Create and save default character let character = create_default_character(); save_character(&character).ok(); character } #[tauri::command] fn get_character() -> Result { Ok(get_active_character()) } #[tauri::command] fn update_character( name: String, system_prompt: String, greeting: Option, personality: Option, description: Option, scenario: Option, mes_example: Option, post_history: Option, alt_greetings: Option>, tags: Option>, creator: Option, character_version: Option, creator_notes: Option, avatar_path: Option, ) -> Result<(), String> { let mut character = get_active_character(); character.name = name; character.system_prompt = system_prompt; character.greeting = greeting; character.personality = personality; character.description = description; character.scenario = scenario; character.mes_example = mes_example; character.post_history_instructions = post_history; character.alternate_greetings = alt_greetings.unwrap_or_default(); character.tags = tags.unwrap_or_default(); character.creator = creator; character.character_version = character_version; character.creator_notes = creator_notes; character.avatar_path = avatar_path; save_character(&character) } #[tauri::command] fn upload_avatar(source_path: String, character_id: String) -> Result { // Create avatars directory if it doesn't exist let avatars_dir = get_avatars_dir(); fs::create_dir_all(&avatars_dir).map_err(|e| e.to_string())?; // Get file extension let source = PathBuf::from(&source_path); let extension = source .extension() .and_then(|s| s.to_str()) .ok_or_else(|| "Invalid file extension".to_string())?; // Create unique filename: character_id + extension let filename = format!("{}.{}", character_id, extension); let dest_path = get_avatar_path(&filename); // Copy file fs::copy(&source, &dest_path).map_err(|e| format!("Failed to copy file: {}", e))?; Ok(filename) } #[tauri::command] async fn select_and_upload_avatar(app_handle: tauri::AppHandle, character_id: String) -> Result { use tauri_plugin_dialog::DialogExt; // Open file dialog let file_path = app_handle .dialog() .file() .add_filter("Images", &["png", "jpg", "jpeg", "webp"]) .blocking_pick_file(); if let Some(path) = file_path { // Upload the selected file let path_str = path.as_path() .ok_or_else(|| "Could not get file path".to_string())? .to_string_lossy() .to_string(); upload_avatar(path_str, character_id) } else { Err("No file selected".to_string()) } } #[tauri::command] fn get_avatar_full_path(avatar_filename: String) -> Result { let path = get_avatar_path(&avatar_filename); if path.exists() { Ok(path.to_string_lossy().to_string()) } else { Err("Avatar file not found".to_string()) } } #[tauri::command] async fn validate_api(base_url: String, api_key: String) -> Result, String> { let client = reqwest::Client::new(); let base = base_url.trim_end_matches('/'); let url = if base.ends_with("/v1") { format!("{}/models", base) } else { format!("{}/v1/models", base) }; let response = client .get(&url) .header("authorization", format!("Bearer {}", &api_key)) .send() .await .map_err(|e| format!("Connection failed: {}", e))?; if !response.status().is_success() { return Err(format!("API returned status: {}", response.status())); } let models: ModelsResponse = response .json() .await .map_err(|e| format!("Invalid response: {}", e))?; Ok(models.data.into_iter().map(|m| m.id).collect()) } #[tauri::command] async fn save_api_config(base_url: String, api_key: String, model: String, stream: bool) -> Result<(), String> { // Preserve existing active_character_id if it exists let active_character_id = load_config().and_then(|c| c.active_character_id); let config = ApiConfig { base_url, api_key, model, active_character_id, stream, }; save_config(&config) } #[tauri::command] fn get_api_config() -> Result { println!("Getting API config..."); load_config().ok_or_else(|| "No config found".to_string()) } #[tauri::command] async fn chat(message: String) -> Result { let config = load_config().ok_or_else(|| "API not configured".to_string())?; let character = get_active_character(); let mut history = load_history(&character.id); // Add user message to history history.messages.push(Message::new_user(message.clone())); 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 - use simple Message for API let mut api_messages = vec![Message::new_user(character.system_prompt.clone())]; api_messages[0].role = "system".to_string(); // Add history messages with current swipe content for msg in &history.messages { let mut api_msg = Message::new_user(msg.get_content().to_string()); api_msg.role = msg.role.clone(); api_messages.push(api_msg); } let request = ChatRequest { model: config.model.clone(), 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())?; // Add assistant message to history history.messages.push(Message::new_assistant(assistant_message.clone())); // Save history save_history(&character.id, &history).ok(); Ok(assistant_message) } #[tauri::command] async fn chat_stream(app_handle: tauri::AppHandle, message: String) -> Result { let config = load_config().ok_or_else(|| "API not configured".to_string())?; let character = get_active_character(); let mut history = load_history(&character.id); // Add user message to history history.messages.push(Message::new_user(message.clone())); 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 - use simple Message for API let mut api_messages = vec![Message::new_user(character.system_prompt.clone())]; api_messages[0].role = "system".to_string(); // Add history messages with current swipe content for msg in &history.messages { let mut api_msg = Message::new_user(msg.get_content().to_string()); api_msg.role = msg.role.clone(); api_messages.push(api_msg); } let request = StreamChatRequest { model: config.model.clone(), max_tokens: 4096, messages: api_messages, stream: true, }; let response = client .post(&url) .header("authorization", format!("Bearer {}", &config.api_key)) .header("content-type", "application/json") .json(&request) .send() .await .map_err(|e| format!("Request failed: {}", e))?; if !response.status().is_success() { return Err(format!("API error: {}", response.status())); } // Process streaming response let mut full_content = String::new(); let mut stream = response.bytes_stream(); let mut buffer = String::new(); while let Some(chunk_result) = stream.next().await { let chunk = chunk_result.map_err(|e| format!("Stream error: {}", e))?; let chunk_str = String::from_utf8_lossy(&chunk); buffer.push_str(&chunk_str); // Process complete lines while let Some(line_end) = buffer.find('\n') { let line = buffer[..line_end].trim().to_string(); buffer = buffer[line_end + 1..].to_string(); // Parse SSE data lines if line.starts_with("data: ") { let data = &line[6..]; // Check for stream end if data == "[DONE]" { break; } // Parse JSON and extract content if let Ok(stream_response) = serde_json::from_str::(data) { if let Some(choice) = stream_response.choices.first() { if let Some(content) = &choice.delta.content { full_content.push_str(content); // Emit token to frontend let _ = app_handle.emit_to("main", "chat-token", content.clone()); } } } } } } // Add assistant message to history history.messages.push(Message::new_assistant(full_content.clone())); // Save history save_history(&character.id, &history).ok(); // Emit completion event let _ = app_handle.emit_to("main", "chat-complete", ()); Ok(full_content) } #[tauri::command] fn get_chat_history() -> Result, String> { let character = get_active_character(); Ok(load_history(&character.id).messages) } #[tauri::command] fn clear_chat_history() -> Result<(), String> { let character = get_active_character(); let history = ChatHistory { messages: vec![] }; save_history(&character.id, &history) } #[tauri::command] fn truncate_history_from(index: usize) -> Result<(), String> { let character = get_active_character(); let mut history = load_history(&character.id); if index < history.messages.len() { history.messages.truncate(index); save_history(&character.id, &history)?; } Ok(()) } #[tauri::command] fn remove_last_assistant_message() -> Result { let character = get_active_character(); let mut history = load_history(&character.id); // Find and remove the last assistant message if let Some(pos) = history.messages.iter().rposition(|m| m.role == "assistant") { history.messages.remove(pos); save_history(&character.id, &history)?; } // Get the last user message let last_user_msg = history.messages .iter() .rev() .find(|m| m.role == "user") .map(|m| m.get_content().to_string()) .ok_or_else(|| "No user message found".to_string())?; Ok(last_user_msg) } #[tauri::command] fn get_last_user_message() -> Result { let character = get_active_character(); let history = load_history(&character.id); // Get the last user message without removing anything let last_user_msg = history.messages .iter() .rev() .find(|m| m.role == "user") .map(|m| m.get_content().to_string()) .ok_or_else(|| "No user message found".to_string())?; Ok(last_user_msg) } #[tauri::command] async fn generate_response_only() -> Result { let config = load_config().ok_or_else(|| "API not configured".to_string())?; let character = get_active_character(); let history = load_history(&character.id); let client = reqwest::Client::new(); let base = config.base_url.trim_end_matches('/'); let url = if base.ends_with("/v1") { format!("{}/chat/completions", base) } else { format!("{}/v1/chat/completions", base) }; // Build messages with system prompt first let mut api_messages = vec![Message::new_user(character.system_prompt.clone())]; api_messages[0].role = "system".to_string(); // Add existing history (which already includes the user message) for msg in &history.messages { let mut api_msg = Message::new_user(msg.get_content().to_string()); api_msg.role = msg.role.clone(); api_messages.push(api_msg); } let request = ChatRequest { model: config.model.clone(), max_tokens: 4096, messages: api_messages, }; let response = client .post(&url) .header("authorization", format!("Bearer {}", &config.api_key)) .header("content-type", "application/json") .json(&request) .send() .await .map_err(|e| format!("Request failed: {}", e))?; if !response.status().is_success() { return Err(format!("API error: {}", response.status())); } let chat_response: ChatResponse = response .json() .await .map_err(|e| format!("Parse error: {}", e))?; let assistant_message = chat_response .choices .first() .map(|c| c.message.content.clone()) .ok_or_else(|| "No response content".to_string())?; Ok(assistant_message) } #[tauri::command] async fn generate_response_stream(app_handle: tauri::AppHandle) -> Result { let config = load_config().ok_or_else(|| "API not configured".to_string())?; let character = get_active_character(); let history = load_history(&character.id); let client = reqwest::Client::new(); let base = config.base_url.trim_end_matches('/'); let url = if base.ends_with("/v1") { format!("{}/chat/completions", base) } else { format!("{}/v1/chat/completions", base) }; // Build messages with system prompt first let mut api_messages = vec![Message::new_user(character.system_prompt.clone())]; api_messages[0].role = "system".to_string(); // Add existing history (which already includes the user message) for msg in &history.messages { let mut api_msg = Message::new_user(msg.get_content().to_string()); api_msg.role = msg.role.clone(); api_messages.push(api_msg); } let request = StreamChatRequest { model: config.model.clone(), max_tokens: 4096, messages: api_messages, stream: true, }; let response = client .post(&url) .header("authorization", format!("Bearer {}", &config.api_key)) .header("content-type", "application/json") .json(&request) .send() .await .map_err(|e| format!("Request failed: {}", e))?; if !response.status().is_success() { return Err(format!("API error: {}", response.status())); } // Process streaming response let mut full_content = String::new(); let mut stream = response.bytes_stream(); let mut buffer = String::new(); while let Some(chunk_result) = stream.next().await { let chunk = chunk_result.map_err(|e| format!("Stream error: {}", e))?; let chunk_str = String::from_utf8_lossy(&chunk); buffer.push_str(&chunk_str); // Process complete lines while let Some(line_end) = buffer.find('\n') { let line = buffer[..line_end].trim().to_string(); buffer = buffer[line_end + 1..].to_string(); // Parse SSE data lines if line.starts_with("data: ") { let data = &line[6..]; // Check for stream end if data == "[DONE]" { break; } // Parse JSON and extract content if let Ok(stream_response) = serde_json::from_str::(data) { if let Some(choice) = stream_response.choices.first() { if let Some(content) = &choice.delta.content { full_content.push_str(content); // Emit token to frontend let _ = app_handle.emit_to("main", "chat-token", content.clone()); } } } } } } // Emit completion event let _ = app_handle.emit_to("main", "chat-complete", ()); Ok(full_content) } #[derive(Debug, Serialize)] struct SwipeInfo { current: usize, total: usize, content: String, } #[tauri::command] fn add_swipe_to_last_assistant(content: String) -> Result { let character = get_active_character(); let mut history = load_history(&character.id); // Find the last assistant message if let Some(pos) = history.messages.iter().rposition(|m| m.role == "assistant") { history.messages[pos].add_swipe(content); save_history(&character.id, &history)?; Ok(SwipeInfo { current: history.messages[pos].current_swipe, total: history.messages[pos].swipes.len(), content: history.messages[pos].get_content().to_string(), }) } else { Err("No assistant message found".to_string()) } } #[tauri::command] fn navigate_swipe(message_index: usize, direction: i32) -> Result { let character = get_active_character(); let mut history = load_history(&character.id); if message_index >= history.messages.len() { return Err("Invalid message index".to_string()); } let msg = &mut history.messages[message_index]; if msg.swipes.is_empty() { return Err("No swipes available".to_string()); } let new_index = if direction > 0 { // Swipe right (next) (msg.current_swipe + 1).min(msg.swipes.len() - 1) } else { // Swipe left (previous) msg.current_swipe.saturating_sub(1) }; msg.set_swipe(new_index)?; // Extract values before saving let current = msg.current_swipe; let total = msg.swipes.len(); let content = msg.get_content().to_string(); save_history(&character.id, &history)?; Ok(SwipeInfo { current, total, content, }) } #[tauri::command] fn get_swipe_info(message_index: usize) -> Result { let character = get_active_character(); let history = load_history(&character.id); if message_index >= history.messages.len() { return Err("Invalid message index".to_string()); } let msg = &history.messages[message_index]; let current = msg.current_swipe; let total = msg.swipes.len(); let content = msg.get_content().to_string(); Ok(SwipeInfo { current, total, content, }) } #[tauri::command] fn create_character(name: String, system_prompt: String) -> Result { let new_id = Uuid::new_v4().to_string(); let character = Character { id: new_id.clone(), name: name.clone(), avatar_path: None, system_prompt, greeting: Some(format!("Hello, I'm {}. How can I help you?", name)), personality: None, created_at: std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_secs() as i64, description: None, scenario: None, mes_example: None, post_history_instructions: None, alternate_greetings: Vec::new(), character_book: None, tags: Vec::new(), creator: None, character_version: None, creator_notes: None, extensions: serde_json::Value::Object(serde_json::Map::new()), }; save_character(&character)?; set_active_character(new_id)?; Ok(character) } #[tauri::command] fn delete_character(character_id: String) -> Result<(), String> { if character_id == "default" { return Err("Cannot delete the default character.".to_string()); } // Get character to check for avatar if let Some(character) = load_character(&character_id) { // Remove avatar if it exists if let Some(avatar_filename) = character.avatar_path { let avatar_path = get_avatar_path(&avatar_filename); if avatar_path.exists() { fs::remove_file(avatar_path).ok(); } } } // Remove character file let path = get_character_path(&character_id); if path.exists() { fs::remove_file(path).map_err(|e| e.to_string())?; } // Remove history file let history_path = get_character_history_path(&character_id); if history_path.exists() { fs::remove_file(history_path).map_err(|e| e.to_string())?; } // If the deleted character was active, switch to default if let Some(config) = load_config() { if config.active_character_id == Some(character_id) { set_active_character("default".to_string())?; } } Ok(()) } #[tauri::command] fn list_characters() -> Result, String> { println!("Listing characters..."); let dir = get_characters_dir(); if !dir.exists() { return Ok(vec![]); } let mut characters = vec![]; for entry in fs::read_dir(dir).map_err(|e| e.to_string())? { let entry = entry.map_err(|e| e.to_string())?; let path = entry.path(); if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("json") { if let Some(character) = load_character(path.file_stem().unwrap().to_str().unwrap()) { characters.push(character); } } } println!("Found {} characters", characters.len()); Ok(characters) } #[tauri::command] fn set_active_character(character_id: String) -> Result<(), String> { if let Some(mut config) = load_config() { config.active_character_id = Some(character_id); save_config(&config) } else { Err("API config not found. Please configure API first.".to_string()) } } // Import character card from PNG #[tauri::command] async fn import_character_card(app_handle: tauri::AppHandle) -> Result { use tauri_plugin_dialog::DialogExt; // Open file picker for PNG files let file_path = app_handle .dialog() .file() .add_filter("Character Cards", &["png"]) .blocking_pick_file(); let png_path = if let Some(path) = file_path { PathBuf::from( path.as_path() .ok_or_else(|| "Could not get file path".to_string())? .to_string_lossy() .to_string(), ) } else { return Err("No file selected".to_string()); }; // Read character data from PNG let card_data = read_character_card_from_png(&png_path)?; // Create new character ID let new_id = Uuid::new_v4().to_string(); // Check for name conflicts and append number if needed let mut final_name = card_data.name.clone(); let existing_chars = list_characters()?; let mut counter = 1; while existing_chars.iter().any(|c| c.name == final_name) { final_name = format!("{} ({})", card_data.name, counter); counter += 1; } // Save PNG as avatar let avatar_filename = format!("{}.png", new_id); let avatar_dest = get_avatar_path(&avatar_filename); // Ensure avatars directory exists fs::create_dir_all(get_avatars_dir()).map_err(|e| e.to_string())?; // Copy PNG to avatars directory fs::copy(&png_path, &avatar_dest) .map_err(|e| format!("Failed to copy avatar: {}", e))?; // Create Character from card data let character = Character { id: new_id.clone(), name: final_name, avatar_path: Some(avatar_filename), system_prompt: card_data.system_prompt.unwrap_or_else(|| "You are a helpful AI assistant.".to_string() ), greeting: card_data.first_mes, personality: card_data.personality, created_at: std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_secs() as i64, description: card_data.description, scenario: card_data.scenario, mes_example: card_data.mes_example, post_history_instructions: card_data.post_history_instructions, alternate_greetings: card_data.alternate_greetings, character_book: card_data.character_book, tags: card_data.tags, creator: card_data.creator, character_version: card_data.character_version, creator_notes: card_data.creator_notes, extensions: card_data.extensions, }; // Save character save_character(&character)?; // Set as active character set_active_character(new_id)?; Ok(character) } // Export character card to PNG #[tauri::command] async fn export_character_card(app_handle: tauri::AppHandle, character_id: String) -> Result { use tauri_plugin_dialog::DialogExt; // Load character let character = load_character(&character_id) .ok_or_else(|| "Character not found".to_string())?; // Get source PNG (avatar or create placeholder) let source_png = if let Some(avatar_filename) = &character.avatar_path { let avatar_path = get_avatar_path(avatar_filename); if avatar_path.exists() { avatar_path } else { // Avatar file missing, create placeholder let temp_path = std::env::temp_dir().join(format!("{}_temp.png", character.id)); create_placeholder_png(&temp_path, &character.name)?; temp_path } } else { // No avatar, create placeholder let temp_path = std::env::temp_dir().join(format!("{}_temp.png", character.id)); create_placeholder_png(&temp_path, &character.name)?; temp_path }; // Open save dialog let save_path = app_handle .dialog() .file() .add_filter("Character Card", &["png"]) .set_file_name(&format!("{}.png", character.name)) .blocking_save_file(); let output_path = if let Some(path) = save_path { PathBuf::from( path.as_path() .ok_or_else(|| "Could not get file path".to_string())? .to_string_lossy() .to_string(), ) } else { return Err("Save cancelled".to_string()); }; // Write character card to PNG write_character_card_to_png(&character, &source_png, &output_path)?; Ok(output_path.to_string_lossy().to_string()) } #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_dialog::init()) .invoke_handler(tauri::generate_handler![ chat, chat_stream, generate_response_only, generate_response_stream, validate_api, save_api_config, get_api_config, get_chat_history, clear_chat_history, truncate_history_from, remove_last_assistant_message, get_last_user_message, add_swipe_to_last_assistant, navigate_swipe, get_swipe_info, get_character, update_character, upload_avatar, select_and_upload_avatar, get_avatar_full_path, list_characters, create_character, delete_character, set_active_character, import_character_card, export_character_card ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); }