feat: add v3 character card support

- Added CharacterCardV3 struct to handle v3 format
- Created From<CharacterCardV3> for CharacterCardV2Data conversion
- Updated read_character_card_from_png() to detect and parse both v2 and v3 specs
- V3 cards have top-level fields + nested data object
- Maintains full backward compatibility with v2 cards
- Export still uses v2 format for maximum compatibility
This commit is contained in:
2025-10-14 08:24:58 -07:00
parent f31e3fb28a
commit 56b9c0b266
5 changed files with 188 additions and 9 deletions

View File

@@ -14,7 +14,8 @@
"Bash(npm run tauri:dev:*)",
"Bash(git add:*)",
"Bash(git commit:*)",
"WebSearch"
"WebSearch",
"Bash(python3:*)"
],
"deny": [],
"ask": []

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 747 KiB

73
check_png_chunks.py Normal file
View File

@@ -0,0 +1,73 @@
#!/usr/bin/env python3
import struct
import sys
def read_png_chunks(filename):
with open(filename, 'rb') as f:
# Check PNG signature
signature = f.read(8)
if signature != b'\x89PNG\r\n\x1a\n':
print("Not a valid PNG file!")
return
print("Valid PNG file")
print("\nChunks found:")
print("-" * 60)
chunk_num = 0
while True:
# Read chunk length
length_data = f.read(4)
if len(length_data) < 4:
break
length = struct.unpack('>I', length_data)[0]
# Read chunk type
chunk_type = f.read(4)
if len(chunk_type) < 4:
break
chunk_type_str = chunk_type.decode('ascii', errors='ignore')
# Read chunk data
data = f.read(length)
# Read CRC
crc = f.read(4)
chunk_num += 1
# Print chunk info
print(f"{chunk_num}. Type: {chunk_type_str:8s} | Length: {length:8d} bytes", end='')
# For text chunks, show the keyword
if chunk_type_str in ['tEXt', 'iTXt', 'zTXt']:
try:
# Find null terminator for keyword
null_pos = data.find(b'\x00')
if null_pos > 0:
keyword = data[:null_pos].decode('latin-1', errors='ignore')
print(f" | Keyword: '{keyword}'", end='')
# For tEXt, show some of the text content
if chunk_type_str == 'tEXt' and len(data) > null_pos + 1:
text_preview = data[null_pos+1:null_pos+51]
print(f" | Text: {text_preview[:50]}...", end='')
except:
pass
print()
if chunk_type_str == 'IEND':
break
print("-" * 60)
print(f"Total chunks: {chunk_num}")
if __name__ == '__main__':
if len(sys.argv) < 2:
print("Usage: check_png_chunks.py <png_file>")
sys.exit(1)
read_png_chunks(sys.argv[1])

View File

@@ -53,7 +53,7 @@ struct Character {
extensions: serde_json::Value,
}
// V2 character card specification structs
// V2/V3 character card specification structs
#[derive(Debug, Serialize, Deserialize)]
struct CharacterCardV2 {
spec: String,
@@ -61,6 +61,55 @@ struct CharacterCardV2 {
data: CharacterCardV2Data,
}
// V3 card format (fields at top level + data object)
#[derive(Debug, Serialize, Deserialize)]
struct CharacterCardV3 {
spec: String,
spec_version: String,
name: String,
#[serde(default)]
description: Option<String>,
#[serde(default)]
personality: Option<String>,
#[serde(default)]
scenario: Option<String>,
#[serde(default)]
first_mes: Option<String>,
#[serde(default)]
mes_example: Option<String>,
#[serde(default)]
data: serde_json::Value, // V3 has additional data nested here
#[serde(default)]
tags: Vec<String>,
#[serde(default)]
extensions: serde_json::Value,
}
impl From<CharacterCardV3> for CharacterCardV2Data {
fn from(v3: CharacterCardV3) -> Self {
Self {
name: v3.name,
description: v3.description,
personality: v3.personality,
scenario: v3.scenario,
first_mes: v3.first_mes,
mes_example: v3.mes_example,
system_prompt: v3.data.get("system_prompt").and_then(|v| v.as_str()).map(String::from),
post_history_instructions: v3.data.get("post_history_instructions").and_then(|v| v.as_str()).map(String::from),
alternate_greetings: v3.data.get("alternate_greetings")
.and_then(|v| v.as_array())
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect())
.unwrap_or_default(),
character_book: v3.data.get("character_book").cloned(),
tags: v3.tags,
creator: v3.data.get("creator").and_then(|v| v.as_str()).map(String::from),
character_version: v3.data.get("character_version").and_then(|v| v.as_str()).map(String::from),
creator_notes: v3.data.get("creator_notes").and_then(|v| v.as_str()).map(String::from),
extensions: v3.extensions,
}
}
}
#[derive(Debug, Serialize, Deserialize)]
struct CharacterCardV2Data {
name: String,
@@ -314,16 +363,29 @@ fn read_character_card_from_png(png_path: &PathBuf) -> Result<CharacterCardV2Dat
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)
// First try to parse as generic value to check spec version
let generic: serde_json::Value = 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));
}
let spec = generic.get("spec")
.and_then(|v| v.as_str())
.ok_or_else(|| "No spec field in character card".to_string())?;
Ok(card.data)
match spec {
"chara_card_v2" => {
// Parse as V2 card
let card: CharacterCardV2 = serde_json::from_value(generic)
.map_err(|e| format!("Failed to parse V2 card: {}", e))?;
Ok(card.data)
}
"chara_card_v3" => {
// Parse as V3 card and convert to V2Data
let card: CharacterCardV3 = serde_json::from_value(generic)
.map_err(|e| format!("Failed to parse V3 card: {}", e))?;
Ok(CharacterCardV2Data::from(card))
}
_ => Err(format!("Unsupported character card spec: {}", spec))
}
}
fn write_character_card_to_png(