diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 10defbe..f62c723 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -9,7 +9,12 @@ "Bash(WAYLAND_DISPLAY=\"\" DISPLAY=:0 ./src-tauri/target/debug/tauri-app:*)", "Bash(ldd:*)", "Read(//home/matt/.config/claudia/**)", - "WebFetch(domain:github.com)" + "WebFetch(domain:github.com)", + "Bash(cat:*)", + "Bash(npm run tauri:dev:*)", + "Bash(git add:*)", + "Bash(git commit:*)", + "WebSearch" ], "deny": [], "ask": [] diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 8cc03a7..d3447b4 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -270,6 +270,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bit_field" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" + [[package]] name = "bitflags" version = "1.3.2" @@ -501,6 +507,12 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "combine" version = "4.6.7" @@ -613,12 +625,37 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-common" version = "0.1.6" @@ -858,6 +895,12 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + [[package]] name = "embed-resource" version = "3.0.6" @@ -962,6 +1005,21 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "exr" +version = "1.73.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83197f59927b46c04a183a619b7c29df34e63e63c7869320862268c0ef687e0" +dependencies = [ + "bit_field", + "half", + "lebe", + "miniz_oxide", + "rayon-core", + "smallvec", + "zune-inflate", +] + [[package]] name = "fastrand" version = "2.3.0" @@ -1324,6 +1382,16 @@ dependencies = [ "wasi 0.14.7+wasi-0.2.4", ] +[[package]] +name = "gif" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ae047235e33e2829703574b54fdec96bfbad892062d97fed2f76022287de61b" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "gimli" version = "0.32.3" @@ -1497,6 +1565,17 @@ dependencies = [ "tracing", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -1818,6 +1897,24 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "image" +version = "0.24.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5690139d2f55868e080017335e4b94cb7414274c74f1669c84fb5feba2c9f69d" +dependencies = [ + "bytemuck", + "byteorder", + "color_quant", + "exr", + "gif", + "jpeg-decoder", + "num-traits", + "png", + "qoi", + "tiff", +] + [[package]] name = "indexmap" version = "1.9.3" @@ -1947,6 +2044,15 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" +[[package]] +name = "jpeg-decoder" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00810f1d8b74be64b13dbf3db89ac67740615d6c891f0e7b6179326533011a07" +dependencies = [ + "rayon", +] + [[package]] name = "js-sys" version = "0.3.81" @@ -2008,6 +2114,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "lebe" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" + [[package]] name = "libappindicator" version = "0.9.0" @@ -3012,6 +3124,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] + [[package]] name = "quick-xml" version = "0.37.5" @@ -3161,6 +3282,26 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" +[[package]] +name = "rayon" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -4051,8 +4192,11 @@ dependencies = [ name = "tauri-app" version = "0.1.0" dependencies = [ + "base64 0.21.7", "bytes", "futures", + "image", + "png", "reqwest", "serde", "serde_json", @@ -4370,6 +4514,17 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "tiff" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba1310fcea54c6a9a4fd1aad794ecc02c31682f6bfbecdf460bf19533eed1e3e" +dependencies = [ + "flate2", + "jpeg-decoder", + "weezl", +] + [[package]] name = "time" version = "0.3.44" @@ -5128,6 +5283,12 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "weezl" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a751b3277700db47d3e574514de2eced5e54dc8a5436a3bf7a0b248b2cee16f3" + [[package]] name = "winapi" version = "0.3.9" @@ -5844,6 +6005,15 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "zune-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] + [[package]] name = "zvariant" version = "5.7.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 7187cd4..079c258 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -28,4 +28,7 @@ tokio = { version = "1", features = ["full"] } uuid = { version = "1", features = ["v4"] } futures = "0.3" bytes = "1" +png = "0.17" +base64 = "0.21" +image = "0.24" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1238e22..d242d84 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,9 +1,11 @@ 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 { @@ -25,6 +27,93 @@ struct Character { 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 character card specification structs +#[derive(Debug, Serialize, Deserialize)] +struct CharacterCardV2 { + spec: String, + spec_version: String, + data: CharacterCardV2Data, +} + +#[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)] @@ -178,6 +267,147 @@ fn get_avatar_path(filename: &str) -> PathBuf { get_avatars_dir().join(filename) } +// PNG Character Card Utilities +fn read_character_card_from_png(png_path: &PathBuf) -> Result { + use png::Decoder; + use std::io::BufReader; + + // Open and decode PNG + let file = fs::File::open(png_path) + .map_err(|e| format!("Failed to open PNG file: {}", e))?; + let decoder = Decoder::new(BufReader::new(file)); + let reader = decoder.read_info() + .map_err(|e| format!("Failed to read PNG info: {}", e))?; + + // Get metadata + let info = reader.info(); + + // Look for "chara" tEXt chunk + let mut chara_data = None; + for text_chunk in &info.uncompressed_latin1_text { + if text_chunk.keyword == "chara" { + chara_data = Some(text_chunk.text.clone()); + break; + } + } + + // Also check UTF-8 text chunks (iTXt) + if chara_data.is_none() { + for text_chunk in &info.utf8_text { + if text_chunk.keyword == "chara" { + let text = text_chunk.get_text() + .map_err(|e| format!("Failed to read UTF-8 text chunk: {}", e))?; + chara_data = Some(text); + break; + } + } + } + + let chara_text = chara_data + .ok_or_else(|| "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))?; + + // Parse as V2 card + let card: CharacterCardV2 = serde_json::from_str(&json_str) + .map_err(|e| format!("Failed to parse character card JSON: {}", e))?; + + // Validate spec + if card.spec != "chara_card_v2" { + return Err(format!("Unsupported character card spec: {}", card.spec)); + } + + Ok(card.data) +} + +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) { @@ -252,6 +482,17 @@ fn create_default_character() -> Character { .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()), } } @@ -883,6 +1124,17 @@ fn create_character(name: String, system_prompt: String) -> Result Result<(), 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() @@ -989,7 +1378,9 @@ pub fn run() { list_characters, create_character, delete_character, - set_active_character + set_active_character, + import_character_card, + export_character_card ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src/index.html b/src/index.html index fedf4a1..fcfe802 100644 --- a/src/index.html +++ b/src/index.html @@ -191,7 +191,20 @@ + + +
+ +
+ + +
+
+ diff --git a/src/main.js b/src/main.js index 4101d7e..b13e174 100644 --- a/src/main.js +++ b/src/main.js @@ -971,6 +971,8 @@ function setupAppControls() { }); document.getElementById('upload-avatar-btn').addEventListener('click', handleAvatarUpload); document.getElementById('remove-avatar-btn').addEventListener('click', handleAvatarRemove); + document.getElementById('import-character-btn').addEventListener('click', handleImportCharacter); + document.getElementById('export-character-btn').addEventListener('click', handleExportCharacter); } // Keyboard shortcuts @@ -1077,6 +1079,51 @@ async function handleDeleteCharacter() { } } +// Handle character card import +async function handleImportCharacter() { + const characterMsg = document.getElementById('character-message'); + try { + const importedCharacter = await invoke('import_character_card'); + characterMsg.textContent = `Successfully imported ${importedCharacter.name}!`; + characterMsg.className = 'validation-message success'; + + // Reload characters and switch to the imported one + await loadCharacters(); + await loadCharacterSettings(); + + setTimeout(() => { + characterMsg.style.display = 'none'; + }, 3000); + } catch (error) { + console.error('Failed to import character:', error); + if (error && !error.toString().includes('No file selected') && !error.toString().includes('cancelled')) { + characterMsg.textContent = `Failed to import: ${error}`; + characterMsg.className = 'validation-message error'; + } + } +} + +// Handle character card export +async function handleExportCharacter() { + const characterMsg = document.getElementById('character-message'); + try { + const characterId = document.getElementById('character-settings-select').value; + const outputPath = await invoke('export_character_card', { characterId }); + characterMsg.textContent = `Successfully exported to ${outputPath}`; + characterMsg.className = 'validation-message success'; + + setTimeout(() => { + characterMsg.style.display = 'none'; + }, 3000); + } catch (error) { + console.error('Failed to export character:', error); + if (error && !error.toString().includes('cancelled')) { + characterMsg.textContent = `Failed to export: ${error}`; + characterMsg.className = 'validation-message error'; + } + } +} + // Load chat history async function loadChatHistory() { try {