mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(desktop): replace PNG persona export with JSON format (#144)
This commit is contained in:
@@ -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_OPEN_BRACE: u8 = 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![],
|
||||
});
|
||||
}
|
||||
let mut preview = parse_png_persona(&file_bytes)?;
|
||||
|
||||
if magic == ZIP_MAGIC {
|
||||
return parse_zip_personas(&file_bytes);
|
||||
}
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
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)
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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};
|
||||
@@ -80,8 +80,9 @@ fn decode_b64_json(b64: &str) -> Result<Value, String> {
|
||||
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")?;
|
||||
@@ -140,120 +146,49 @@ fn parse_chara_payload(b64: &str) -> Result<(String, String), String> {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PNG persona encoding
|
||||
// JSON persona parsing / encoding
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub fn encode_persona_png(
|
||||
pub fn parse_json_persona(json_bytes: &[u8]) -> Result<ParsedPersonaPreview, String> {
|
||||
let v: Value =
|
||||
serde_json::from_slice(json_bytes).map_err(|e| format!("Invalid JSON: {e}"))?;
|
||||
let (name, prompt) = extract_sprout_fields(&v)?;
|
||||
|
||||
let avatar_data_url = v
|
||||
.get("avatarUrl")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
Ok(ParsedPersonaPreview {
|
||||
display_name: name,
|
||||
system_prompt: prompt,
|
||||
avatar_data_url,
|
||||
source_file: String::new(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn encode_persona_json(
|
||||
display_name: &str,
|
||||
system_prompt: &str,
|
||||
avatar_png_bytes: &[u8],
|
||||
avatar_url: Option<&str>,
|
||||
) -> Result<Vec<u8>, 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<Vec<u8>, String> {
|
||||
let hue = display_name.as_bytes().iter().map(|&b| b as u32).sum::<u32>() % 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 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));
|
||||
}
|
||||
|
||||
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,
|
||||
)
|
||||
serde_json::to_vec_pretty(&map).map_err(|e| format!("Failed to serialize JSON: {e}"))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -275,7 +210,7 @@ pub fn parse_zip_personas(zip_bytes: &[u8]) -> Result<ParsePersonaFilesResult, S
|
||||
let mut personas = Vec::new();
|
||||
let mut skipped = Vec::new();
|
||||
let mut total_decompressed: usize = 0;
|
||||
let mut has_png = false;
|
||||
let mut has_valid_file = false;
|
||||
|
||||
for i in 0..archive.len() {
|
||||
let mut entry = archive
|
||||
@@ -294,15 +229,24 @@ pub fn parse_zip_personas(zip_bytes: &[u8]) -> Result<ParsePersonaFilesResult, S
|
||||
continue;
|
||||
}
|
||||
|
||||
if !name.to_ascii_lowercase().ends_with(".png") {
|
||||
// Skip macOS resource fork metadata (e.g. __MACOSX/._file.json)
|
||||
if name.starts_with("__MACOSX/") || name.contains("/._") || name.starts_with("._") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let lower = name.to_ascii_lowercase();
|
||||
let is_png = lower.ends_with(".png");
|
||||
let is_json = lower.ends_with(".json");
|
||||
|
||||
if !is_png && !is_json {
|
||||
skipped.push(SkippedFile {
|
||||
source_file: raw_name,
|
||||
reason: "Not a PNG file".to_string(),
|
||||
reason: "Not a PNG or JSON file".to_string(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
has_png = true;
|
||||
has_valid_file = true;
|
||||
|
||||
// Read with cumulative size limit
|
||||
let mut data = Vec::new();
|
||||
@@ -319,7 +263,13 @@ pub fn parse_zip_personas(zip_bytes: &[u8]) -> Result<ParsePersonaFilesResult, S
|
||||
data.extend_from_slice(&chunk[..n]);
|
||||
}
|
||||
|
||||
match parse_png_persona(&data) {
|
||||
let parse_result = if is_json {
|
||||
parse_json_persona(&data)
|
||||
} else {
|
||||
parse_png_persona(&data)
|
||||
};
|
||||
|
||||
match parse_result {
|
||||
Ok(mut preview) => {
|
||||
preview.source_file = raw_name;
|
||||
personas.push(preview);
|
||||
@@ -333,8 +283,8 @@ pub fn parse_zip_personas(zip_bytes: &[u8]) -> Result<ParsePersonaFilesResult, S
|
||||
}
|
||||
}
|
||||
|
||||
if !has_png {
|
||||
return Err("No PNG files found in this archive.".to_string());
|
||||
if !has_valid_file {
|
||||
return Err("No persona files found (expected .png or .json).".to_string());
|
||||
}
|
||||
|
||||
Ok(ParsePersonaFilesResult { personas, skipped })
|
||||
@@ -347,15 +297,10 @@ pub fn parse_zip_personas(zip_bytes: &[u8]) -> Result<ParsePersonaFilesResult, S
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use png::{BitDepth, ColorType, Encoder};
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
let mut buf = Vec::new();
|
||||
@@ -370,6 +315,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<u8> {
|
||||
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<u8> {
|
||||
let mut buf = Vec::new();
|
||||
@@ -398,7 +354,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 +446,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 +460,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 +476,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 +493,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 +502,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 +529,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 +545,120 @@ 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_eq!(
|
||||
result.avatar_data_url.as_deref(),
|
||||
Some("https://example.com/ada.png")
|
||||
);
|
||||
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_round_trip_no_avatar() {
|
||||
let bytes = encode_persona_json("Bob", "You are Bob.", None).unwrap();
|
||||
let result = parse_json_persona(&bytes).unwrap();
|
||||
assert_eq!(result.display_name, "Bob");
|
||||
assert_eq!(result.system_prompt, "You are Bob.");
|
||||
assert!(result.avatar_data_url.is_none());
|
||||
}
|
||||
|
||||
#[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_round_trip_data_uri_avatar() {
|
||||
let data_uri = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg==";
|
||||
let bytes = encode_persona_json("Carol", "You are Carol.", Some(data_uri)).unwrap();
|
||||
let result = parse_json_persona(&bytes).unwrap();
|
||||
assert_eq!(result.display_name, "Carol");
|
||||
assert_eq!(result.avatar_data_url.as_deref(), Some(data_uri));
|
||||
}
|
||||
|
||||
#[test]
|
||||
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 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"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_zip_ignores_macos_resource_forks() {
|
||||
let j1 = encode_persona_json("Frank", "You are Frank.", None).unwrap();
|
||||
let j2 = encode_persona_json("Jackie", "You are Jackie.", None).unwrap();
|
||||
let zip = make_test_zip(&[
|
||||
("frank-costanza.persona.json", &j1),
|
||||
("jackie-chiles.persona.json", &j2),
|
||||
("__MACOSX/._frank-costanza.persona.json", b"\x00\x05\x16"),
|
||||
("__MACOSX/._jackie-chiles.persona.json", b"\x00\x05\x16"),
|
||||
]);
|
||||
let result = parse_zip_personas(&zip).unwrap();
|
||||
assert_eq!(result.personas.len(), 2);
|
||||
// macOS resource forks should be silently ignored, not skipped with errors
|
||||
assert!(result.skipped.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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<PersonaDialogState>(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(
|
||||
|
||||
@@ -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 ? (
|
||||
<div className="pointer-events-none absolute inset-0 z-50 flex items-center justify-center rounded-lg border-2 border-dashed border-primary/50 bg-primary/5">
|
||||
<p className="text-sm font-medium text-primary">
|
||||
Drop .persona.png or .zip
|
||||
Drop .persona.json, .persona.png, or .zip
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -184,7 +188,8 @@ export function PersonaDialog({
|
||||
{description}
|
||||
{enableImportDrop ? (
|
||||
<span className="mt-1 block text-xs text-muted-foreground/70">
|
||||
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.
|
||||
</span>
|
||||
) : null}
|
||||
</DialogDescription>
|
||||
|
||||
@@ -180,7 +180,7 @@ export function PersonasSection({
|
||||
onClick={() => onExport(persona)}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Export PNG
|
||||
Export
|
||||
</DropdownMenuItem>
|
||||
{!persona.isBuiltIn ? (
|
||||
<DropdownMenuItem
|
||||
|
||||
@@ -122,6 +122,6 @@ export async function parsePersonaFiles(
|
||||
};
|
||||
}
|
||||
|
||||
export async function exportPersonaToPng(id: string): Promise<boolean> {
|
||||
return invokeTauri<boolean>("export_persona_to_png", { id });
|
||||
export async function exportPersonaToJson(id: string): Promise<boolean> {
|
||||
return invokeTauri<boolean>("export_persona_to_json", { id });
|
||||
}
|
||||
|
||||
@@ -2424,7 +2424,7 @@ async function handleParsePersonaFiles(args: {
|
||||
};
|
||||
}
|
||||
|
||||
async function handleExportPersonaToPng(args: {
|
||||
async function handleExportPersonaToJson(args: {
|
||||
id: string;
|
||||
}): Promise<boolean> {
|
||||
// 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":
|
||||
|
||||
Reference in New Issue
Block a user