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
This commit is contained in:
220
src-tauri/Cargo.lock
generated
220
src-tauri/Cargo.lock
generated
@@ -56,6 +56,27 @@ version = "1.0.100"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
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]]
|
[[package]]
|
||||||
name = "async-broadcast"
|
name = "async-broadcast"
|
||||||
version = "0.7.2"
|
version = "0.7.2"
|
||||||
@@ -747,6 +768,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec"
|
checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.9.4",
|
"bitflags 2.9.4",
|
||||||
|
"block2 0.6.2",
|
||||||
|
"libc",
|
||||||
"objc2 0.6.3",
|
"objc2 0.6.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -761,6 +784,15 @@ dependencies = [
|
|||||||
"syn 2.0.106",
|
"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]]
|
[[package]]
|
||||||
name = "dlopen2"
|
name = "dlopen2"
|
||||||
version = "0.8.0"
|
version = "0.8.0"
|
||||||
@@ -784,6 +816,12 @@ dependencies = [
|
|||||||
"syn 2.0.106",
|
"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]]
|
[[package]]
|
||||||
name = "dpi"
|
name = "dpi"
|
||||||
version = "0.1.2"
|
version = "0.1.2"
|
||||||
@@ -1541,6 +1579,12 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "http-range"
|
||||||
|
version = "0.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "httparse"
|
name = "httparse"
|
||||||
version = "1.10.1"
|
version = "1.10.1"
|
||||||
@@ -2838,7 +2882,7 @@ checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"indexmap 2.11.4",
|
"indexmap 2.11.4",
|
||||||
"quick-xml",
|
"quick-xml 0.38.3",
|
||||||
"serde",
|
"serde",
|
||||||
"time",
|
"time",
|
||||||
]
|
]
|
||||||
@@ -2968,6 +3012,15 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"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]]
|
[[package]]
|
||||||
name = "quick-xml"
|
name = "quick-xml"
|
||||||
version = "0.38.3"
|
version = "0.38.3"
|
||||||
@@ -3017,6 +3070,16 @@ dependencies = [
|
|||||||
"rand_core 0.6.4",
|
"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]]
|
[[package]]
|
||||||
name = "rand_chacha"
|
name = "rand_chacha"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
@@ -3037,6 +3100,16 @@ dependencies = [
|
|||||||
"rand_core 0.6.4",
|
"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]]
|
[[package]]
|
||||||
name = "rand_core"
|
name = "rand_core"
|
||||||
version = "0.5.1"
|
version = "0.5.1"
|
||||||
@@ -3055,6 +3128,15 @@ dependencies = [
|
|||||||
"getrandom 0.2.16",
|
"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]]
|
[[package]]
|
||||||
name = "rand_hc"
|
name = "rand_hc"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -3191,6 +3273,31 @@ dependencies = [
|
|||||||
"web-sys",
|
"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]]
|
[[package]]
|
||||||
name = "ring"
|
name = "ring"
|
||||||
version = "0.17.14"
|
version = "0.17.14"
|
||||||
@@ -3347,6 +3454,12 @@ dependencies = [
|
|||||||
"syn 2.0.106",
|
"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]]
|
[[package]]
|
||||||
name = "scopeguard"
|
name = "scopeguard"
|
||||||
version = "1.2.0"
|
version = "1.2.0"
|
||||||
@@ -3898,6 +4011,7 @@ dependencies = [
|
|||||||
"gtk",
|
"gtk",
|
||||||
"heck 0.5.0",
|
"heck 0.5.0",
|
||||||
"http",
|
"http",
|
||||||
|
"http-range",
|
||||||
"jni",
|
"jni",
|
||||||
"libc",
|
"libc",
|
||||||
"log",
|
"log",
|
||||||
@@ -3944,6 +4058,7 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
|
"tauri-plugin-dialog",
|
||||||
"tauri-plugin-opener",
|
"tauri-plugin-opener",
|
||||||
"tokio",
|
"tokio",
|
||||||
"uuid",
|
"uuid",
|
||||||
@@ -4029,6 +4144,46 @@ dependencies = [
|
|||||||
"walkdir",
|
"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]]
|
[[package]]
|
||||||
name = "tauri-plugin-opener"
|
name = "tauri-plugin-opener"
|
||||||
version = "2.5.0"
|
version = "2.5.0"
|
||||||
@@ -4273,6 +4428,7 @@ dependencies = [
|
|||||||
"slab",
|
"slab",
|
||||||
"socket2",
|
"socket2",
|
||||||
"tokio-macros",
|
"tokio-macros",
|
||||||
|
"tracing",
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -4822,6 +4978,66 @@ dependencies = [
|
|||||||
"web-sys",
|
"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]]
|
[[package]]
|
||||||
name = "web-sys"
|
name = "web-sys"
|
||||||
version = "0.3.81"
|
version = "0.3.81"
|
||||||
@@ -5511,6 +5727,7 @@ dependencies = [
|
|||||||
"ordered-stream",
|
"ordered-stream",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_repr",
|
"serde_repr",
|
||||||
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
"uds_windows",
|
"uds_windows",
|
||||||
"windows-sys 0.60.2",
|
"windows-sys 0.60.2",
|
||||||
@@ -5636,6 +5853,7 @@ dependencies = [
|
|||||||
"endi",
|
"endi",
|
||||||
"enumflags2",
|
"enumflags2",
|
||||||
"serde",
|
"serde",
|
||||||
|
"url",
|
||||||
"winnow 0.7.13",
|
"winnow 0.7.13",
|
||||||
"zvariant_derive",
|
"zvariant_derive",
|
||||||
"zvariant_utils",
|
"zvariant_utils",
|
||||||
|
|||||||
@@ -18,8 +18,9 @@ crate-type = ["staticlib", "cdylib", "rlib"]
|
|||||||
tauri-build = { version = "2", features = [] }
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tauri = { version = "2", features = [] }
|
tauri = { version = "2", features = ["protocol-asset"] }
|
||||||
tauri-plugin-opener = "2"
|
tauri-plugin-opener = "2"
|
||||||
|
tauri-plugin-dialog = "2"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
reqwest = { version = "0.12", features = ["json", "stream"] }
|
reqwest = { version = "0.12", features = ["json", "stream"] }
|
||||||
|
|||||||
@@ -113,6 +113,15 @@ fn get_character_history_path(character_id: &str) -> PathBuf {
|
|||||||
PathBuf::from(home).join(format!(".config/claudia/history_{}.json", character_id))
|
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<ApiConfig> {
|
fn load_config() -> Option<ApiConfig> {
|
||||||
let path = get_config_path();
|
let path = get_config_path();
|
||||||
if let Ok(contents) = fs::read_to_string(path) {
|
if let Ok(contents) = fs::read_to_string(path) {
|
||||||
@@ -217,15 +226,73 @@ fn update_character(
|
|||||||
system_prompt: String,
|
system_prompt: String,
|
||||||
greeting: Option<String>,
|
greeting: Option<String>,
|
||||||
personality: Option<String>,
|
personality: Option<String>,
|
||||||
|
avatar_path: Option<String>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let mut character = get_active_character();
|
let mut character = get_active_character();
|
||||||
character.name = name;
|
character.name = name;
|
||||||
character.system_prompt = system_prompt;
|
character.system_prompt = system_prompt;
|
||||||
character.greeting = greeting;
|
character.greeting = greeting;
|
||||||
character.personality = personality;
|
character.personality = personality;
|
||||||
|
character.avatar_path = avatar_path;
|
||||||
save_character(&character)
|
save_character(&character)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn upload_avatar(source_path: String, character_id: String) -> Result<String, String> {
|
||||||
|
// 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<String, String> {
|
||||||
|
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<String, String> {
|
||||||
|
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]
|
#[tauri::command]
|
||||||
async fn validate_api(base_url: String, api_key: String) -> Result<Vec<String>, String> {
|
async fn validate_api(base_url: String, api_key: String) -> Result<Vec<String>, String> {
|
||||||
let client = reqwest::Client::new();
|
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());
|
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
|
// Remove character file
|
||||||
let path = get_character_path(&character_id);
|
let path = get_character_path(&character_id);
|
||||||
if path.exists() {
|
if path.exists() {
|
||||||
@@ -543,6 +621,7 @@ fn set_active_character(character_id: String) -> Result<(), String> {
|
|||||||
pub fn run() {
|
pub fn run() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
chat,
|
chat,
|
||||||
chat_stream,
|
chat_stream,
|
||||||
@@ -553,6 +632,9 @@ pub fn run() {
|
|||||||
clear_chat_history,
|
clear_chat_history,
|
||||||
get_character,
|
get_character,
|
||||||
update_character,
|
update_character,
|
||||||
|
upload_avatar,
|
||||||
|
select_and_upload_avatar,
|
||||||
|
get_avatar_full_path,
|
||||||
list_characters,
|
list_characters,
|
||||||
create_character,
|
create_character,
|
||||||
delete_character,
|
delete_character,
|
||||||
|
|||||||
@@ -24,7 +24,11 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"security": {
|
||||||
"csp": null
|
"csp": null,
|
||||||
|
"assetProtocol": {
|
||||||
|
"enable": true,
|
||||||
|
"scope": ["$HOME/.config/claudia/**"]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
|
|||||||
@@ -135,6 +135,27 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="character-avatar">Avatar (Optional)</label>
|
||||||
|
<div class="avatar-upload">
|
||||||
|
<div id="avatar-preview" class="avatar-preview">
|
||||||
|
<div class="avatar-circle-large"></div>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
id="character-avatar"
|
||||||
|
accept="image/png,image/jpeg,image/jpg,image/webp"
|
||||||
|
style="display: none;"
|
||||||
|
/>
|
||||||
|
<button type="button" id="upload-avatar-btn" class="btn-secondary">
|
||||||
|
Choose Image
|
||||||
|
</button>
|
||||||
|
<button type="button" id="remove-avatar-btn" class="btn-secondary" style="display: none;">
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="character-system-prompt">System Prompt</label>
|
<label for="character-system-prompt">System Prompt</label>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -193,5 +214,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Avatar zoom modal -->
|
||||||
|
<div id="avatar-modal" class="avatar-modal" style="display: none;">
|
||||||
|
<div class="avatar-modal-overlay"></div>
|
||||||
|
<div class="avatar-modal-content">
|
||||||
|
<img id="avatar-modal-img" src="" alt="Avatar" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
179
src/main.js
179
src/main.js
@@ -12,6 +12,66 @@ let characterHeaderName;
|
|||||||
let newCharacterBtn;
|
let newCharacterBtn;
|
||||||
|
|
||||||
let currentCharacter = null;
|
let currentCharacter = null;
|
||||||
|
let pendingAvatarPath = null;
|
||||||
|
|
||||||
|
// Helper function to get avatar URL
|
||||||
|
async function getAvatarUrl(avatarFilename) {
|
||||||
|
if (!avatarFilename) return null;
|
||||||
|
try {
|
||||||
|
const fullPath = await invoke('get_avatar_full_path', { avatarFilename });
|
||||||
|
console.log('Avatar full path:', fullPath);
|
||||||
|
|
||||||
|
// Try to use convertFileSrc if available
|
||||||
|
if (window.__TAURI__ && window.__TAURI__.core && window.__TAURI__.core.convertFileSrc) {
|
||||||
|
const url = window.__TAURI__.core.convertFileSrc(fullPath);
|
||||||
|
console.log('Converted URL:', url);
|
||||||
|
return url;
|
||||||
|
} else {
|
||||||
|
// Fallback to using the path directly with proper protocol
|
||||||
|
const url = `asset://localhost/${fullPath}`;
|
||||||
|
console.log('Using asset protocol URL:', url);
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to get avatar URL for', avatarFilename, ':', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show avatar in modal
|
||||||
|
function showAvatarModal(avatarUrl) {
|
||||||
|
const modal = document.getElementById('avatar-modal');
|
||||||
|
const modalImg = document.getElementById('avatar-modal-img');
|
||||||
|
|
||||||
|
modalImg.src = avatarUrl;
|
||||||
|
modal.style.display = 'flex';
|
||||||
|
|
||||||
|
// Fade in animation
|
||||||
|
modal.style.opacity = '0';
|
||||||
|
setTimeout(() => {
|
||||||
|
modal.style.opacity = '1';
|
||||||
|
modal.style.transition = 'opacity 0.2s ease';
|
||||||
|
}, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide avatar modal
|
||||||
|
function hideAvatarModal() {
|
||||||
|
const modal = document.getElementById('avatar-modal');
|
||||||
|
modal.style.opacity = '0';
|
||||||
|
setTimeout(() => {
|
||||||
|
modal.style.display = 'none';
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make avatar clickable
|
||||||
|
function makeAvatarClickable(avatarElement, avatarUrl) {
|
||||||
|
if (!avatarUrl) return;
|
||||||
|
|
||||||
|
avatarElement.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
showAvatarModal(avatarUrl);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Auto-resize textarea
|
// Auto-resize textarea
|
||||||
function autoResize(textarea) {
|
function autoResize(textarea) {
|
||||||
@@ -26,7 +86,16 @@ function addMessage(content, isUser = false) {
|
|||||||
|
|
||||||
const avatar = document.createElement('div');
|
const avatar = document.createElement('div');
|
||||||
avatar.className = 'avatar-circle';
|
avatar.className = 'avatar-circle';
|
||||||
// TODO: Set avatar image
|
|
||||||
|
// Set avatar image for assistant messages
|
||||||
|
if (!isUser && 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');
|
const contentDiv = document.createElement('div');
|
||||||
contentDiv.className = 'message-content';
|
contentDiv.className = 'message-content';
|
||||||
@@ -187,6 +256,16 @@ async function handleSubmit(e) {
|
|||||||
const avatar = document.createElement('div');
|
const avatar = document.createElement('div');
|
||||||
avatar.className = 'avatar-circle';
|
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');
|
const contentDiv = document.createElement('div');
|
||||||
contentDiv.className = 'message-content';
|
contentDiv.className = 'message-content';
|
||||||
|
|
||||||
@@ -384,6 +463,55 @@ async function handleSaveSettings(e) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Avatar upload handling
|
||||||
|
async function handleAvatarUpload() {
|
||||||
|
const characterMsg = document.getElementById('character-message');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const characterId = document.getElementById('character-settings-select').value;
|
||||||
|
const avatarFilename = await invoke('select_and_upload_avatar', {
|
||||||
|
characterId: characterId
|
||||||
|
});
|
||||||
|
|
||||||
|
pendingAvatarPath = avatarFilename;
|
||||||
|
|
||||||
|
// Update preview
|
||||||
|
const avatarPreview = document.querySelector('.avatar-circle-large');
|
||||||
|
const avatarUrl = await getAvatarUrl(avatarFilename);
|
||||||
|
if (avatarUrl) {
|
||||||
|
avatarPreview.style.backgroundImage = `url('${avatarUrl}')`;
|
||||||
|
}
|
||||||
|
document.getElementById('remove-avatar-btn').style.display = 'inline-block';
|
||||||
|
|
||||||
|
characterMsg.textContent = 'Avatar uploaded. Click "Save Character" to apply.';
|
||||||
|
characterMsg.className = 'validation-message success';
|
||||||
|
setTimeout(() => {
|
||||||
|
characterMsg.style.display = 'none';
|
||||||
|
}, 3000);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Avatar upload error:', error);
|
||||||
|
// Don't show error if user just cancelled the dialog
|
||||||
|
if (error && !error.toString().includes('No file selected')) {
|
||||||
|
characterMsg.textContent = `Failed to upload avatar: ${error}`;
|
||||||
|
characterMsg.className = 'validation-message error';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAvatarRemove() {
|
||||||
|
pendingAvatarPath = null;
|
||||||
|
const avatarPreview = document.querySelector('.avatar-circle-large');
|
||||||
|
avatarPreview.style.backgroundImage = '';
|
||||||
|
document.getElementById('remove-avatar-btn').style.display = 'none';
|
||||||
|
|
||||||
|
const characterMsg = document.getElementById('character-message');
|
||||||
|
characterMsg.textContent = 'Avatar removed. Click "Save Character" to apply.';
|
||||||
|
characterMsg.className = 'validation-message success';
|
||||||
|
setTimeout(() => {
|
||||||
|
characterMsg.style.display = 'none';
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
// App controls
|
// App controls
|
||||||
function setupAppControls() {
|
function setupAppControls() {
|
||||||
document.getElementById('settings-btn').addEventListener('click', showSettings);
|
document.getElementById('settings-btn').addEventListener('click', showSettings);
|
||||||
@@ -397,6 +525,8 @@ function setupAppControls() {
|
|||||||
await invoke('set_active_character', { characterId });
|
await invoke('set_active_character', { characterId });
|
||||||
await loadCharacterSettings();
|
await loadCharacterSettings();
|
||||||
});
|
});
|
||||||
|
document.getElementById('upload-avatar-btn').addEventListener('click', handleAvatarUpload);
|
||||||
|
document.getElementById('remove-avatar-btn').addEventListener('click', handleAvatarRemove);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keyboard shortcuts
|
// Keyboard shortcuts
|
||||||
@@ -433,6 +563,19 @@ async function loadCharacters() {
|
|||||||
characterHeaderName.textContent = activeCharacter.name;
|
characterHeaderName.textContent = activeCharacter.name;
|
||||||
currentCharacter = activeCharacter;
|
currentCharacter = activeCharacter;
|
||||||
|
|
||||||
|
// Update header avatar
|
||||||
|
const headerAvatar = document.querySelector('.avatar-circle');
|
||||||
|
if (headerAvatar && activeCharacter.avatar_path) {
|
||||||
|
getAvatarUrl(activeCharacter.avatar_path).then(url => {
|
||||||
|
if (url) {
|
||||||
|
headerAvatar.style.backgroundImage = `url('${url}')`;
|
||||||
|
makeAvatarClickable(headerAvatar, url);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if (headerAvatar) {
|
||||||
|
headerAvatar.style.backgroundImage = '';
|
||||||
|
}
|
||||||
|
|
||||||
await loadChatHistory();
|
await loadChatHistory();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load characters:', error);
|
console.error('Failed to load characters:', error);
|
||||||
@@ -552,6 +695,24 @@ async function loadCharacterSettings() {
|
|||||||
document.getElementById('character-system-prompt').value = character.system_prompt;
|
document.getElementById('character-system-prompt').value = character.system_prompt;
|
||||||
document.getElementById('character-greeting').value = character.greeting || '';
|
document.getElementById('character-greeting').value = character.greeting || '';
|
||||||
document.getElementById('character-personality').value = character.personality || '';
|
document.getElementById('character-personality').value = character.personality || '';
|
||||||
|
|
||||||
|
// Load avatar preview
|
||||||
|
const avatarPreview = document.querySelector('.avatar-circle-large');
|
||||||
|
const removeAvatarBtn = document.getElementById('remove-avatar-btn');
|
||||||
|
if (character.avatar_path) {
|
||||||
|
getAvatarUrl(character.avatar_path).then(url => {
|
||||||
|
if (url) {
|
||||||
|
avatarPreview.style.backgroundImage = `url('${url}')`;
|
||||||
|
makeAvatarClickable(avatarPreview, url);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
removeAvatarBtn.style.display = 'inline-block';
|
||||||
|
pendingAvatarPath = character.avatar_path;
|
||||||
|
} else {
|
||||||
|
avatarPreview.style.backgroundImage = '';
|
||||||
|
removeAvatarBtn.style.display = 'none';
|
||||||
|
pendingAvatarPath = null;
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load character:', error);
|
console.error('Failed to load character:', error);
|
||||||
}
|
}
|
||||||
@@ -582,7 +743,8 @@ async function handleSaveCharacter(e) {
|
|||||||
name,
|
name,
|
||||||
systemPrompt,
|
systemPrompt,
|
||||||
greeting,
|
greeting,
|
||||||
personality
|
personality,
|
||||||
|
avatarPath: pendingAvatarPath
|
||||||
});
|
});
|
||||||
characterMsg.textContent = 'Character saved successfully';
|
characterMsg.textContent = 'Character saved successfully';
|
||||||
characterMsg.className = 'validation-message success';
|
characterMsg.className = 'validation-message success';
|
||||||
@@ -654,6 +816,19 @@ window.addEventListener('DOMContentLoaded', () => {
|
|||||||
setupKeyboardShortcuts();
|
setupKeyboardShortcuts();
|
||||||
setupTabs();
|
setupTabs();
|
||||||
|
|
||||||
|
// Avatar modal close handlers
|
||||||
|
const avatarModal = document.getElementById('avatar-modal');
|
||||||
|
const avatarModalOverlay = document.querySelector('.avatar-modal-overlay');
|
||||||
|
|
||||||
|
avatarModalOverlay.addEventListener('click', hideAvatarModal);
|
||||||
|
|
||||||
|
// ESC key to close modal
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape' && avatarModal.style.display !== 'none') {
|
||||||
|
hideAvatarModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
messageInput.focus();
|
messageInput.focus();
|
||||||
setStatus('Ready');
|
setStatus('Ready');
|
||||||
|
|
||||||
|
|||||||
@@ -84,6 +84,28 @@ body {
|
|||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--bg-tertiary);
|
background: var(--bg-tertiary);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-circle-large {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 2px solid var(--border);
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-upload {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-preview {
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
#character-header-name {
|
#character-header-name {
|
||||||
@@ -730,6 +752,65 @@ body {
|
|||||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Avatar Modal */
|
||||||
|
.avatar-modal {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 10000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-modal-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.85);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-modal-content {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
max-width: 90vw;
|
||||||
|
max-height: 90vh;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-modal-content img {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 90vh;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Make avatars clickable */
|
||||||
|
.avatar-circle,
|
||||||
|
.avatar-circle-large {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-circle:hover,
|
||||||
|
.avatar-circle-large:hover {
|
||||||
|
transform: scale(1.05);
|
||||||
|
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-circle:active,
|
||||||
|
.avatar-circle-large:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
/* Responsive */
|
/* Responsive */
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
.messages-list {
|
.messages-list {
|
||||||
|
|||||||
Reference in New Issue
Block a user