feat: add v2 character card import/export

- Expanded Character struct with all v2 spec fields (description, scenario, mes_example, post_history_instructions, alternate_greetings, character_book, tags, creator, character_version, creator_notes, extensions)
- Created CharacterCardV2 serialization structs following spec at github.com/malfoyslastname/character-card-spec-v2
- Implemented PNG metadata utilities:
  * read_character_card_from_png() - extracts and decodes character data from PNG tEXt chunks
  * write_character_card_to_png() - embeds character data into PNG files
  * create_placeholder_png() - generates gradient placeholder images for avatarless characters
- Added Tauri commands:
  * import_character_card - opens file picker, imports PNG with automatic name conflict handling
  * export_character_card - exports character as v2 PNG card with embedded metadata
- Added Import/Export buttons to character settings UI
- Full backward compatibility with existing characters using serde defaults
- Added dependencies: png 0.17, base64 0.21, image 0.24
This commit is contained in:
2025-10-14 08:19:57 -07:00
parent f82ec6f6a8
commit f31e3fb28a
6 changed files with 632 additions and 3 deletions

View File

@@ -9,7 +9,12 @@
"Bash(WAYLAND_DISPLAY=\"\" DISPLAY=:0 ./src-tauri/target/debug/tauri-app:*)", "Bash(WAYLAND_DISPLAY=\"\" DISPLAY=:0 ./src-tauri/target/debug/tauri-app:*)",
"Bash(ldd:*)", "Bash(ldd:*)",
"Read(//home/matt/.config/claudia/**)", "Read(//home/matt/.config/claudia/**)",
"WebFetch(domain:github.com)" "WebFetch(domain:github.com)",
"Bash(cat:*)",
"Bash(npm run tauri:dev:*)",
"Bash(git add:*)",
"Bash(git commit:*)",
"WebSearch"
], ],
"deny": [], "deny": [],
"ask": [] "ask": []

170
src-tauri/Cargo.lock generated
View File

