From 90bbeb4468abddf154b7ba3a7ee6fc69ac9214d7 Mon Sep 17 00:00:00 2001 From: matt Date: Mon, 13 Oct 2025 18:49:34 -0700 Subject: [PATCH] feat: add character avatars with upload and zoom functionality - Add avatar upload with file picker dialog - Display avatars in header, chat messages, and settings - Implement clickable avatars with full-screen zoom modal - Enable asset protocol for local file access in Tauri config - Add tauri-plugin-dialog for native file selection - Store avatars in ~/.config/claudia/avatars/ - Support PNG, JPG, JPEG, and WEBP formats - Modal closes on overlay click or ESC key --- src-tauri/Cargo.lock | 220 +++++++++++++++++++++++++++++++++++++- src-tauri/Cargo.toml | 3 +- src-tauri/src/lib.rs | 82 ++++++++++++++ src-tauri/tauri.conf.json | 6 +- src/index.html | 29 +++++ src/main.js | 179 ++++++++++++++++++++++++++++++- src/styles.css | 81 ++++++++++++++ 7 files changed, 595 insertions(+), 5 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index afe5642..8cc03a7 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -56,6 +56,27 @@ version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +[[package]] +name = "ashpd" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cbdf310d77fd3aaee6ea2093db7011dc2d35d2eb3481e5607f1f8d942ed99df" +dependencies = [ + "enumflags2", + "futures-channel", + "futures-util", + "rand 0.9.2", + "raw-window-handle", + "serde", + "serde_repr", + "tokio", + "url", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "zbus", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -747,6 +768,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" dependencies = [ "bitflags 2.9.4", + "block2 0.6.2", + "libc", "objc2 0.6.3", ] @@ -761,6 +784,15 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "dlib" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412" +dependencies = [ + "libloading", +] + [[package]] name = "dlopen2" version = "0.8.0" @@ -784,6 +816,12 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + [[package]] name = "dpi" version = "0.1.2" @@ -1541,6 +1579,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-range" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573" + [[package]] name = "httparse" version = "1.10.1" @@ -2838,7 +2882,7 @@ checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" dependencies = [ "base64 0.22.1", "indexmap 2.11.4", - "quick-xml", + "quick-xml 0.38.3", "serde", "time", ] @@ -2968,6 +3012,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quick-xml" +version = "0.37.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +dependencies = [ + "memchr", +] + [[package]] name = "quick-xml" version = "0.38.3" @@ -3017,6 +3070,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.3", +] + [[package]] name = "rand_chacha" version = "0.2.2" @@ -3037,6 +3100,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.3", +] + [[package]] name = "rand_core" version = "0.5.1" @@ -3055,6 +3128,15 @@ dependencies = [ "getrandom 0.2.16", ] +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom 0.3.3", +] + [[package]] name = "rand_hc" version = "0.2.0" @@ -3191,6 +3273,31 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfd" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef2bee61e6cffa4635c72d7d81a84294e28f0930db0ddcb0f66d10244674ebed" +dependencies = [ + "ashpd", + "block2 0.6.2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2 0.6.3", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.59.0", +] + [[package]] name = "ring" version = "0.17.14" @@ -3347,6 +3454,12 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + [[package]] name = "scopeguard" version = "1.2.0" @@ -3898,6 +4011,7 @@ dependencies = [ "gtk", "heck 0.5.0", "http", + "http-range", "jni", "libc", "log", @@ -3944,6 +4058,7 @@ dependencies = [ "serde_json", "tauri", "tauri-build", + "tauri-plugin-dialog", "tauri-plugin-opener", "tokio", "uuid", @@ -4029,6 +4144,46 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-dialog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beee42a4002bc695550599b011728d9dfabf82f767f134754ed6655e434824e" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.17", + "url", +] + +[[package]] +name = "tauri-plugin-fs" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "315784ec4be45e90a987687bae7235e6be3d6e9e350d2b75c16b8a4bf22c1db7" +dependencies = [ + "anyhow", + "dunce", + "glob", + "percent-encoding", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.17", + "toml 0.9.8", + "url", +] + [[package]] name = "tauri-plugin-opener" version = "2.5.0" @@ -4273,6 +4428,7 @@ dependencies = [ "slab", "socket2", "tokio-macros", + "tracing", "windows-sys 0.59.0", ] @@ -4822,6 +4978,66 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wayland-backend" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673a33c33048a5ade91a6b139580fa174e19fb0d23f396dca9fa15f2e1e49b35" +dependencies = [ + "cc", + "downcast-rs", + "rustix", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c66a47e840dc20793f2264eb4b3e4ecb4b75d91c0dd4af04b456128e0bdd449d" +dependencies = [ + "bitflags 2.9.4", + "rustix", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efa790ed75fbfd71283bd2521a1cfdc022aabcc28bdcff00851f9e4ae88d9901" +dependencies = [ + "bitflags 2.9.4", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54cb1e9dc49da91950bdfd8b848c49330536d9d1fb03d4bfec8cae50caa50ae3" +dependencies = [ + "proc-macro2", + "quick-xml 0.37.5", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34949b42822155826b41db8e5d0c1be3a2bd296c747577a43a3e6daefc296142" +dependencies = [ + "dlib", + "log", + "pkg-config", +] + [[package]] name = "web-sys" version = "0.3.81" @@ -5511,6 +5727,7 @@ dependencies = [ "ordered-stream", "serde", "serde_repr", + "tokio", "tracing", "uds_windows", "windows-sys 0.60.2", @@ -5636,6 +5853,7 @@ dependencies = [ "endi", "enumflags2", "serde", + "url", "winnow 0.7.13", "zvariant_derive", "zvariant_utils", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 3437c16..7187cd4 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -18,8 +18,9 @@ crate-type = ["staticlib", "cdylib", "rlib"] tauri-build = { version = "2", features = [] } [dependencies] -tauri = { version = "2", features = [] } +tauri = { version = "2", features = ["protocol-asset"] } tauri-plugin-opener = "2" +tauri-plugin-dialog = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" reqwest = { version = "0.12", features = ["json", "stream"] } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b6bbc1b..12f23a1 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -113,6 +113,15 @@ fn get_character_history_path(character_id: &str) -> PathBuf { 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) +} + fn load_config() -> Option { let path = get_config_path(); if let Ok(contents) = fs::read_to_string(path) { @@ -217,15 +226,73 @@ fn update_character( system_prompt: String, greeting: Option, personality: 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.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(); @@ -485,6 +552,17 @@ fn delete_character(character_id: String) -> Result<(), String> { 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() { @@ -543,6 +621,7 @@ fn set_active_character(character_id: String) -> Result<(), String> { pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_opener::init()) + .plugin(tauri_plugin_dialog::init()) .invoke_handler(tauri::generate_handler![ chat, chat_stream, @@ -553,6 +632,9 @@ pub fn run() { clear_chat_history, get_character, update_character, + upload_avatar, + select_and_upload_avatar, + get_avatar_full_path, list_characters, create_character, delete_character, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index be24c67..d566e4a 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -24,7 +24,11 @@ } ], "security": { - "csp": null + "csp": null, + "assetProtocol": { + "enable": true, + "scope": ["$HOME/.config/claudia/**"] + } } }, "bundle": { diff --git a/src/index.html b/src/index.html index bcfe0e9..fedf4a1 100644 --- a/src/index.html +++ b/src/index.html @@ -135,6 +135,27 @@ /> +
+ +
+
+
+
+ + + +
+
+