From e6d9b53ab878898fd6aad6e6db6d9d02781ff58d Mon Sep 17 00:00:00 2001 From: Wes Date: Fri, 20 Mar 2026 16:09:59 -0700 Subject: [PATCH 1/4] feat(desktop): replace PNG persona export with JSON format - Add encode_persona_json() and parse_json_persona() for the new .persona.json file format (version 1 schema) - Replace export_persona_to_png Tauri command with export_persona_to_json - Remove PNG encoding path: encode_persona_png, generate_placeholder_png, hsl_to_rgb, and chara card encoding - Keep PNG/chara import path for backwards compatibility with existing .persona.png files - Update ZIP parser to accept both .json and .png entries - Update parse_persona_files to detect and handle JSON input - Frontend: single 'Export' button (JSON only), remove PNG export UI - Update drag-and-drop import to accept .persona.json files - Add comprehensive tests for JSON round-trip, validation, and mixed ZIP archives - Update e2eBridge test harness for new command name --- desktop/src-tauri/src/commands/personas.rs | 75 ++-- desktop/src-tauri/src/lib.rs | 2 +- .../src/managed_agents/persona_card.rs | 361 ++++++++---------- desktop/src/features/agents/hooks.ts | 6 +- desktop/src/features/agents/ui/AgentsView.tsx | 7 +- .../src/features/agents/ui/PersonaDialog.tsx | 15 +- .../features/agents/ui/PersonasSection.tsx | 2 +- desktop/src/shared/api/tauriPersonas.ts | 4 +- desktop/src/testing/e2eBridge.ts | 6 +- 9 files changed, 232 insertions(+), 246 deletions(-) diff --git a/desktop/src-tauri/src/commands/personas.rs b/desktop/src-tauri/src/commands/personas.rs index 6e5ee3253c..fcaa7e3b81 100644 --- a/desktop/src-tauri/src/commands/personas.rs +++ b/desktop/src-tauri/src/commands/personas.rs @@ -1,4 +1,3 @@ -use base64::{engine::general_purpose::STANDARD, Engine as _}; use tauri::{AppHandle, State}; use tauri_plugin_dialog::DialogExt; use uuid::Uuid; @@ -6,7 +5,7 @@ use uuid::Uuid; use crate::{ app_state::AppState, managed_agents::{ - encode_persona_png, generate_placeholder_png, load_managed_agents, load_personas, + encode_persona_json, load_managed_agents, load_personas, parse_json_persona, parse_png_persona, parse_zip_personas, save_managed_agents, save_personas, CreatePersonaRequest, ParsePersonaFilesResult, PersonaRecord, UpdatePersonaRequest, }, @@ -151,10 +150,12 @@ pub fn delete_persona( // --------------------------------------------------------------------------- const MAX_PNG_BYTES: usize = 10 * 1024 * 1024; +const MAX_JSON_BYTES: usize = 5 * 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]; +const JSON_MAGIC: [u8; 1] = [0x7B]; #[tauri::command] pub fn parse_persona_files( @@ -164,33 +165,51 @@ pub fn parse_persona_files( 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()); + if file_bytes.is_empty() { + return Err("File is empty.".to_string()); } - let magic: [u8; 4] = file_bytes[..4] - .try_into() - .map_err(|_| "Failed to read file header".to_string())?; + let first_byte = file_bytes[0]; - if magic == PNG_MAGIC { - if file_bytes.len() > MAX_PNG_BYTES { - return Err("PNG file is too large (max 10 MB).".to_string()); + if file_bytes.len() >= 4 { + 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; + return Ok(ParsePersonaFilesResult { + personas: vec![preview], + skipped: vec![], + }); + } + + if magic == ZIP_MAGIC { + return parse_zip_personas(&file_bytes); + } + } + + if first_byte == JSON_MAGIC[0] { + if file_bytes.len() > MAX_JSON_BYTES { + return Err("JSON file is too large (max 5 MB).".to_string()); } - let mut preview = parse_png_persona(&file_bytes)?; + let mut preview = parse_json_persona(&file_bytes)?; preview.source_file = file_name; - Ok(ParsePersonaFilesResult { + return 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()) + }); } + + Err("Unsupported file format. Expected .persona.png, .persona.json, or .zip".to_string()) } #[tauri::command] -pub async fn export_persona_to_png( +pub async fn export_persona_to_json( id: String, app: AppHandle, state: State<'_, AppState>, @@ -213,18 +232,8 @@ pub async fn export_persona_to_png( ) }; - // 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)?; + let json_bytes = + encode_persona_json(&display_name, &system_prompt, avatar_url.as_deref())?; // Slugify display name for filename. let slug: String = display_name @@ -241,8 +250,8 @@ pub async fn export_persona_to_png( let (tx, rx) = tokio::sync::oneshot::channel(); app.dialog() .file() - .add_filter("PNG Image", &["png"]) - .set_file_name(&format!("{slug}.persona.png")) + .add_filter("JSON", &["json"]) + .set_file_name(&format!("{slug}.persona.json")) .save_file(move |path| { let _ = tx.send(path); }); @@ -256,7 +265,7 @@ pub async fn export_persona_to_png( let dest = file_path .as_path() .ok_or_else(|| "Save dialog returned an invalid path".to_string())?; - std::fs::write(dest, &png_bytes) + std::fs::write(dest, &json_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 950c24e10c..7a5438adc0 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -179,7 +179,7 @@ pub fn run() { update_team, delete_team, parse_persona_files, - export_persona_to_png, + export_persona_to_json, ]) .build(tauri::generate_context!()) .expect("error while building tauri application"); diff --git a/desktop/src-tauri/src/managed_agents/persona_card.rs b/desktop/src-tauri/src/managed_agents/persona_card.rs index 224d57cf81..58469db31f 100644 --- a/desktop/src-tauri/src/managed_agents/persona_card.rs +++ b/desktop/src-tauri/src/managed_agents/persona_card.rs @@ -1,5 +1,5 @@ use base64::{engine::general_purpose::STANDARD, Engine as _}; -use png::{BitDepth, ColorType, Decoder, Encoder}; +use png::Decoder; use serde::Serialize; use serde_json::Value; use std::io::{Cursor, Read}; @@ -140,120 +140,66 @@ fn parse_chara_payload(b64: &str) -> Result<(String, String), String> { } // --------------------------------------------------------------------------- -// PNG persona encoding +// JSON persona parsing / 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) -} +pub fn parse_json_persona(json_bytes: &[u8]) -> Result { + let v: Value = + serde_json::from_slice(json_bytes).map_err(|e| format!("Invalid JSON: {e}"))?; -// --------------------------------------------------------------------------- -// Placeholder PNG generation -// --------------------------------------------------------------------------- + let version = v.get("version").and_then(|v| v.as_u64()).unwrap_or(0); + if version != 1 { + return Err(format!("Unsupported persona version: {version}")); + } -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) + 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(ParsedPersonaPreview { + display_name: name, + system_prompt: prompt, + avatar_data_url: None, + source_file: String::new(), + }) } -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, - ) +pub fn encode_persona_json( + display_name: &str, + system_prompt: &str, + avatar_url: Option<&str>, +) -> Result, String> { + let mut map = serde_json::Map::new(); + map.insert("version".to_string(), serde_json::json!(1)); + map.insert( + "displayName".to_string(), + serde_json::json!(display_name), + ); + map.insert( + "systemPrompt".to_string(), + serde_json::json!(system_prompt), + ); + if let Some(url) = avatar_url { + map.insert("avatarUrl".to_string(), serde_json::json!(url)); + } + + serde_json::to_vec_pretty(&map).map_err(|e| format!("Failed to serialize JSON: {e}")) } // --------------------------------------------------------------------------- @@ -275,7 +221,7 @@ pub fn parse_zip_personas(zip_bytes: &[u8]) -> Result Result Result { preview.source_file = raw_name; personas.push(preview); @@ -333,8 +289,8 @@ pub fn parse_zip_personas(zip_bytes: &[u8]) -> Result Result 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(); @@ -370,6 +321,17 @@ mod tests { buf } + /// Helper: build a PNG with a sprout_persona tEXt chunk for the given name/prompt. + fn make_test_persona_png(name: &str, prompt: &str) -> Vec { + let payload = serde_json::json!({ + "version": 1, + "displayName": name, + "systemPrompt": prompt, + }); + let b64 = STANDARD.encode(payload.to_string().as_bytes()); + make_png_with_text("sprout_persona", &b64) + } + /// Helper: build a plain PNG with no metadata. fn make_plain_png() -> Vec { let mut buf = Vec::new(); @@ -398,7 +360,7 @@ mod tests { #[test] fn parse_png_round_trip() { - let png = make_persona_png("George Costanza", "You are George."); + let png = make_test_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."); @@ -490,23 +452,11 @@ mod tests { 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 p1 = make_test_persona_png("Alice", "Prompt A"); + let p2 = make_test_persona_png("Bob", "Prompt B"); + let p3 = make_test_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); @@ -516,8 +466,8 @@ mod tests { #[test] fn parse_zip_mixed() { - let valid1 = make_persona_png("Alice", "Prompt A"); - let valid2 = make_persona_png("Bob", "Prompt B"); + let valid1 = make_test_persona_png("Alice", "Prompt A"); + let valid2 = make_test_persona_png("Bob", "Prompt B"); let bad_png = make_plain_png(); // no metadata let zip = make_test_zip(&[ ("alice.png", &valid1), @@ -532,14 +482,14 @@ mod tests { #[test] fn parse_zip_no_pngs() { - let zip = make_test_zip(&[("readme.txt", b"hello"), ("data.json", b"{}")]); + let zip = make_test_zip(&[("readme.txt", b"hello"), ("data.csv", b"a,b")]); let err = parse_zip_personas(&zip).unwrap_err(); - assert!(err.contains("No PNG files found")); + assert!(err.contains("No persona files found")); } #[test] fn parse_zip_exceeds_entry_limit() { - let png = make_persona_png("X", "Y"); + let png = make_test_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); @@ -549,8 +499,8 @@ mod tests { #[test] fn parse_zip_path_traversal() { - let valid = make_persona_png("Safe", "Prompt"); - let evil = make_persona_png("Evil", "Prompt"); + let valid = make_test_persona_png("Safe", "Prompt"); + let evil = make_test_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); @@ -558,20 +508,6 @@ mod tests { 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. @@ -599,14 +535,11 @@ mod tests { #[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(); @@ -618,45 +551,83 @@ mod tests { assert!(err.contains("exceeds 100MB")); } + // --- JSON persona tests --- + #[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"); + fn parse_json_round_trip() { + let bytes = + encode_persona_json("Ada Lovelace", "You are Ada.", Some("https://example.com/ada.png")) + .unwrap(); + let result = parse_json_persona(&bytes).unwrap(); + assert_eq!(result.display_name, "Ada Lovelace"); + assert_eq!(result.system_prompt, "You are Ada."); + assert!(result.avatar_data_url.is_none()); + assert!(result.source_file.is_empty()); } #[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,")); + fn parse_json_invalid_version() { + let json = serde_json::json!({ + "version": 99, + "displayName": "X", + "systemPrompt": "Y" + }); + let bytes = serde_json::to_vec(&json).unwrap(); + let err = parse_json_persona(&bytes).unwrap_err(); + assert!(err.contains("Unsupported persona version")); } #[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"); + fn parse_json_empty_fields() { + let json_empty_name = serde_json::json!({ + "version": 1, + "displayName": "", + "systemPrompt": "Y" + }); + let err = parse_json_persona(&serde_json::to_vec(&json_empty_name).unwrap()).unwrap_err(); + assert!(err.contains("displayName is empty")); + + let json_empty_prompt = serde_json::json!({ + "version": 1, + "displayName": "X", + "systemPrompt": "" + }); + let err = + parse_json_persona(&serde_json::to_vec(&json_empty_prompt).unwrap()).unwrap_err(); + assert!(err.contains("systemPrompt is empty")); + } + + #[test] + fn parse_json_malformed() { + let err = parse_json_persona(b"not json at all").unwrap_err(); + assert!(err.contains("Invalid JSON")); + } + + #[test] + fn parse_zip_with_json() { + let j1 = encode_persona_json("Alice", "Prompt A", None).unwrap(); + let j2 = encode_persona_json("Bob", "Prompt B", None).unwrap(); + let zip = make_test_zip(&[("alice.persona.json", &j1), ("bob.persona.json", &j2)]); + let result = parse_zip_personas(&zip).unwrap(); + assert_eq!(result.personas.len(), 2); + assert!(result.skipped.is_empty()); + assert_eq!(result.personas[0].display_name, "Alice"); + assert_eq!(result.personas[1].display_name, "Bob"); + } + + #[test] + fn parse_zip_mixed_png_and_json() { + let png = make_test_persona_png("PngPersona", "PNG prompt"); + let json = encode_persona_json("JsonPersona", "JSON prompt", None).unwrap(); + let zip = make_test_zip(&[ + ("persona.png", &png), + ("persona.json", &json), + ("readme.txt", b"hello"), + ]); + let result = parse_zip_personas(&zip).unwrap(); + assert_eq!(result.personas.len(), 2); + // readme.txt should be skipped + assert_eq!(result.skipped.len(), 1); + assert!(result.skipped[0].reason.contains("Not a PNG or JSON file")); } } diff --git a/desktop/src/features/agents/hooks.ts b/desktop/src/features/agents/hooks.ts index 912c328ddc..78623f2c55 100644 --- a/desktop/src/features/agents/hooks.ts +++ b/desktop/src/features/agents/hooks.ts @@ -22,7 +22,7 @@ import { import { createPersona, deletePersona, - exportPersonaToPng, + exportPersonaToJson, listPersonas, updatePersona, } from "@/shared/api/tauriPersonas"; @@ -406,9 +406,9 @@ export function useEnsureGooseInChannelMutation(channelId: string | null) { }); } -export function useExportPersonaPngMutation() { +export function useExportPersonaJsonMutation() { return useMutation({ - mutationFn: (id: string) => exportPersonaToPng(id), + mutationFn: (id: string) => exportPersonaToJson(id), }); } diff --git a/desktop/src/features/agents/ui/AgentsView.tsx b/desktop/src/features/agents/ui/AgentsView.tsx index 77552d543c..f3fb172d8d 100644 --- a/desktop/src/features/agents/ui/AgentsView.tsx +++ b/desktop/src/features/agents/ui/AgentsView.tsx @@ -7,7 +7,7 @@ import { useCreatePersonaMutation, useDeletePersonaMutation, useDeleteManagedAgentMutation, - useExportPersonaPngMutation, + useExportPersonaJsonMutation, useManagedAgentLogQuery, useManagedAgentsQuery, useMintManagedAgentTokenMutation, @@ -65,7 +65,7 @@ export function AgentsView() { const createPersonaMutation = useCreatePersonaMutation(); const updatePersonaMutation = useUpdatePersonaMutation(); const deletePersonaMutation = useDeletePersonaMutation(); - const exportPersonaPngMutation = useExportPersonaPngMutation(); + const exportPersonaJsonMutation = useExportPersonaJsonMutation(); const [isCreateOpen, setIsCreateOpen] = React.useState(false); const [personaDialogState, setPersonaDialogState] = React.useState(null); @@ -294,6 +294,7 @@ export function AgentsView() { createPersonaMutation.isPending || updatePersonaMutation.isPending || deletePersonaMutation.isPending || + exportPersonaJsonMutation.isPending || teamActions.createTeamMutation.isPending || teamActions.updateTeamMutation.isPending || teamActions.deleteTeamMutation.isPending; @@ -366,7 +367,7 @@ export function AgentsView() { }); }} onExport={(persona) => { - exportPersonaPngMutation.mutate(persona.id, { + exportPersonaJsonMutation.mutate(persona.id, { onSuccess: (saved) => { if (saved) { setActionNoticeMessage( diff --git a/desktop/src/features/agents/ui/PersonaDialog.tsx b/desktop/src/features/agents/ui/PersonaDialog.tsx index d6da6ff84a..a866ef3a45 100644 --- a/desktop/src/features/agents/ui/PersonaDialog.tsx +++ b/desktop/src/features/agents/ui/PersonaDialog.tsx @@ -17,9 +17,10 @@ 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 MAX_FILE_SIZE = 100 * 1024 * 1024; // 100 MB (ZIP ceiling) const PNG_MAGIC = [0x89, 0x50, 0x4e, 0x47]; const ZIP_MAGIC = [0x50, 0x4b, 0x03, 0x04]; +const JSON_FIRST_BYTE = 0x7b; // '{' function matchesMagic(bytes: number[], magic: number[]) { return magic.every((b, i) => bytes[i] === b); @@ -107,8 +108,9 @@ export function PersonaDialog({ const isPng = matchesMagic(bytes, PNG_MAGIC); const isZip = matchesMagic(bytes, ZIP_MAGIC); + const isJson = bytes.length > 0 && bytes[0] === JSON_FIRST_BYTE; - if (isPng && result.personas.length === 1) { + if ((isPng || isJson) && result.personas.length === 1) { const persona = result.personas[0]; setDisplayName(persona.displayName); setSystemPrompt(persona.systemPrompt); @@ -126,7 +128,9 @@ export function PersonaDialog({ return; } - setImportError("Unsupported file format. Drop a .persona.png or .zip."); + setImportError( + "Unsupported file format. Drop a .persona.json, .persona.png, or .zip.", + ); } catch (err) { setImportError( err instanceof Error ? err.message : "Failed to parse file.", @@ -173,7 +177,7 @@ export function PersonaDialog({ {isDragOver && enableImportDrop ? (

- Drop .persona.png or .zip + Drop .persona.json, .persona.png, or .zip

) : null} @@ -184,7 +188,8 @@ export function PersonaDialog({ {description} {enableImportDrop ? ( - Or drag a .persona.png or .zip onto this dialog to import. + Or drag a .persona.json, .persona.png, or .zip onto this + dialog to import. ) : null} diff --git a/desktop/src/features/agents/ui/PersonasSection.tsx b/desktop/src/features/agents/ui/PersonasSection.tsx index 7d07f5f799..6b4ec16609 100644 --- a/desktop/src/features/agents/ui/PersonasSection.tsx +++ b/desktop/src/features/agents/ui/PersonasSection.tsx @@ -180,7 +180,7 @@ export function PersonasSection({ onClick={() => onExport(persona)} > - Export PNG + Export {!persona.isBuiltIn ? ( { - return invokeTauri("export_persona_to_png", { id }); +export async function exportPersonaToJson(id: string): Promise { + return invokeTauri("export_persona_to_json", { id }); } diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index bb685f60a9..5f11fcc284 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -2424,7 +2424,7 @@ async function handleParsePersonaFiles(args: { }; } -async function handleExportPersonaToPng(args: { +async function handleExportPersonaToJson(args: { id: string; }): Promise { // In test mode, just verify the persona exists @@ -3200,8 +3200,8 @@ export function maybeInstallE2eTauriMocks() { return handleParsePersonaFiles( payload as { fileBytes: number[]; fileName: string }, ); - case "export_persona_to_png": - return handleExportPersonaToPng(payload as { id: string }); + case "export_persona_to_json": + return handleExportPersonaToJson(payload as { id: string }); case "list_managed_agents": return handleListManagedAgents(); case "create_managed_agent": From 932a597dd274066c2379b9509ecc9b3ac9708884 Mon Sep 17 00:00:00 2001 From: Wes Date: Fri, 20 Mar 2026 16:12:14 -0700 Subject: [PATCH 2/4] refactor(desktop): clean up persona JSON export/import - Extract shared extract_sprout_fields() helper to deduplicate validation logic between parse_json_persona() and parse_sprout_payload() - Rename JSON_MAGIC ([u8; 1] array) to JSON_OPEN_BRACE (u8 scalar) for clarity and consistency with the frontend's JSON_FIRST_BYTE pattern --- desktop/src-tauri/src/commands/personas.rs | 4 +-- .../src/managed_agents/persona_card.rs | 36 +++++-------------- 2 files changed, 11 insertions(+), 29 deletions(-) diff --git a/desktop/src-tauri/src/commands/personas.rs b/desktop/src-tauri/src/commands/personas.rs index fcaa7e3b81..2231afaf42 100644 --- a/desktop/src-tauri/src/commands/personas.rs +++ b/desktop/src-tauri/src/commands/personas.rs @@ -155,7 +155,7 @@ 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]; -const JSON_MAGIC: [u8; 1] = [0x7B]; +const JSON_OPEN_BRACE: u8 = 0x7B; #[tauri::command] pub fn parse_persona_files( @@ -193,7 +193,7 @@ pub fn parse_persona_files( } } - if first_byte == JSON_MAGIC[0] { + if first_byte == JSON_OPEN_BRACE { if file_bytes.len() > MAX_JSON_BYTES { return Err("JSON file is too large (max 5 MB).".to_string()); } diff --git a/desktop/src-tauri/src/managed_agents/persona_card.rs b/desktop/src-tauri/src/managed_agents/persona_card.rs index 58469db31f..7b0a6ed2af 100644 --- a/desktop/src-tauri/src/managed_agents/persona_card.rs +++ b/desktop/src-tauri/src/managed_agents/persona_card.rs @@ -80,8 +80,9 @@ fn decode_b64_json(b64: &str) -> Result { 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)?; +/// Extract and validate `displayName` + `systemPrompt` from a Sprout persona +/// JSON value (shared by both the PNG tEXt-chunk path and the standalone JSON path). +fn extract_sprout_fields(v: &Value) -> Result<(String, String), String> { let version = v.get("version").and_then(|v| v.as_u64()).unwrap_or(0); if version != 1 { return Err(format!("Unsupported persona version: {version}")); @@ -107,6 +108,11 @@ fn parse_sprout_payload(b64: &str) -> Result<(String, String), String> { Ok((name, prompt)) } +fn parse_sprout_payload(b64: &str) -> Result<(String, String), String> { + let v = decode_b64_json(b64)?; + extract_sprout_fields(&v) +} + 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")?; @@ -146,31 +152,7 @@ fn parse_chara_payload(b64: &str) -> Result<(String, String), String> { pub fn parse_json_persona(json_bytes: &[u8]) -> Result { let v: Value = serde_json::from_slice(json_bytes).map_err(|e| format!("Invalid JSON: {e}"))?; - - 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()); - } + let (name, prompt) = extract_sprout_fields(&v)?; Ok(ParsedPersonaPreview { display_name: name, From 9b372d1a4f6affa6cf97676a63c083eb9654cd83 Mon Sep 17 00:00:00 2001 From: Wes Date: Fri, 20 Mar 2026 16:36:43 -0700 Subject: [PATCH 3/4] fix(desktop): preserve avatarUrl in JSON persona import parse_json_persona() now reads the avatarUrl field from the JSON file and passes it through as avatar_data_url in the preview. This ensures base64 data-URI avatars and regular URL avatars survive the export/import round-trip. Adds tests for avatar round-trip with URL, data URI, and no-avatar cases. --- .../src/managed_agents/persona_card.rs | 32 +++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/desktop/src-tauri/src/managed_agents/persona_card.rs b/desktop/src-tauri/src/managed_agents/persona_card.rs index 7b0a6ed2af..5f79f43dda 100644 --- a/desktop/src-tauri/src/managed_agents/persona_card.rs +++ b/desktop/src-tauri/src/managed_agents/persona_card.rs @@ -154,10 +154,17 @@ pub fn parse_json_persona(json_bytes: &[u8]) -> Result Date: Fri, 20 Mar 2026 16:53:58 -0700 Subject: [PATCH 4/4] fix(desktop): silently skip macOS resource forks in ZIP persona imports Skip __MACOSX/._ entries during ZIP parsing instead of treating them as persona files and reporting confusing parse errors. --- .../src/managed_agents/persona_card.rs | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/desktop/src-tauri/src/managed_agents/persona_card.rs b/desktop/src-tauri/src/managed_agents/persona_card.rs index 5f79f43dda..5f2ee9bebe 100644 --- a/desktop/src-tauri/src/managed_agents/persona_card.rs +++ b/desktop/src-tauri/src/managed_agents/persona_card.rs @@ -229,6 +229,11 @@ pub fn parse_zip_personas(zip_bytes: &[u8]) -> Result