@@ -270,6 +270,12 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bit_field"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6"
[[package]] [[package]]
name = "bitflags" name = "bitflags"
version = "1.3.2" version = "1.3.2"
@@ -501,6 +507,12 @@ dependencies = [
"windows-link 0.2.1", "windows-link 0.2.1",
] ]
[[package]]
name = "color_quant"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]] [[package]]
name = "combine" name = "combine"
version = "4.6.7" version = "4.6.7"
@@ -613,12 +625,37 @@ dependencies = [
"crossbeam-utils", "crossbeam-utils",
] ]
[[package]]
name = "crossbeam-deque"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
dependencies = [
"crossbeam-epoch",
"crossbeam-utils",
]
[[package]]
name = "crossbeam-epoch"
version = "0.9.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
dependencies = [
"crossbeam-utils",
]
[[package]] [[package]]
name = "crossbeam-utils" name = "crossbeam-utils"
version = "0.8.21" version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "crunchy"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]] [[package]]
name = "crypto-common" name = "crypto-common"
version = "0.1.6" version = "0.1.6"
@@ -858,6 +895,12 @@ version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
name = "either"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]] [[package]]
name = "embed-resource" name = "embed-resource"
version = "3.0.6" version = "3.0.6"
@@ -962,6 +1005,21 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
] ]
[[package]]
name = "exr"
version = "1.73.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f83197f59927b46c04a183a619b7c29df34e63e63c7869320862268c0ef687e0"
dependencies = [
"bit_field",
"half",
"lebe",
"miniz_oxide",
"rayon-core",
"smallvec",
"zune-inflate",
]
[[package]] [[package]]
name = "fastrand" name = "fastrand"
version = "2.3.0" version = "2.3.0"
@@ -1324,6 +1382,16 @@ dependencies = [
"wasi 0.14.7+wasi-0.2.4", "wasi 0.14.7+wasi-0.2.4",
] ]
[[package]]
name = "gif"
version = "0.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ae047235e33e2829703574b54fdec96bfbad892062d97fed2f76022287de61b"
dependencies = [
"color_quant",
"weezl",
]
[[package]] [[package]]
name = "gimli" name = "gimli"
version = "0.32.3" version = "0.32.3"
@@ -1497,6 +1565,17 @@ dependencies = [
"tracing", "tracing",
] ]
[[package]]
name = "half"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
dependencies = [
"cfg-if",
"crunchy",
"zerocopy",
]
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.12.3" version = "0.12.3"
@@ -1818,6 +1897,24 @@ dependencies = [
"icu_properties", "icu_properties",
] ]
[[package]]
name = "image"
version = "0.24.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5690139d2f55868e080017335e4b94cb7414274c74f1669c84fb5feba2c9f69d"
dependencies = [
"bytemuck",
"byteorder",
"color_quant",
"exr",
"gif",
"jpeg-decoder",
"num-traits",
"png",
"qoi",
"tiff",
]
[[package]] [[package]]
name = "indexmap" name = "indexmap"
version = "1.9.3" version = "1.9.3"
@@ -1947,6 +2044,15 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130"
[[package]]
name = "jpeg-decoder"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00810f1d8b74be64b13dbf3db89ac67740615d6c891f0e7b6179326533011a07"
dependencies = [
"rayon",
]
[[package]] [[package]]
name = "js-sys" name = "js-sys"
version = "0.3.81" version = "0.3.81"
@@ -2008,6 +2114,12 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "lebe"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8"
[[package]] [[package]]
name = "libappindicator" name = "libappindicator"
version = "0.9.0" version = "0.9.0"
@@ -3012,6 +3124,15 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "qoi"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001"
dependencies = [
"bytemuck",
]
[[package]] [[package]]
name = "quick-xml" name = "quick-xml"
version = "0.37.5" version = "0.37.5"
@@ -3161,6 +3282,26 @@ version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
[[package]]
name = "rayon"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f"
dependencies = [
"either",
"rayon-core",
]
[[package]]
name = "rayon-core"
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
dependencies = [
"crossbeam-deque",
"crossbeam-utils",
]
[[package]] [[package]]
name = "redox_syscall" name = "redox_syscall"
version = "0.5.18" version = "0.5.18"
@@ -4051,8 +4192,11 @@ dependencies = [
name = "tauri-app" name = "tauri-app"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"base64 0.21.7",
"bytes", "bytes",
"futures", "futures",
"image",
"png",
"reqwest", "reqwest",
"serde", "serde",
"serde_json", "serde_json",
@@ -4370,6 +4514,17 @@ dependencies = [
"syn 2.0.106", "syn 2.0.106",
] ]
[[package]]
name = "tiff"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba1310fcea54c6a9a4fd1aad794ecc02c31682f6bfbecdf460bf19533eed1e3e"
dependencies = [
"flate2",
"jpeg-decoder",
"weezl",
]
[[package]] [[package]]
name = "time" name = "time"
version = "0.3.44" version = "0.3.44"
@@ -5128,6 +5283,12 @@ dependencies = [
"windows-core 0.61.2", "windows-core 0.61.2",
] ]
[[package]]
name = "weezl"
version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a751b3277700db47d3e574514de2eced5e54dc8a5436a3bf7a0b248b2cee16f3"
[[package]] [[package]]
name = "winapi" name = "winapi"
version = "0.3.9" version = "0.3.9"
@@ -5844,6 +6005,15 @@ dependencies = [
"syn 2.0.106", "syn 2.0.106",
] ]
[[package]]
name = "zune-inflate"
version = "0.2.54"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02"
dependencies = [
"simd-adler32",
]
[[package]] [[package]]
name = "zvariant" name = "zvariant"
version = "5.7.0" version = "5.7.0"

View File

@@ -28,4 +28,7 @@ tokio = { version = "1", features = ["full"] }
uuid = { version = "1", features = ["v4"] } uuid = { version = "1", features = ["v4"] }
futures = "0.3" futures = "0.3"
bytes = "1" bytes = "1"
png = "0.17"
base64 = "0.21"
image = "0.24"

