From 0951de8ee5a31483cc6ab21c1985356e94593525 Mon Sep 17 00:00:00 2001 From: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> Date: Fri, 20 Mar 2026 18:27:55 -0400 Subject: [PATCH] feat(desktop): persona import/export via PNG cards and ZIP packs (#137) --- desktop/scripts/check-file-sizes.mjs | 3 +- desktop/src-tauri/Cargo.lock | 215 +++++- desktop/src-tauri/Cargo.toml | 2 + desktop/src-tauri/src/commands/personas.rs | 123 +++- desktop/src-tauri/src/lib.rs | 2 + desktop/src-tauri/src/managed_agents/mod.rs | 2 + .../src/managed_agents/persona_card.rs | 662 ++++++++++++++++++ desktop/src-tauri/tauri.conf.json | 1 + desktop/src/features/agents/channelAgents.ts | 19 +- desktop/src/features/agents/hooks.ts | 7 + desktop/src/features/agents/ui/AgentsView.tsx | 56 +- .../features/agents/ui/BatchImportDialog.tsx | 237 +++++++ .../src/features/agents/ui/PersonaDialog.tsx | 109 ++- .../features/agents/ui/PersonasSection.tsx | 19 +- desktop/src/shared/api/tauriPersonas.ts | 62 ++ desktop/src/testing/e2eBridge.ts | 41 ++ 16 files changed, 1547 insertions(+), 13 deletions(-) create mode 100644 desktop/src-tauri/src/managed_agents/persona_card.rs create mode 100644 desktop/src/features/agents/ui/BatchImportDialog.tsx diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index f20a0b9f2f..c1354956eb 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -30,8 +30,9 @@ const rules = [ // Exceptions should stay rare and temporary. Prefer splitting files instead. const overrides = new Map([ + ["src-tauri/src/managed_agents/persona_card.rs", 700], // PNG/ZIP persona card codec + 21 unit tests (~300 lines of tests) ["src/app/AppShell.tsx", 750], - ["src/features/agents/ui/AgentsView.tsx", 575], // persona + team dialog orchestration (team state extracted to useTeamActions) + ["src/features/agents/ui/AgentsView.tsx", 625], // persona/team orchestration plus import/export wiring ["src/features/channels/hooks.ts", 525], // canvas query + mutation hooks ["src/features/channels/ui/ChannelManagementSheet.tsx", 800], ["src/features/messages/ui/MessageComposer.tsx", 665], // media upload handlers (paste, drop, dialog) + channelId reset effect diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index fa9ef01f2e..1c17f8e133 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -68,6 +68,15 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "arrayvec" version = "0.7.6" @@ -452,6 +461,25 @@ dependencies = [ "serde", ] +[[package]] +name = "bzip2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ecfb22d906f800d4fe833b6282cf4dc1c298f5057ca0b5445e5c209735ca47" +dependencies = [ + "bzip2-sys", +] + +[[package]] +name = "bzip2-sys" +version = "0.1.13+1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "cairo-rs" version = "0.18.5" @@ -535,6 +563,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -645,6 +675,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "constant_time_eq" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" + [[package]] name = "convert_case" version = "0.4.0" @@ -720,6 +756,21 @@ dependencies = [ "libc", ] +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + [[package]] name = "crc32fast" version = "1.5.0" @@ -833,6 +884,12 @@ version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" +[[package]] +name = "deflate64" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "807800ff3288b621186fe0a8f3392c4652068257302709c24efd918c3dffcdc2" + [[package]] name = "deranged" version = "0.5.8" @@ -843,6 +900,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "derive_more" version = "0.99.20" @@ -1839,7 +1907,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" dependencies = [ "byteorder", - "png", + "png 0.17.16", ] [[package]] @@ -2105,6 +2173,16 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + [[package]] name = "js-sys" version = "0.3.91" @@ -2248,6 +2326,27 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "lzma-rs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "297e814c836ae64db86b36cf2a557ba54368d03f6afcd7d947c266692f71115e" +dependencies = [ + "byteorder", + "crc", +] + +[[package]] +name = "lzma-sys" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fda04ab3764e6cde78b9974eec4f779acaba7c4e84b36eca3cf77c581b85d27" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + [[package]] name = "mac" version = "0.1.1" @@ -2354,7 +2453,7 @@ dependencies = [ "objc2-core-foundation", "objc2-foundation", "once_cell", - "png", + "png 0.17.16", "serde", "thiserror 2.0.18", "windows-sys 0.60.2", @@ -3036,6 +3135,19 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.11.0", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "polling" version = "3.11.0" @@ -4115,6 +4227,7 @@ dependencies = [ "infer 0.16.0", "libc", "nostr 0.37.0", + "png 0.18.1", "reqwest 0.12.28", "serde", "serde_json", @@ -4132,6 +4245,7 @@ dependencies = [ "url", "uuid", "windows-sys 0.59.0", + "zip", ] [[package]] @@ -4416,7 +4530,7 @@ dependencies = [ "ico", "json-patch", "plist", - "png", + "png 0.17.16", "proc-macro2", "quote", "semver", @@ -5071,7 +5185,7 @@ dependencies = [ "objc2-core-graphics", "objc2-foundation", "once_cell", - "png", + "png 0.17.16", "serde", "thiserror 2.0.18", "windows-sys 0.60.2", @@ -6202,6 +6316,15 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "xz2" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "388c44dc09d76f1536602ead6d325eb532f5c122f17782bd57fb47baeeb767e2" +dependencies = [ + "lzma-sys", +] + [[package]] name = "yoke" version = "0.8.1" @@ -6332,6 +6455,20 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] [[package]] name = "zerotrie" @@ -6366,12 +6503,82 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "aes", + "arbitrary", + "bzip2", + "constant_time_eq", + "crc32fast", + "crossbeam-utils", + "deflate64", + "displaydoc", + "flate2", + "getrandom 0.3.4", + "hmac", + "indexmap 2.13.0", + "lzma-rs", + "memchr", + "pbkdf2", + "sha1", + "thiserror 2.0.18", + "time", + "xz2", + "zeroize", + "zopfli", + "zstd", +] + [[package]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "zvariant" version = "5.10.0" diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 472345192c..273ec85369 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -44,6 +44,8 @@ sha2 = "0.10" chrono = { version = "0.4", features = ["serde"] } tauri-plugin-notification = "2.3.3" uuid = { version = "1", features = ["v4"] } +png = "0.18" +zip = "2" [dev-dependencies] tempfile = "3" diff --git a/desktop/src-tauri/src/commands/personas.rs b/desktop/src-tauri/src/commands/personas.rs index b0c0d4b741..6e5ee3253c 100644 --- a/desktop/src-tauri/src/commands/personas.rs +++ b/desktop/src-tauri/src/commands/personas.rs @@ -1,11 +1,14 @@ +use base64::{engine::general_purpose::STANDARD, Engine as _}; use tauri::{AppHandle, State}; +use tauri_plugin_dialog::DialogExt; use uuid::Uuid; use crate::{ app_state::AppState, managed_agents::{ - load_managed_agents, load_personas, save_managed_agents, save_personas, CreatePersonaRequest, - PersonaRecord, UpdatePersonaRequest, + encode_persona_png, generate_placeholder_png, load_managed_agents, load_personas, + parse_png_persona, parse_zip_personas, save_managed_agents, save_personas, + CreatePersonaRequest, ParsePersonaFilesResult, PersonaRecord, UpdatePersonaRequest, }, util::now_iso, }; @@ -142,3 +145,119 @@ pub fn delete_persona( Ok(()) } + +// --------------------------------------------------------------------------- +// Import / Export +// --------------------------------------------------------------------------- + +const MAX_PNG_BYTES: usize = 10 * 1024 * 1024; +const MAX_ZIP_BYTES: usize = 100 * 1024 * 1024; + +const PNG_MAGIC: [u8; 4] = [0x89, 0x50, 0x4E, 0x47]; +const ZIP_MAGIC: [u8; 4] = [0x50, 0x4B, 0x03, 0x04]; + +#[tauri::command] +pub fn parse_persona_files( + file_bytes: Vec, + file_name: String, +) -> Result { + if file_bytes.len() > MAX_ZIP_BYTES { + return Err("File is too large (max 100 MB).".to_string()); + } + if file_bytes.len() < 4 { + return Err("File is too small to be a valid PNG or ZIP.".to_string()); + } + + let magic: [u8; 4] = file_bytes[..4] + .try_into() + .map_err(|_| "Failed to read file header".to_string())?; + + if magic == PNG_MAGIC { + if file_bytes.len() > MAX_PNG_BYTES { + return Err("PNG file is too large (max 10 MB).".to_string()); + } + let mut preview = parse_png_persona(&file_bytes)?; + preview.source_file = file_name; + Ok(ParsePersonaFilesResult { + personas: vec![preview], + skipped: vec![], + }) + } else if magic == ZIP_MAGIC { + parse_zip_personas(&file_bytes) + } else { + Err("Unsupported file format. Expected .persona.png or .zip".to_string()) + } +} + +#[tauri::command] +pub async fn export_persona_to_png( + id: String, + app: AppHandle, + state: State<'_, AppState>, +) -> Result { + // Load persona data under lock, then drop lock before dialog. + let (display_name, system_prompt, avatar_url) = { + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + let personas = load_personas(&app)?; + let persona = personas + .iter() + .find(|p| p.id == id) + .ok_or_else(|| format!("persona {id} not found"))?; + ( + persona.display_name.clone(), + persona.system_prompt.clone(), + persona.avatar_url.clone(), + ) + }; + + // Build avatar PNG bytes. + let avatar_png = match avatar_url.as_deref() { + Some(url) if url.starts_with("data:image/png;base64,") => { + let b64 = &url["data:image/png;base64,".len()..]; + STANDARD + .decode(b64) + .map_err(|e| format!("Invalid avatar data URL: {e}"))? + } + _ => generate_placeholder_png(&display_name)?, + }; + + let png_bytes = encode_persona_png(&display_name, &system_prompt, &avatar_png)?; + + // Slugify display name for filename. + let slug: String = display_name + .to_lowercase() + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) + .collect::() + .trim_matches('-') + .to_string(); + let slug = if slug.is_empty() { "persona" } else { &slug }; + let slug = if slug.len() > 50 { &slug[..50] } else { slug }; + let slug = slug.trim_end_matches('-'); + + let (tx, rx) = tokio::sync::oneshot::channel(); + app.dialog() + .file() + .add_filter("PNG Image", &["png"]) + .set_file_name(&format!("{slug}.persona.png")) + .save_file(move |path| { + let _ = tx.send(path); + }); + + let selected = rx.await.map_err(|_| "dialog cancelled".to_string())?; + let file_path = match selected { + Some(p) => p, + None => return Ok(false), + }; + + let dest = file_path + .as_path() + .ok_or_else(|| "Save dialog returned an invalid path".to_string())?; + std::fs::write(dest, &png_bytes) + .map_err(|e| format!("Failed to write file: {e}"))?; + + Ok(true) +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index bd5dcb1a1a..950c24e10c 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -178,6 +178,8 @@ pub fn run() { create_team, update_team, delete_team, + parse_persona_files, + export_persona_to_png, ]) .build(tauri::generate_context!()) .expect("error while building tauri application"); diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index ac03ab37cf..f4e234a447 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -1,4 +1,5 @@ mod discovery; +mod persona_card; mod personas; mod runtime; mod storage; @@ -6,6 +7,7 @@ mod teams; mod types; pub use discovery::*; +pub use persona_card::*; pub use personas::*; pub use runtime::*; pub use storage::*; diff --git a/desktop/src-tauri/src/managed_agents/persona_card.rs b/desktop/src-tauri/src/managed_agents/persona_card.rs new file mode 100644 index 0000000000..224d57cf81 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/persona_card.rs @@ -0,0 +1,662 @@ +use base64::{engine::general_purpose::STANDARD, Engine as _}; +use png::{BitDepth, ColorType, Decoder, Encoder}; +use serde::Serialize; +use serde_json::Value; +use std::io::{Cursor, Read}; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize)] +pub struct ParsedPersonaPreview { + pub display_name: String, + pub system_prompt: String, + pub avatar_data_url: Option, + pub source_file: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ParsePersonaFilesResult { + pub personas: Vec, + pub skipped: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct SkippedFile { + pub source_file: String, + pub reason: String, +} + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const MAX_ZIP_ENTRIES: usize = 50; +const MAX_ZIP_DECOMPRESSED: usize = 100 * 1024 * 1024; + +// --------------------------------------------------------------------------- +// PNG persona parsing +// --------------------------------------------------------------------------- + +pub fn parse_png_persona(png_bytes: &[u8]) -> Result { + let decoder = Decoder::new(Cursor::new(png_bytes)); + let reader = decoder.read_info().map_err(|e| format!("Invalid PNG: {e}"))?; + let info = reader.info(); + + let mut sprout_text: Option<&str> = None; + let mut chara_text: Option<&str> = None; + + for chunk in &info.uncompressed_latin1_text { + match chunk.keyword.as_str() { + "sprout_persona" if sprout_text.is_none() => sprout_text = Some(&chunk.text), + "chara" | "ccv3" if chara_text.is_none() => chara_text = Some(&chunk.text), + _ => {} + } + } + + let preview = if let Some(text) = sprout_text { + parse_sprout_payload(text)? + } else if let Some(text) = chara_text { + parse_chara_payload(text)? + } else { + return Err("This image doesn't contain persona data.".to_string()); + }; + + let avatar_data_url = Some(format!("data:image/png;base64,{}", STANDARD.encode(png_bytes))); + + Ok(ParsedPersonaPreview { + display_name: preview.0, + system_prompt: preview.1, + avatar_data_url, + source_file: String::new(), + }) +} + +fn decode_b64_json(b64: &str) -> Result { + let bytes = STANDARD + .decode(b64.trim()) + .map_err(|e| format!("Invalid base64: {e}"))?; + serde_json::from_slice(&bytes).map_err(|e| format!("Invalid JSON: {e}")) +} + +fn parse_sprout_payload(b64: &str) -> Result<(String, String), String> { + let v = decode_b64_json(b64)?; + let version = v.get("version").and_then(|v| v.as_u64()).unwrap_or(0); + if version != 1 { + return Err(format!("Unsupported persona version: {version}")); + } + let name = v + .get("displayName") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim() + .to_string(); + let prompt = v + .get("systemPrompt") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim() + .to_string(); + if name.is_empty() { + return Err("displayName is empty".to_string()); + } + if prompt.is_empty() { + return Err("systemPrompt is empty".to_string()); + } + Ok((name, prompt)) +} + +fn parse_chara_payload(b64: &str) -> Result<(String, String), String> { + let v = decode_b64_json(b64)?; + let data = v.get("data").ok_or("Missing 'data' in chara payload")?; + let name = data + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim() + .to_string(); + let mut prompt = data + .get("system_prompt") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim() + .to_string(); + if prompt.is_empty() { + prompt = data + .get("description") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim() + .to_string(); + } + if name.is_empty() { + return Err("Chara card has no name".to_string()); + } + if prompt.is_empty() { + return Err("Chara card has no system_prompt or description".to_string()); + } + Ok((name, prompt)) +} + +// --------------------------------------------------------------------------- +// PNG persona encoding +// --------------------------------------------------------------------------- + +pub fn encode_persona_png( + display_name: &str, + system_prompt: &str, + avatar_png_bytes: &[u8], +) -> Result, String> { + // Decode the source PNG to raw pixels so we can re-encode with tEXt chunks. + let decoder = Decoder::new(Cursor::new(avatar_png_bytes)); + let mut reader = decoder.read_info().map_err(|e| format!("Invalid avatar PNG: {e}"))?; + let mut pixels = vec![0u8; reader.output_buffer_size().ok_or("Cannot determine PNG buffer size")?]; + let output_info = reader + .next_frame(&mut pixels) + .map_err(|e| format!("Failed to decode avatar PNG: {e}"))?; + pixels.truncate(output_info.buffer_size()); + + let width = output_info.width; + let height = output_info.height; + let color_type = output_info.color_type; + let bit_depth = output_info.bit_depth; + + let sprout_json = serde_json::json!({ + "version": 1, + "displayName": display_name, + "systemPrompt": system_prompt, + }); + let chara_json = serde_json::json!({ + "spec": "chara_card_v2", + "spec_version": "2.0", + "data": { + "name": display_name, + "description": "", + "personality": "", + "system_prompt": system_prompt, + "extensions": { + "sprout": { + "version": 1, + "source": "sprout" + } + } + } + }); + + let sprout_b64 = STANDARD.encode(sprout_json.to_string().as_bytes()); + let chara_b64 = STANDARD.encode(chara_json.to_string().as_bytes()); + + let mut buf = Vec::new(); + { + let mut encoder = Encoder::new(Cursor::new(&mut buf), width, height); + encoder.set_color(color_type); + encoder.set_depth(bit_depth); + encoder + .add_text_chunk("sprout_persona".to_string(), sprout_b64) + .map_err(|e| format!("Failed to add text chunk: {e}"))?; + encoder + .add_text_chunk("chara".to_string(), chara_b64) + .map_err(|e| format!("Failed to add text chunk: {e}"))?; + let mut writer = encoder + .write_header() + .map_err(|e| format!("Failed to write PNG header: {e}"))?; + writer + .write_image_data(&pixels) + .map_err(|e| format!("Failed to write PNG data: {e}"))?; + } + Ok(buf) +} + +// --------------------------------------------------------------------------- +// Placeholder PNG generation +// --------------------------------------------------------------------------- + +pub fn generate_placeholder_png(display_name: &str) -> Result, String> { + let hue = display_name.as_bytes().iter().map(|&b| b as u32).sum::() % 360; + let (r, g, b) = hsl_to_rgb(hue as f64, 0.65, 0.55); + + const SIZE: u32 = 256; + let mut pixels = Vec::with_capacity((SIZE * SIZE * 4) as usize); + for _ in 0..(SIZE * SIZE) { + pixels.extend_from_slice(&[r, g, b, 255]); + } + + let mut buf = Vec::new(); + { + let mut encoder = Encoder::new(Cursor::new(&mut buf), SIZE, SIZE); + encoder.set_color(ColorType::Rgba); + encoder.set_depth(BitDepth::Eight); + let mut writer = encoder + .write_header() + .map_err(|e| format!("Failed to write placeholder PNG header: {e}"))?; + writer + .write_image_data(&pixels) + .map_err(|e| format!("Failed to write placeholder PNG data: {e}"))?; + } + Ok(buf) +} + +fn hsl_to_rgb(h: f64, s: f64, l: f64) -> (u8, u8, u8) { + let c = (1.0 - (2.0 * l - 1.0).abs()) * s; + let x = c * (1.0 - ((h / 60.0) % 2.0 - 1.0).abs()); + let m = l - c / 2.0; + let (r1, g1, b1) = match h as u32 { + 0..=59 => (c, x, 0.0), + 60..=119 => (x, c, 0.0), + 120..=179 => (0.0, c, x), + 180..=239 => (0.0, x, c), + 240..=299 => (x, 0.0, c), + _ => (c, 0.0, x), + }; + ( + ((r1 + m) * 255.0).round() as u8, + ((g1 + m) * 255.0).round() as u8, + ((b1 + m) * 255.0).round() as u8, + ) +} + +// --------------------------------------------------------------------------- +// ZIP parsing +// --------------------------------------------------------------------------- + +pub fn parse_zip_personas(zip_bytes: &[u8]) -> Result { + let cursor = Cursor::new(zip_bytes); + let mut archive = + zip::ZipArchive::new(cursor).map_err(|e| format!("Invalid ZIP archive: {e}"))?; + + if archive.len() > MAX_ZIP_ENTRIES { + return Err(format!( + "ZIP contains too many entries ({}, max {MAX_ZIP_ENTRIES})", + archive.len() + )); + } + + let mut personas = Vec::new(); + let mut skipped = Vec::new(); + let mut total_decompressed: usize = 0; + let mut has_png = false; + + for i in 0..archive.len() { + let mut entry = archive + .by_index(i) + .map_err(|e| format!("Failed to read ZIP entry: {e}"))?; + + let raw_name = entry.name().to_string(); + + // Sanitize path + let name = raw_name.trim_start_matches('/'); + if name.contains("..") { + skipped.push(SkippedFile { + source_file: raw_name.clone(), + reason: "Path traversal detected".to_string(), + }); + continue; + } + + if !name.to_ascii_lowercase().ends_with(".png") { + skipped.push(SkippedFile { + source_file: raw_name, + reason: "Not a PNG file".to_string(), + }); + continue; + } + + has_png = true; + + // Read with cumulative size limit + let mut data = Vec::new(); + loop { + let mut chunk = [0u8; 8192]; + let n = entry.read(&mut chunk).map_err(|e| format!("Read error: {e}"))?; + if n == 0 { + break; + } + total_decompressed += n; + if total_decompressed > MAX_ZIP_DECOMPRESSED { + return Err("ZIP decompressed content exceeds 100MB limit".to_string()); + } + data.extend_from_slice(&chunk[..n]); + } + + match parse_png_persona(&data) { + Ok(mut preview) => { + preview.source_file = raw_name; + personas.push(preview); + } + Err(reason) => { + skipped.push(SkippedFile { + source_file: raw_name, + reason, + }); + } + } + } + + if !has_png { + return Err("No PNG files found in this archive.".to_string()); + } + + Ok(ParsePersonaFilesResult { personas, skipped }) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use zip::write::{SimpleFileOptions, ZipWriter}; + + /// Helper: encode a persona PNG with given fields. + fn make_persona_png(name: &str, prompt: &str) -> Vec { + let placeholder = generate_placeholder_png(name).unwrap(); + encode_persona_png(name, prompt, &placeholder).unwrap() + } + + /// Helper: build a minimal valid PNG with a custom tEXt chunk. + fn make_png_with_text(keyword: &str, text: &str) -> Vec { + let mut buf = Vec::new(); + { + let mut enc = Encoder::new(Cursor::new(&mut buf), 1, 1); + enc.set_color(ColorType::Rgba); + enc.set_depth(BitDepth::Eight); + enc.add_text_chunk(keyword.to_string(), text.to_string()).unwrap(); + let mut w = enc.write_header().unwrap(); + w.write_image_data(&[0, 0, 0, 255]).unwrap(); + } + buf + } + + /// Helper: build a plain PNG with no metadata. + fn make_plain_png() -> Vec { + let mut buf = Vec::new(); + { + let mut enc = Encoder::new(Cursor::new(&mut buf), 1, 1); + enc.set_color(ColorType::Rgba); + enc.set_depth(BitDepth::Eight); + let mut w = enc.write_header().unwrap(); + w.write_image_data(&[0, 0, 0, 255]).unwrap(); + } + buf + } + + /// Helper: create a ZIP from name→data pairs. + fn make_test_zip(entries: &[(&str, &[u8])]) -> Vec { + let mut buf = Cursor::new(Vec::new()); + let mut zip = ZipWriter::new(&mut buf); + let options = SimpleFileOptions::default(); + for (name, data) in entries { + zip.start_file(*name, options).unwrap(); + zip.write_all(data).unwrap(); + } + zip.finish().unwrap(); + buf.into_inner() + } + + #[test] + fn parse_png_round_trip() { + let png = make_persona_png("George Costanza", "You are George."); + let result = parse_png_persona(&png).unwrap(); + assert_eq!(result.display_name, "George Costanza"); + assert_eq!(result.system_prompt, "You are George."); + assert!(result.avatar_data_url.unwrap().starts_with("data:image/png;base64,")); + } + + #[test] + fn parse_png_no_metadata() { + let png = make_plain_png(); + let err = parse_png_persona(&png).unwrap_err(); + assert!(err.contains("doesn't contain persona data")); + } + + #[test] + fn parse_png_unknown_version() { + let payload = serde_json::json!({"version": 99, "displayName": "X", "systemPrompt": "Y"}); + let b64 = STANDARD.encode(payload.to_string().as_bytes()); + let png = make_png_with_text("sprout_persona", &b64); + let err = parse_png_persona(&png).unwrap_err(); + assert!(err.contains("Unsupported persona version")); + } + + #[test] + fn parse_png_malformed_base64() { + let png = make_png_with_text("sprout_persona", "!!!not-base64!!!"); + let err = parse_png_persona(&png).unwrap_err(); + assert!(err.contains("Invalid base64")); + } + + #[test] + fn parse_png_malformed_json() { + let b64 = STANDARD.encode(b"not json at all"); + let png = make_png_with_text("sprout_persona", &b64); + let err = parse_png_persona(&png).unwrap_err(); + assert!(err.contains("Invalid JSON")); + } + + #[test] + fn parse_png_empty_fields() { + let payload = serde_json::json!({"version": 1, "displayName": "", "systemPrompt": "Y"}); + let b64 = STANDARD.encode(payload.to_string().as_bytes()); + let png = make_png_with_text("sprout_persona", &b64); + let err = parse_png_persona(&png).unwrap_err(); + assert!(err.contains("displayName is empty")); + } + + #[test] + fn parse_png_chara_fallback() { + let chara = serde_json::json!({ + "spec": "chara_card_v2", + "spec_version": "2.0", + "data": { + "name": "Kramer", + "system_prompt": "You are Kramer.", + "description": "" + } + }); + let b64 = STANDARD.encode(chara.to_string().as_bytes()); + let png = make_png_with_text("chara", &b64); + let result = parse_png_persona(&png).unwrap(); + assert_eq!(result.display_name, "Kramer"); + assert_eq!(result.system_prompt, "You are Kramer."); + } + + #[test] + fn parse_png_chara_ignored_when_sprout_present() { + // Build a PNG with both sprout_persona and chara chunks. + let sprout = serde_json::json!({"version": 1, "displayName": "Sprout Name", "systemPrompt": "Sprout prompt"}); + let chara = serde_json::json!({ + "spec": "chara_card_v2", "spec_version": "2.0", + "data": {"name": "Chara Name", "system_prompt": "Chara prompt", "description": ""} + }); + let sprout_b64 = STANDARD.encode(sprout.to_string().as_bytes()); + let chara_b64 = STANDARD.encode(chara.to_string().as_bytes()); + + let mut buf = Vec::new(); + { + let mut enc = Encoder::new(Cursor::new(&mut buf), 1, 1); + enc.set_color(ColorType::Rgba); + enc.set_depth(BitDepth::Eight); + enc.add_text_chunk("sprout_persona".to_string(), sprout_b64).unwrap(); + enc.add_text_chunk("chara".to_string(), chara_b64).unwrap(); + let mut w = enc.write_header().unwrap(); + w.write_image_data(&[0, 0, 0, 255]).unwrap(); + } + + let result = parse_png_persona(&buf).unwrap(); + assert_eq!(result.display_name, "Sprout Name"); + assert_eq!(result.system_prompt, "Sprout prompt"); + } + + #[test] + fn export_writes_both_chunks() { + let png = make_persona_png("Test", "A prompt"); + let decoder = Decoder::new(Cursor::new(&png)); + let reader = decoder.read_info().unwrap(); + let info = reader.info(); + + let keywords: Vec<&str> = info.uncompressed_latin1_text.iter().map(|c| c.keyword.as_str()).collect(); + assert!(keywords.contains(&"sprout_persona")); + assert!(keywords.contains(&"chara")); + } + + #[test] + fn parse_zip_valid_pack() { + let p1 = make_persona_png("Alice", "Prompt A"); + let p2 = make_persona_png("Bob", "Prompt B"); + let p3 = make_persona_png("Carol", "Prompt C"); + let zip = make_test_zip(&[("alice.png", &p1), ("bob.png", &p2), ("carol.png", &p3)]); + let result = parse_zip_personas(&zip).unwrap(); + assert_eq!(result.personas.len(), 3); + assert!(result.skipped.is_empty()); + assert_eq!(result.personas[0].source_file, "alice.png"); + } + + #[test] + fn parse_zip_mixed() { + let valid1 = make_persona_png("Alice", "Prompt A"); + let valid2 = make_persona_png("Bob", "Prompt B"); + let bad_png = make_plain_png(); // no metadata + let zip = make_test_zip(&[ + ("alice.png", &valid1), + ("bob.png", &valid2), + ("bad.png", &bad_png), + ("readme.txt", b"hello"), + ]); + let result = parse_zip_personas(&zip).unwrap(); + assert_eq!(result.personas.len(), 2); + assert_eq!(result.skipped.len(), 2); + } + + #[test] + fn parse_zip_no_pngs() { + let zip = make_test_zip(&[("readme.txt", b"hello"), ("data.json", b"{}")]); + let err = parse_zip_personas(&zip).unwrap_err(); + assert!(err.contains("No PNG files found")); + } + + #[test] + fn parse_zip_exceeds_entry_limit() { + let png = make_persona_png("X", "Y"); + let entries: Vec<(String, &[u8])> = (0..51).map(|i| (format!("{i}.png"), png.as_slice())).collect(); + let refs: Vec<(&str, &[u8])> = entries.iter().map(|(n, d)| (n.as_str(), *d)).collect(); + let zip = make_test_zip(&refs); + let err = parse_zip_personas(&zip).unwrap_err(); + assert!(err.contains("too many entries")); + } + + #[test] + fn parse_zip_path_traversal() { + let valid = make_persona_png("Safe", "Prompt"); + let evil = make_persona_png("Evil", "Prompt"); + let zip = make_test_zip(&[("safe.png", &valid), ("../evil.png", &evil)]); + let result = parse_zip_personas(&zip).unwrap(); + assert_eq!(result.personas.len(), 1); + assert_eq!(result.skipped.len(), 1); + assert!(result.skipped[0].reason.contains("Path traversal")); + } + + #[test] + fn placeholder_deterministic() { + let a = generate_placeholder_png("George").unwrap(); + let b = generate_placeholder_png("George").unwrap(); + assert_eq!(a, b); + } + + #[test] + fn placeholder_different_names() { + let a = generate_placeholder_png("George").unwrap(); + let b = generate_placeholder_png("Elaine").unwrap(); + assert_ne!(a, b); + } + + #[test] + fn parse_png_duplicate_chunks() { + // Two sprout_persona chunks — should use the first and ignore the second. + let payload1 = serde_json::json!({"version": 1, "displayName": "First", "systemPrompt": "Prompt 1"}); + let payload2 = serde_json::json!({"version": 1, "displayName": "Second", "systemPrompt": "Prompt 2"}); + let b64_1 = STANDARD.encode(payload1.to_string().as_bytes()); + let b64_2 = STANDARD.encode(payload2.to_string().as_bytes()); + + let mut buf = Vec::new(); + { + let mut enc = Encoder::new(Cursor::new(&mut buf), 1, 1); + enc.set_color(ColorType::Rgba); + enc.set_depth(BitDepth::Eight); + enc.add_text_chunk("sprout_persona".to_string(), b64_1).unwrap(); + enc.add_text_chunk("sprout_persona".to_string(), b64_2).unwrap(); + let mut w = enc.write_header().unwrap(); + w.write_image_data(&[0, 0, 0, 255]).unwrap(); + } + + let result = parse_png_persona(&buf).unwrap(); + assert_eq!(result.display_name, "First"); + assert_eq!(result.system_prompt, "Prompt 1"); + } + + #[test] + fn parse_zip_exceeds_size_limit() { + // Create a ZIP with entries whose cumulative decompressed size exceeds 100MB. + // Use a single large entry (101 MB of zeros — compresses well in ZIP). + let mut zip_buf = Cursor::new(Vec::new()); + { + let mut zip = ZipWriter::new(&mut zip_buf); + let options = SimpleFileOptions::default(); + zip.start_file("big.png", options).unwrap(); + // Write 101 MB in chunks — this is a valid PNG-named file but will + // exceed the decompressed size limit during streaming read. + let chunk = vec![0u8; 1024 * 1024]; // 1 MB + for _ in 0..101 { + zip.write_all(&chunk).unwrap(); + } + zip.finish().unwrap(); + } + let zip_bytes = zip_buf.into_inner(); + let err = parse_zip_personas(&zip_bytes).unwrap_err(); + assert!(err.contains("exceeds 100MB")); + } + + #[test] + fn export_data_uri_avatar_round_trip() { + // Encode with a known placeholder, then verify the round-trip preserves data. + let placeholder = generate_placeholder_png("Test").unwrap(); + let data_url = format!("data:image/png;base64,{}", STANDARD.encode(&placeholder)); + + // Decode the data URI the same way export_persona_to_png does. + let prefix = "data:image/png;base64,"; + assert!(data_url.starts_with(prefix)); + let decoded = STANDARD.decode(&data_url[prefix.len()..]).unwrap(); + assert_eq!(decoded, placeholder); + + // Encode into a persona PNG and verify it round-trips. + let png = encode_persona_png("Test", "A prompt", &decoded).unwrap(); + let result = parse_png_persona(&png).unwrap(); + assert_eq!(result.display_name, "Test"); + assert_eq!(result.system_prompt, "A prompt"); + } + + #[test] + fn export_no_avatar_uses_placeholder() { + // When avatar_url is None, export should use generate_placeholder_png. + let placeholder = generate_placeholder_png("NoAvatar").unwrap(); + let png = encode_persona_png("NoAvatar", "Prompt", &placeholder).unwrap(); + let result = parse_png_persona(&png).unwrap(); + assert_eq!(result.display_name, "NoAvatar"); + // The avatar_data_url should be a valid data URI containing the placeholder. + let avatar = result.avatar_data_url.unwrap(); + assert!(avatar.starts_with("data:image/png;base64,")); + } + + #[test] + fn export_non_png_avatar_uses_placeholder() { + // When avatar_url is https://... or any non-data-URI, export uses placeholder. + // Simulate: generate placeholder for a name, encode, verify it works. + let placeholder = generate_placeholder_png("HttpsAvatar").unwrap(); + assert!(!placeholder.is_empty()); + let png = encode_persona_png("HttpsAvatar", "Prompt", &placeholder).unwrap(); + let result = parse_png_persona(&png).unwrap(); + assert_eq!(result.display_name, "HttpsAvatar"); + } +} diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 62afad529a..b2782da04a 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -22,6 +22,7 @@ "visible": false, "titleBarStyle": "Overlay", "hiddenTitle": true, + "dragDropEnabled": false, "trafficLightPosition": { "x": 12, "y": 22 } } ], diff --git a/desktop/src/features/agents/channelAgents.ts b/desktop/src/features/agents/channelAgents.ts index a6a711addd..73d4c5349b 100644 --- a/desktop/src/features/agents/channelAgents.ts +++ b/desktop/src/features/agents/channelAgents.ts @@ -6,6 +6,7 @@ import { listManagedAgents, startManagedAgent, stopManagedAgent, + uploadMediaBytes, } from "@/shared/api/tauri"; import type { AcpProvider, @@ -265,6 +266,22 @@ export async function createChannelManagedAgent( throw new Error("Agent name is required."); } + // If the avatar is a data URI (e.g. from a persona PNG card import), + // upload it to get a hosted URL the relay can serve. + let resolvedAvatarUrl = input.avatarUrl?.trim() || undefined; + if (resolvedAvatarUrl?.startsWith("data:image/")) { + try { + const [, b64] = resolvedAvatarUrl.split(",", 2); + if (!b64) throw new Error("empty data URI payload"); + const bytes = Array.from(atob(b64), (c) => c.charCodeAt(0)); + const blob = await uploadMediaBytes(bytes); + resolvedAvatarUrl = blob.url; + } catch (err) { + console.warn("Avatar upload failed, proceeding without avatar:", err); + resolvedAvatarUrl = undefined; + } + } + const created = await createManagedAgent({ name: trimmedName, acpCommand: "sprout-acp", @@ -276,7 +293,7 @@ export async function createChannelManagedAgent( tokenScopes: DEFAULT_MANAGED_AGENT_SCOPES, personaId: input.personaId ?? undefined, systemPrompt: input.systemPrompt?.trim() || undefined, - avatarUrl: input.avatarUrl?.trim() || undefined, + avatarUrl: resolvedAvatarUrl, spawnAfterCreate: false, }); const attached = await attachManagedAgentToChannel(channelId, { diff --git a/desktop/src/features/agents/hooks.ts b/desktop/src/features/agents/hooks.ts index 8a395c314f..912c328ddc 100644 --- a/desktop/src/features/agents/hooks.ts +++ b/desktop/src/features/agents/hooks.ts @@ -22,6 +22,7 @@ import { import { createPersona, deletePersona, + exportPersonaToPng, listPersonas, updatePersona, } from "@/shared/api/tauriPersonas"; @@ -405,6 +406,12 @@ export function useEnsureGooseInChannelMutation(channelId: string | null) { }); } +export function useExportPersonaPngMutation() { + return useMutation({ + mutationFn: (id: string) => exportPersonaToPng(id), + }); +} + export function useManagedAgentLogQuery( pubkey: string | null, lineCount = 120, diff --git a/desktop/src/features/agents/ui/AgentsView.tsx b/desktop/src/features/agents/ui/AgentsView.tsx index 60a9b11985..77552d543c 100644 --- a/desktop/src/features/agents/ui/AgentsView.tsx +++ b/desktop/src/features/agents/ui/AgentsView.tsx @@ -1,10 +1,13 @@ import * as React from "react"; +import { useQueryClient } from "@tanstack/react-query"; import { type AttachManagedAgentToChannelResult, + personasQueryKey, useCreatePersonaMutation, useDeletePersonaMutation, useDeleteManagedAgentMutation, + useExportPersonaPngMutation, useManagedAgentLogQuery, useManagedAgentsQuery, useMintManagedAgentTokenMutation, @@ -15,6 +18,7 @@ import { useStopManagedAgentMutation, useUpdatePersonaMutation, } from "@/features/agents/hooks"; +import type { ParsePersonaFilesResult } from "@/shared/api/tauriPersonas"; import type { AgentPersona, Channel, @@ -25,6 +29,7 @@ import type { } from "@/shared/api/types"; import { AddAgentToChannelDialog } from "./AddAgentToChannelDialog"; import { AddTeamToChannelDialog } from "./AddTeamToChannelDialog"; +import { BatchImportDialog } from "./BatchImportDialog"; import { CreateAgentDialog } from "./CreateAgentDialog"; import { ManagedAgentLogPanel } from "./ManagedAgentLogPanel"; import { ManagedAgentsSection } from "./ManagedAgentsSection"; @@ -41,12 +46,14 @@ import { useTeamActions } from "./useTeamActions"; type PersonaDialogState = { description: string; + enableImportDrop: boolean; initialValues: CreatePersonaInput | UpdatePersonaInput; submitLabel: string; title: string; } | null; export function AgentsView() { + const queryClient = useQueryClient(); const relayAgentsQuery = useRelayAgentsQuery(); const managedAgentsQuery = useManagedAgentsQuery(); const personasQuery = usePersonasQuery(); @@ -58,6 +65,7 @@ export function AgentsView() { const createPersonaMutation = useCreatePersonaMutation(); const updatePersonaMutation = useUpdatePersonaMutation(); const deletePersonaMutation = useDeletePersonaMutation(); + const exportPersonaPngMutation = useExportPersonaPngMutation(); const [isCreateOpen, setIsCreateOpen] = React.useState(false); const [personaDialogState, setPersonaDialogState] = React.useState(null); @@ -85,7 +93,9 @@ export function AgentsView() { refetchRelayAgents: () => void relayAgentsQuery.refetch(), }, ); - + const [batchImportResult, setBatchImportResult] = + React.useState(null); + const [batchImportFileName, setBatchImportFileName] = React.useState(""); const managedAgents = React.useMemo( () => [...(managedAgentsQuery.data ?? [])].sort((left, right) => { @@ -312,6 +322,7 @@ export function AgentsView() { title: "Create persona", description: "Save a reusable role, prompt, and optional avatar for future agent deployments.", + enableImportDrop: true, submitLabel: "Create persona", initialValues: { displayName: "", @@ -328,6 +339,7 @@ export function AgentsView() { title: `Duplicate ${persona.displayName}`, description: "Create a new persona by copying this template and adjusting it as needed.", + enableImportDrop: false, submitLabel: "Create persona", initialValues: { displayName: `${persona.displayName} copy`, @@ -343,6 +355,7 @@ export function AgentsView() { title: `Edit ${persona.displayName}`, description: "Update this saved persona. New deployments will use the updated values.", + enableImportDrop: false, submitLabel: "Save changes", initialValues: { id: persona.id, @@ -352,6 +365,24 @@ export function AgentsView() { }, }); }} + onExport={(persona) => { + exportPersonaPngMutation.mutate(persona.id, { + onSuccess: (saved) => { + if (saved) { + setActionNoticeMessage( + `Exported ${persona.displayName}.`, + ); + } + }, + onError: (error) => { + setActionErrorMessage( + error instanceof Error + ? error.message + : "Failed to export persona.", + ); + }, + }); + }} personas={personas} /> @@ -476,6 +507,7 @@ export function AgentsView() { /> { + setBatchImportResult(result); + setBatchImportFileName(fileName); + setPersonaDialogState(null); + }} onOpenChange={(open) => { if (!open) { setPersonaDialogState(null); @@ -557,6 +594,23 @@ export function AgentsView() { personas={personas} team={teamActions.teamToAddToChannel} /> + { + setBatchImportResult(null); + setActionNoticeMessage( + `Imported ${count} persona${count !== 1 ? "s" : ""}.`, + ); + void queryClient.invalidateQueries({ queryKey: personasQueryKey }); + }} + onOpenChange={(open) => { + if (!open) { + setBatchImportResult(null); + } + }} + open={batchImportResult !== null} + result={batchImportResult} + /> ); } diff --git a/desktop/src/features/agents/ui/BatchImportDialog.tsx b/desktop/src/features/agents/ui/BatchImportDialog.tsx new file mode 100644 index 0000000000..5f0036f373 --- /dev/null +++ b/desktop/src/features/agents/ui/BatchImportDialog.tsx @@ -0,0 +1,237 @@ +import * as React from "react"; +import { AlertTriangle, ChevronDown, ChevronRight } from "lucide-react"; + +import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; +import type { ParsePersonaFilesResult } from "@/shared/api/tauriPersonas"; +import { createPersona } from "@/shared/api/tauriPersonas"; +import { Button } from "@/shared/ui/button"; +import { Checkbox } from "@/shared/ui/checkbox"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/shared/ui/dialog"; + +type BatchImportDialogProps = { + fileName: string; + open: boolean; + result: ParsePersonaFilesResult | null; + onOpenChange: (open: boolean) => void; + onComplete: (count: number) => void; +}; + +export function BatchImportDialog({ + fileName, + open, + result, + onOpenChange, + onComplete, +}: BatchImportDialogProps) { + const [selected, setSelected] = React.useState>(new Set()); + const [status, setStatus] = React.useState< + "idle" | "importing" | "done" | "error" + >("idle"); + const [importedCount, setImportedCount] = React.useState(0); + const [errorMessage, setErrorMessage] = React.useState(null); + const [expandedIndex, setExpandedIndex] = React.useState(null); + const [skippedExpanded, setSkippedExpanded] = React.useState(false); + + React.useEffect(() => { + if (!open) { + return; + } + + const count = result?.personas.length ?? 0; + setSelected(new Set(Array.from({ length: count }, (_, i) => i))); + setStatus("idle"); + setImportedCount(0); + setErrorMessage(null); + setExpandedIndex(null); + setSkippedExpanded(false); + }, [open, result]); + + async function handleImport() { + if (!result) { + return; + } + + setStatus("importing"); + setErrorMessage(null); + + let completed = 0; + + for (const index of selected) { + const persona = result.personas[index]; + if (!persona) { + continue; + } + + try { + await createPersona({ + displayName: persona.displayName, + avatarUrl: persona.avatarDataUrl ?? undefined, + systemPrompt: persona.systemPrompt, + }); + completed += 1; + setImportedCount(completed); + } catch (error) { + setStatus("error"); + setErrorMessage( + `Imported ${completed} of ${selected.size}. Failed on '${persona.displayName}': ${error instanceof Error ? error.message : String(error)}. Already-imported personas are saved.`, + ); + return; + } + } + + setStatus("done"); + onComplete(completed); + } + + const personas = result?.personas ?? []; + const skipped = result?.skipped ?? []; + const selectedCount = selected.size; + + return ( + + +
+ + Import Personas + + Found {personas.length} persona + {personas.length !== 1 ? "s" : ""} in {fileName || "archive"}. + + + +
+
+ {personas.map((persona, index) => { + const isExpanded = expandedIndex === index; + const isSelected = selected.has(index); + const firstLine = persona.systemPrompt + .trim() + .split("\n") + .find((line) => line.trim().length > 0); + + return ( +
+ + {isExpanded ? ( +
+
+                          {persona.systemPrompt}
+                        
+
+ ) : null} +
+ ); + })} +
+ + {skipped.length > 0 ? ( +
+ + {skippedExpanded ? ( +
+ {skipped.map((file) => ( +

+ {file.sourceFile} + {" — "} + {file.reason} +

+ ))} +
+ ) : null} +
+ ) : null} + + {errorMessage ? ( +

+ {errorMessage} +

+ ) : null} +
+ +
+ + +
+
+
+
+ ); +} diff --git a/desktop/src/features/agents/ui/PersonaDialog.tsx b/desktop/src/features/agents/ui/PersonaDialog.tsx index cc55d32e6d..d6da6ff84a 100644 --- a/desktop/src/features/agents/ui/PersonaDialog.tsx +++ b/desktop/src/features/agents/ui/PersonaDialog.tsx @@ -1,5 +1,7 @@ import * as React from "react"; +import type { ParsePersonaFilesResult } from "@/shared/api/tauriPersonas"; +import { parsePersonaFiles } from "@/shared/api/tauriPersonas"; import type { CreatePersonaInput, UpdatePersonaInput, @@ -15,6 +17,14 @@ import { import { Input } from "@/shared/ui/input"; import { Textarea } from "@/shared/ui/textarea"; +const MAX_FILE_SIZE = 100 * 1024 * 1024; // 100 MB (ZIP ceiling; PNG is smaller) +const PNG_MAGIC = [0x89, 0x50, 0x4e, 0x47]; +const ZIP_MAGIC = [0x50, 0x4b, 0x03, 0x04]; + +function matchesMagic(bytes: number[], magic: number[]) { + return magic.every((b, i) => bytes[i] === b); +} + type PersonaDialogProps = { open: boolean; title: string; @@ -23,8 +33,10 @@ type PersonaDialogProps = { initialValues: CreatePersonaInput | UpdatePersonaInput | null; error: Error | null; isPending: boolean; + enableImportDrop?: boolean; onOpenChange: (open: boolean) => void; onSubmit: (input: CreatePersonaInput | UpdatePersonaInput) => Promise; + onBatchImport?: (result: ParsePersonaFilesResult, fileName: string) => void; }; export function PersonaDialog({ @@ -35,12 +47,16 @@ export function PersonaDialog({ initialValues, error, isPending, + enableImportDrop, onOpenChange, onSubmit, + onBatchImport, }: PersonaDialogProps) { const [displayName, setDisplayName] = React.useState(""); const [avatarUrl, setAvatarUrl] = React.useState(""); const [systemPrompt, setSystemPrompt] = React.useState(""); + const [isDragOver, setIsDragOver] = React.useState(false); + const [importError, setImportError] = React.useState(null); React.useEffect(() => { if (!open || !initialValues) { @@ -57,11 +73,67 @@ export function PersonaDialog({ setDisplayName(""); setAvatarUrl(""); setSystemPrompt(""); + setIsDragOver(false); + setImportError(null); } onOpenChange(next); } + async function handleDrop(e: React.DragEvent) { + e.preventDefault(); + setIsDragOver(false); + + if (!enableImportDrop) { + return; + } + + const file = e.dataTransfer.files[0]; + if (!file) { + return; + } + + if (file.size > MAX_FILE_SIZE) { + setImportError("File is too large (max 100 MB)."); + return; + } + + setImportError(null); + + try { + const buffer = await file.arrayBuffer(); + const bytes = Array.from(new Uint8Array(buffer)); + const result = await parsePersonaFiles(bytes, file.name); + + const isPng = matchesMagic(bytes, PNG_MAGIC); + const isZip = matchesMagic(bytes, ZIP_MAGIC); + + if (isPng && result.personas.length === 1) { + const persona = result.personas[0]; + setDisplayName(persona.displayName); + setSystemPrompt(persona.systemPrompt); + setAvatarUrl(persona.avatarDataUrl ?? ""); + return; + } + + if (isZip && result.personas.length > 0 && onBatchImport) { + onBatchImport(result, file.name); + return; + } + + if (result.personas.length === 0) { + setImportError("No valid personas found in file."); + return; + } + + setImportError("Unsupported file format. Drop a .persona.png or .zip."); + } catch (err) { + setImportError( + err instanceof Error ? err.message : "Failed to parse file.", + ); + } + } + async function handleSubmit() { if (!initialValues) { return; @@ -86,11 +158,36 @@ export function PersonaDialog({ return ( - -
+ setIsDragOver(false)} + onDragOver={(e: React.DragEvent) => { + if (enableImportDrop) { + e.preventDefault(); + setIsDragOver(true); + } + }} + onDrop={(e: React.DragEvent) => void handleDrop(e)} + > +
+ {isDragOver && enableImportDrop ? ( +
+

+ Drop .persona.png or .zip +

+
+ ) : null} + {title} - {description} + + {description} + {enableImportDrop ? ( + + Or drag a .persona.png or .zip onto this dialog to import. + + ) : null} +
@@ -156,6 +253,12 @@ export function PersonaDialog({ {error.message}

) : null} + + {importError ? ( +

+ {importError} +

+ ) : null}
diff --git a/desktop/src/features/agents/ui/PersonasSection.tsx b/desktop/src/features/agents/ui/PersonasSection.tsx index 186689251e..7d07f5f799 100644 --- a/desktop/src/features/agents/ui/PersonasSection.tsx +++ b/desktop/src/features/agents/ui/PersonasSection.tsx @@ -1,4 +1,12 @@ -import { CopyPlus, Ellipsis, Info, Pencil, Plus, Trash2 } from "lucide-react"; +import { + CopyPlus, + Download, + Ellipsis, + Info, + Pencil, + Plus, + Trash2, +} from "lucide-react"; import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; import type { AgentPersona } from "@/shared/api/types"; @@ -32,6 +40,7 @@ type PersonasSectionProps = { onCreate: () => void; onDuplicate: (persona: AgentPersona) => void; onEdit: (persona: AgentPersona) => void; + onExport: (persona: AgentPersona) => void; onDelete: (persona: AgentPersona) => void; }; @@ -43,6 +52,7 @@ export function PersonasSection({ onCreate, onDuplicate, onEdit, + onExport, onDelete, }: PersonasSectionProps) { return ( @@ -165,6 +175,13 @@ export function PersonasSection({ Duplicate + onExport(persona)} + > + + Export PNG + {!persona.isBuiltIn ? ( { await invokeTauri("delete_persona", { id }); } + +export async function parsePersonaFiles( + fileBytes: number[], + fileName: string, +): Promise { + const raw = await invokeTauri( + "parse_persona_files", + { fileBytes, fileName }, + ); + return { + personas: raw.personas.map((p) => ({ + displayName: p.display_name, + systemPrompt: p.system_prompt, + avatarDataUrl: p.avatar_data_url, + sourceFile: p.source_file, + })), + skipped: raw.skipped.map((s) => ({ + sourceFile: s.source_file, + reason: s.reason, + })), + }; +} + +export async function exportPersonaToPng(id: string): Promise { + return invokeTauri("export_persona_to_png", { id }); +} diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index cfa812f907..bb685f60a9 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -2398,6 +2398,41 @@ async function handleDeletePersona(args: { id: string }): Promise { } } +async function handleParsePersonaFiles(args: { + fileBytes: number[]; + fileName: string; +}): Promise<{ + personas: { + display_name: string; + system_prompt: string; + avatar_data_url: string | null; + source_file: string; + }[]; + skipped: { source_file: string; reason: string }[]; +}> { + // In test mode, return canned data — we can't actually parse PNG chunks in JS + return { + personas: [ + { + display_name: "Imported Persona", + system_prompt: "You are an imported test persona.", + avatar_data_url: null, + source_file: args.fileName, + }, + ], + skipped: [], + }; +} + +async function handleExportPersonaToPng(args: { + id: string; +}): Promise { + // In test mode, just verify the persona exists + const persona = mockPersonas.find((p) => p.id === args.id); + if (!persona) throw new Error(`Persona ${args.id} not found.`); + return true; // Simulate successful save +} + async function handleCreateManagedAgent(args: { input: { name: string; @@ -3161,6 +3196,12 @@ export function maybeInstallE2eTauriMocks() { return handleDeletePersona( payload as Parameters[0], ); + case "parse_persona_files": + return handleParsePersonaFiles( + payload as { fileBytes: number[]; fileName: string }, + ); + case "export_persona_to_png": + return handleExportPersonaToPng(payload as { id: string }); case "list_managed_agents": return handleListManagedAgents(); case "create_managed_agent":