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:
170
src-tauri/Cargo.lock
generated
170
src-tauri/Cargo.lock
generated
@@ -270,6 +270,12 @@ version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "bit_field"
|
||||
version = "0.10.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "1.3.2"
|
||||
@@ -501,6 +507,12 @@ dependencies = [
|
||||
"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]]
|
||||
name = "combine"
|
||||
version = "4.6.7"
|
||||
@@ -613,12 +625,37 @@ dependencies = [
|
||||
"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]]
|
||||
name = "crossbeam-utils"
|
||||
version = "0.8.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
|
||||
|
||||
[[package]]
|
||||
name = "crunchy"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.6"
|
||||
@@ -858,6 +895,12 @@ version = "1.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
||||
|
||||
[[package]]
|
||||
name = "embed-resource"
|
||||
version = "3.0.6"
|
||||
@@ -962,6 +1005,21 @@ dependencies = [
|
||||
"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]]
|
||||
name = "fastrand"
|
||||
version = "2.3.0"
|
||||
@@ -1324,6 +1382,16 @@ dependencies = [
|
||||
"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]]
|
||||
name = "gimli"
|
||||
version = "0.32.3"
|
||||
@@ -1497,6 +1565,17 @@ dependencies = [
|
||||
"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]]
|
||||
name = "hashbrown"
|
||||
version = "0.12.3"
|
||||
@@ -1818,6 +1897,24 @@ dependencies = [
|
||||
"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]]
|
||||
name = "indexmap"
|
||||
version = "1.9.3"
|
||||
@@ -1947,6 +2044,15 @@ version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "js-sys"
|
||||
version = "0.3.81"
|
||||
@@ -2008,6 +2114,12 @@ version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||
|
||||
[[package]]
|
||||
name = "lebe"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8"
|
||||
|
||||
[[package]]
|
||||
name = "libappindicator"
|
||||
version = "0.9.0"
|
||||
@@ -3012,6 +3124,15 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "qoi"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.37.5"
|
||||
@@ -3161,6 +3282,26 @@ version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "redox_syscall"
|
||||
version = "0.5.18"
|
||||
@@ -4051,8 +4192,11 @@ dependencies = [
|
||||
name = "tauri-app"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64 0.21.7",
|
||||
"bytes",
|
||||
"futures",
|
||||
"image",
|
||||
"png",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4370,6 +4514,17 @@ dependencies = [
|
||||
"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]]
|
||||
name = "time"
|
||||
version = "0.3.44"
|
||||
@@ -5128,6 +5283,12 @@ dependencies = [
|
||||
"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]]
|
||||
name = "winapi"
|
||||
version = "0.3.9"
|
||||
@@ -5844,6 +6005,15 @@ dependencies = [
|
||||
"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]]
|
||||
name = "zvariant"
|
||||
version = "5.7.0"
|
||||
|
||||
@@ -28,4 +28,7 @@ tokio = { version = "1", features = ["full"] }
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
futures = "0.3"
|
||||
bytes = "1"
|
||||
png = "0.17"
|
||||
base64 = "0.21"
|
||||
image = "0.24"
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::io::BufWriter;
|
||||
use uuid::Uuid;
|
||||
use futures::StreamExt;
|
||||
use tauri::Emitter;
|
||||
use base64::Engine;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct ApiConfig {
|
||||
@@ -25,6 +27,93 @@ struct Character {
|
||||
greeting: Option<String>,
|
||||
personality: Option<String>,
|
||||
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)]
|
||||
@@ -178,6 +267,147 @@ fn get_avatar_path(filename: &str) -> PathBuf {
|
||||
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> {
|
||||
let path = get_config_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)
|
||||
.unwrap()
|
||||
.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)
|
||||
.unwrap()
|
||||
.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)?;
|
||||
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)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
@@ -989,7 +1378,9 @@ pub fn run() {
|
||||
list_characters,
|
||||
create_character,
|
||||
delete_character,
|
||||
set_active_character
|
||||
set_active_character,
|
||||
import_character_card,
|
||||
export_character_card
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
Reference in New Issue
Block a user