View File

@@ -1,9 +1,11 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::fs; use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
use std::io::BufWriter;
use uuid::Uuid; use uuid::Uuid;
use futures::StreamExt; use futures::StreamExt;
use tauri::Emitter; use tauri::Emitter;
use base64::Engine;
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
struct ApiConfig { struct ApiConfig {
@@ -25,6 +27,93 @@ struct Character {
greeting: Option<String>, greeting: Option<String>,
personality: Option<String>, personality: Option<String>,
created_at: i64, created_at: i64,
// V2 character card fields
#[serde(default)]
description: Option<String>,
#[serde(default)]
scenario: Option<String>,
#[serde(default)]
mes_example: Option<String>,
#[serde(default)]
post_history_instructions: Option<String>,
#[serde(default)]
alternate_greetings: Vec<String>,
#[serde(default)]
character_book: Option<serde_json::Value>,
#[serde(default)]
tags: Vec<String>,
#[serde(default)]
creator: Option<String>,
#[serde(default)]
character_version: Option<String>,
#[serde(default)]
creator_notes: Option<String>,
#[serde(default)]
extensions: serde_json::Value,
}
// V2 character card specification structs
#[derive(Debug, Serialize, Deserialize)]
struct CharacterCardV2 {
spec: String,
spec_version: String,
data: CharacterCardV2Data,
}
#[derive(Debug, Serialize, Deserialize)]
struct CharacterCardV2Data {
name: String,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
personality: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
scenario: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
first_mes: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
mes_example: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
system_prompt: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
post_history_instructions: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
alternate_greetings: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
character_book: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
tags: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
creator: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
character_version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
creator_notes: Option<String>,
#[serde(default)]
extensions: serde_json::Value,
}
impl From<Character> for CharacterCardV2Data {
fn from(character: Character) -> Self {
Self {
name: character.name,
description: character.description,
personality: character.personality,
scenario: character.scenario,
first_mes: character.greeting,
mes_example: character.mes_example,
system_prompt: Some(character.system_prompt),
post_history_instructions: character.post_history_instructions,
alternate_greetings: character.alternate_greetings,
character_book: character.character_book,
tags: character.tags,
creator: character.creator,
character_version: character.character_version,
creator_notes: character.creator_notes,
extensions: character.extensions,
}
}
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -178,6 +267,147 @@ fn get_avatar_path(filename: &str) -> PathBuf {
get_avatars_dir().join(filename) get_avatars_dir().join(filename)
} }
// PNG Character Card Utilities
fn read_character_card_from_png(png_path: &PathBuf) -> Result<CharacterCardV2Data, String> {
use png::Decoder;
use std::io::BufReader;
// Open and decode PNG
let file = fs::File::open(png_path)
.map_err(|e| format!("Failed to open PNG file: {}", e))?;
let decoder = Decoder::new(BufReader::new(file));
let reader = decoder.read_info()
.map_err(|e| format!("Failed to read PNG info: {}", e))?;
// Get metadata
let info = reader.info();
// Look for "chara" tEXt chunk
let mut chara_data = None;
for text_chunk in &info.uncompressed_latin1_text {
if text_chunk.keyword == "chara" {
chara_data = Some(text_chunk.text.clone());
break;
}
}
// Also check UTF-8 text chunks (iTXt)
if chara_data.is_none() {
for text_chunk in &info.utf8_text {
if text_chunk.keyword == "chara" {
let text = text_chunk.get_text()
.map_err(|e| format!("Failed to read UTF-8 text chunk: {}", e))?;
chara_data = Some(text);
break;
}
}
}
let chara_text = chara_data
.ok_or_else(|| "No character card data found in PNG (missing 'chara' chunk)".to_string())?;
// Base64 decode
let json_bytes = base64::engine::general_purpose::STANDARD.decode(&chara_text)
.map_err(|e| format!("Failed to decode base64: {}", e))?;
// Convert to UTF-8 string
let json_str = String::from_utf8(json_bytes)
.map_err(|e| format!("Invalid UTF-8 in character data: {}", e))?;
// Parse as V2 card
let card: CharacterCardV2 = serde_json::from_str(&json_str)
.map_err(|e| format!("Failed to parse character card JSON: {}", e))?;
// Validate spec
if card.spec != "chara_card_v2" {
return Err(format!("Unsupported character card spec: {}", card.spec));
}
Ok(card.data)
}
fn write_character_card_to_png(
character: &Character,
source_png_path: &PathBuf,
output_png_path: &PathBuf,
) -> Result<(), String> {
use image::io::Reader as ImageReader;
use png::{Encoder, ColorType, BitDepth, Compression};
// Load the source image
let img = ImageReader::open(source_png_path)
.map_err(|e| format!("Failed to open source image: {}", e))?
.decode()
.map_err(|e| format!("Failed to decode image: {}", e))?;
let rgba = img.to_rgba8();
let (width, height) = (rgba.width(), rgba.height());
// Build V2 card
let card = CharacterCardV2 {
spec: "chara_card_v2".to_string(),
spec_version: "2.0".to_string(),
data: CharacterCardV2Data::from(character.clone()),
};
// Serialize to JSON
let json_str = serde_json::to_string(&card)
.map_err(|e| format!("Failed to serialize character card: {}", e))?;
// Base64 encode
let b64_data = base64::engine::general_purpose::STANDARD.encode(json_str.as_bytes());
// Create output file
let file = fs::File::create(output_png_path)
.map_err(|e| format!("Failed to create output file: {}", e))?;
let w = BufWriter::new(file);
// Create PNG encoder
let mut encoder = Encoder::new(w, width, height);
encoder.set_color(ColorType::Rgba);
encoder.set_depth(BitDepth::Eight);
encoder.set_compression(Compression::Default);
// Add character data as tEXt chunk
encoder.add_text_chunk("chara".to_string(), b64_data)
.map_err(|e| format!("Failed to add text chunk: {}", e))?;
// Write PNG
let mut writer = encoder.write_header()
.map_err(|e| format!("Failed to write PNG header: {}", e))?;
writer.write_image_data(rgba.as_raw())
.map_err(|e| format!("Failed to write image data: {}", e))?;
writer.finish()
.map_err(|e| format!("Failed to finish writing PNG: {}", e))?;
Ok(())
}
fn create_placeholder_png(output_path: &PathBuf, character_name: &str) -> Result<(), String> {
use image::{ImageBuffer, Rgba};
// Create a 512x512 placeholder image with gradient
let width = 512;
let height = 512;
let mut img = ImageBuffer::new(width, height);
for (x, y, pixel) in img.enumerate_pixels_mut() {
// Create a simple gradient based on character name hash
let name_hash = character_name.bytes().fold(0u32, |acc, b| acc.wrapping_add(b as u32));
let r = ((name_hash % 200) + 55) as u8;
let g = ((x + y + name_hash) % 200 + 55) as u8;
let b = ((x.wrapping_mul(2) + y.wrapping_mul(3) + name_hash) % 200 + 55) as u8;
*pixel = Rgba([r, g, b, 255]);
}
img.save(output_path)
.map_err(|e| format!("Failed to save placeholder image: {}", e))?;
Ok(())
}
fn load_config() -> Option<ApiConfig> { fn load_config() -> Option<ApiConfig> {
let path = get_config_path(); let path = get_config_path();
if let Ok(contents) = fs::read_to_string(path) { if let Ok(contents) = fs::read_to_string(path) {
@@ -252,6 +482,17 @@ fn create_default_character() -> Character {
.duration_since(std::time::UNIX_EPOCH) .duration_since(std::time::UNIX_EPOCH)
.unwrap() .unwrap()
.as_secs() as i64, .as_secs() as i64,
description: None,
scenario: None,
mes_example: None,
post_history_instructions: None,
alternate_greetings: Vec::new(),
character_book: None,
tags: Vec::new(),
creator: None,
character_version: None,
creator_notes: None,
extensions: serde_json::Value::Object(serde_json::Map::new()),
} }
} }
@@ -883,6 +1124,17 @@ fn create_character(name: String, system_prompt: String) -> Result<Character, St
.duration_since(std::time::UNIX_EPOCH) .duration_since(std::time::UNIX_EPOCH)
.unwrap() .unwrap()
.as_secs() as i64, .as_secs() as i64,
description: None,
scenario: None,
mes_example: None,
post_history_instructions: None,
alternate_greetings: Vec::new(),
character_book: None,
tags: Vec::new(),
creator: None,
character_version: None,
creator_notes: None,
extensions: serde_json::Value::Object(serde_json::Map::new()),
}; };
save_character(&character)?; save_character(&character)?;
set_active_character(new_id)?; set_active_character(new_id)?;
@@ -960,6 +1212,143 @@ fn set_active_character(character_id: String) -> Result<(), String> {
} }
} }
// Import character card from PNG
#[tauri::command]
async fn import_character_card(app_handle: tauri::AppHandle) -> Result<Character, String> {
use tauri_plugin_dialog::DialogExt;
// Open file picker for PNG files
let file_path = app_handle
.dialog()
.file()
.add_filter("Character Cards", &["png"])
.blocking_pick_file();
let png_path = if let Some(path) = file_path {
PathBuf::from(
path.as_path()
.ok_or_else(|| "Could not get file path".to_string())?
.to_string_lossy()
.to_string(),
)
} else {
return Err("No file selected".to_string());
};
// Read character data from PNG
let card_data = read_character_card_from_png(&png_path)?;
// Create new character ID
let new_id = Uuid::new_v4().to_string();
// Check for name conflicts and append number if needed
let mut final_name = card_data.name.clone();
let existing_chars = list_characters()?;
let mut counter = 1;
while existing_chars.iter().any(|c| c.name == final_name) {
final_name = format!("{} ({})", card_data.name, counter);
counter += 1;
}
// Save PNG as avatar
let avatar_filename = format!("{}.png", new_id);
let avatar_dest = get_avatar_path(&avatar_filename);
// Ensure avatars directory exists
fs::create_dir_all(get_avatars_dir()).map_err(|e| e.to_string())?;
// Copy PNG to avatars directory
fs::copy(&png_path, &avatar_dest)
.map_err(|e| format!("Failed to copy avatar: {}", e))?;
// Create Character from card data
let character = Character {
id: new_id.clone(),
name: final_name,
avatar_path: Some(avatar_filename),
system_prompt: card_data.system_prompt.unwrap_or_else(||
"You are a helpful AI assistant.".to_string()
),
greeting: card_data.first_mes,
personality: card_data.personality,
created_at: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64,
description: card_data.description,
scenario: card_data.scenario,
mes_example: card_data.mes_example,
post_history_instructions: card_data.post_history_instructions,
alternate_greetings: card_data.alternate_greetings,
character_book: card_data.character_book,
tags: card_data.tags,
creator: card_data.creator,
character_version: card_data.character_version,
creator_notes: card_data.creator_notes,
extensions: card_data.extensions,
};
// Save character
save_character(&character)?;
// Set as active character
set_active_character(new_id)?;
Ok(character)
}
// Export character card to PNG
#[tauri::command]
async fn export_character_card(app_handle: tauri::AppHandle, character_id: String) -> Result<String, String> {
use tauri_plugin_dialog::DialogExt;
// Load character
let character = load_character(&character_id)
.ok_or_else(|| "Character not found".to_string())?;
// Get source PNG (avatar or create placeholder)
let source_png = if let Some(avatar_filename) = &character.avatar_path {
let avatar_path = get_avatar_path(avatar_filename);
if avatar_path.exists() {
avatar_path
} else {
// Avatar file missing, create placeholder
let temp_path = std::env::temp_dir().join(format!("{}_temp.png", character.id));
create_placeholder_png(&temp_path, &character.name)?;
temp_path
}
} else {
// No avatar, create placeholder
let temp_path = std::env::temp_dir().join(format!("{}_temp.png", character.id));
create_placeholder_png(&temp_path, &character.name)?;
temp_path
};
// Open save dialog
let save_path = app_handle
.dialog()
.file()
.add_filter("Character Card", &["png"])
.set_file_name(&format!("{}.png", character.name))
.blocking_save_file();
let output_path = if let Some(path) = save_path {
PathBuf::from(
path.as_path()
.ok_or_else(|| "Could not get file path".to_string())?
.to_string_lossy()
.to_string(),
)
} else {
return Err("Save cancelled".to_string());
};
// Write character card to PNG
write_character_card_to_png(&character, &source_png, &output_path)?;
Ok(output_path.to_string_lossy().to_string())
}
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
tauri::Builder::default() tauri::Builder::default()
@@ -989,7 +1378,9 @@ pub fn run() {
list_characters, list_characters,
create_character, create_character,
delete_character, delete_character,
set_active_character set_active_character,
import_character_card,
export_character_card
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");

View File

@@ -191,7 +191,20 @@
</button> </button>
<button type="button" id="delete-character-btn" class="btn-danger"> <button type="button" id="delete-character-btn" class="btn-danger">
Delete Character Delete Character
</button> </form> </button>
<div class="form-group" style="margin-top: 1rem; padding-top: 1rem; border-top: 1px solid var(--border-color);">
<label>Character Card Import/Export</label>
<div style="display: flex; gap: 0.5rem;">
<button type="button" id="import-character-btn" class="btn-secondary" style="flex: 1;">
Import v2 Card
</button>
<button type="button" id="export-character-btn" class="btn-secondary" style="flex: 1;">
Export v2 Card
</button>
</div>
</div>
</form>
</div> </div>
</div> </div>

View File

@@ -971,6 +971,8 @@ function setupAppControls() {
}); });
document.getElementById('upload-avatar-btn').addEventListener('click', handleAvatarUpload); document.getElementById('upload-avatar-btn').addEventListener('click', handleAvatarUpload);
document.getElementById('remove-avatar-btn').addEventListener('click', handleAvatarRemove); document.getElementById('remove-avatar-btn').addEventListener('click', handleAvatarRemove);
document.getElementById('import-character-btn').addEventListener('click', handleImportCharacter);
document.getElementById('export-character-btn').addEventListener('click', handleExportCharacter);
} }
// Keyboard shortcuts // Keyboard shortcuts
@@ -1077,6 +1079,51 @@ async function handleDeleteCharacter() {
} }
} }
// Handle character card import
async function handleImportCharacter() {
const characterMsg = document.getElementById('character-message');
try {
const importedCharacter = await invoke('import_character_card');
characterMsg.textContent = `Successfully imported ${importedCharacter.name}!`;
characterMsg.className = 'validation-message success';
// Reload characters and switch to the imported one
await loadCharacters();
await loadCharacterSettings();
setTimeout(() => {
characterMsg.style.display = 'none';
}, 3000);
} catch (error) {
console.error('Failed to import character:', error);
if (error && !error.toString().includes('No file selected') && !error.toString().includes('cancelled')) {
characterMsg.textContent = `Failed to import: ${error}`;
characterMsg.className = 'validation-message error';
}
}
}
// Handle character card export
async function handleExportCharacter() {
const characterMsg = document.getElementById('character-message');
try {
const characterId = document.getElementById('character-settings-select').value;
const outputPath = await invoke('export_character_card', { characterId });
characterMsg.textContent = `Successfully exported to ${outputPath}`;
characterMsg.className = 'validation-message success';
setTimeout(() => {
characterMsg.style.display = 'none';
}, 3000);
} catch (error) {
console.error('Failed to export character:', error);
if (error && !error.toString().includes('cancelled')) {
characterMsg.textContent = `Failed to export: ${error}`;
characterMsg.className = 'validation-message error';
}
}
}
// Load chat history // Load chat history
async function loadChatHistory() { async function loadChatHistory() {
try { try {