import json import base64 import uuid from pathlib import Path import aiosqlite from database.db import DB_PATH def parse_card_v2(data: dict) -> dict: inner = data.get("data", data) if isinstance(inner, str): inner = json.loads(inner) book = inner.get("character_book") or {} entries = book.get("entries", []) if isinstance(entries, dict): entries = list(entries.values()) return { "card_id": ( inner.get("name", "imported").lower().replace(" ", "_")[:48] + "_" + uuid.uuid4().hex[:8] ), "name": inner.get("name", "Character"), "description": inner.get("description", ""), "personality": inner.get("personality", ""), "scenario": inner.get("scenario", ""), "first_mes": inner.get("first_mes", ""), "mes_example": inner.get("mes_example", ""), "appearance_tags": _extract_appearance(inner), "lorebook_json": json.dumps(entries, ensure_ascii=False), "raw_json": json.dumps(data if "data" in data else {"data": inner}, ensure_ascii=False), } def _extract_appearance(inner: dict) -> str: """Extract booru-style appearance tags from character fields.""" import re # fall back: scan description for visual keywords, skip world-building sentences desc = inner.get("description", "") appearance_keywords = re.findall( r'\b(?:' r'\w*hair|hair\w*|\w*eyes|eye\w*|\w*skin|skin\w*' r'|tall|short|slim|curvy|muscular|petite' r'|ears?|tail|horns?|wings?|cloak|dress|outfit|uniform|armor' r'|wolf\w*|cat\w*|fox\w*|elf\w*|demon\w*|angel\w*' r'|silver|blonde|black|white|red|blue|green|purple|pink|brown|golden' r')\b', desc, re.IGNORECASE ) seen = [] for kw in appearance_keywords: kw_lower = kw.lower() if kw_lower not in seen: seen.append(kw_lower) return ", ".join(seen[:20]) def parse_png_card(file_bytes: bytes) -> dict | None: if not file_bytes.startswith(b"\x89PNG"): return None idx = 8 # skip PNG file signature while idx < len(file_bytes) - 12: length = int.from_bytes(file_bytes[idx : idx + 4], "big") chunk_type = file_bytes[idx + 4 : idx + 8] chunk_data = file_bytes[idx + 8 : idx + 8 + length] if chunk_type == b"tEXt": try: key, _, val = chunk_data.partition(b"\x00") if key in (b"chara", b"ccv3"): decoded = base64.b64decode(val).decode("utf-8") return parse_card_v2(json.loads(decoded)) except Exception: pass elif chunk_type == b"iTXt": try: # iTXt: keyword \x00 compression_flag \x00 compression_method \x00 language \x00 translated_keyword \x00 text key, _, rest = chunk_data.partition(b"\x00") if key in (b"chara", b"ccv3"): # skip compression_flag, compression_method, language tag, translated keyword text = rest[2:].split(b"\x00", 2)[-1].decode("utf-8") # text may be base64 or raw JSON try: return parse_card_v2(json.loads(base64.b64decode(text).decode("utf-8"))) except Exception: return parse_card_v2(json.loads(text)) except Exception: pass idx += 12 + length return None def build_system_prompt(card: dict) -> str: parts = [ f"You are {card['name']}. Stay in character.", f"Description: {card['description']}", f"Personality: {card['personality']}", f"Scenario: {card['scenario']}", ] if card.get("mes_example"): parts.append(f"Example dialogue:\n{card['mes_example']}") parts.append("Reply only as the character. Do not add image tags.") return "\n\n".join(p for p in parts if p.split(": ", 1)[-1].strip()) async def save_character(card: dict, lora_name: str = "", lora_weight: float = 0.8) -> dict: card_id = card["card_id"] async with aiosqlite.connect(DB_PATH) as db: await db.execute( """INSERT OR REPLACE INTO characters (card_id, name, description, personality, scenario, first_mes, mes_example, raw_json, lora_name, lora_weight, appearance_tags, lorebook_json, avatar_path) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", ( card_id, card["name"], card["description"], card["personality"], card["scenario"], card["first_mes"], card["mes_example"], card["raw_json"], lora_name, lora_weight, card.get("appearance_tags", ""), card["lorebook_json"], card.get("avatar_path", ""), ), ) await db.commit() return {**card, "lora_name": lora_name, "lora_weight": lora_weight} async def get_character(card_id: str) -> dict | None: async with aiosqlite.connect(DB_PATH) as db: db.row_factory = aiosqlite.Row async with db.execute( "SELECT * FROM characters WHERE card_id = ?", (card_id,) ) as cur: row = await cur.fetchone() return dict(row) if row else None async def list_characters() -> list: async with aiosqlite.connect(DB_PATH) as db: db.row_factory = aiosqlite.Row async with db.execute( "SELECT card_id, name, description, lora_name FROM characters ORDER BY name" ) as cur: rows = await cur.fetchall() return [dict(r) for r in rows] async def delete_character(card_id: str) -> bool: async with aiosqlite.connect(DB_PATH) as db: cur = await db.execute( "DELETE FROM characters WHERE card_id = ?", (card_id,) ) await db.commit() return cur.rowcount > 0 async def update_appearance_tags(card_id: str, appearance_tags: str): async with aiosqlite.connect(DB_PATH) as db: await db.execute( "UPDATE characters SET appearance_tags = ? WHERE card_id = ?", (appearance_tags, card_id), ) await db.commit() async def update_character(card_id: str, fields: dict) -> bool: allowed = {"name", "description", "personality", "scenario", "first_mes", "mes_example", "appearance_tags", "lora_name", "lora_weight", "avatar_path"} updates = {k: v for k, v in fields.items() if k in allowed} if not updates: return False cols = ", ".join(f"{k} = ?" for k in updates) async with aiosqlite.connect(DB_PATH) as db: cur = await db.execute( f"UPDATE characters SET {cols} WHERE card_id = ?", (*updates.values(), card_id), ) await db.commit() return cur.rowcount > 0 async def import_card_file(content: bytes, filename: str, lora_name: str = "", lora_weight: float = 0.8) -> dict: if filename.lower().endswith(".png"): card = parse_png_card(content) if not card: raise ValueError("PNG does not contain character card metadata") # Use the PNG itself as avatar avatar_rel = _save_avatar_bytes(content, f"card_{card['card_id']}") card["avatar_path"] = avatar_rel else: card = parse_card_v2(json.loads(content.decode("utf-8"))) saved = await save_character(card, lora_name=lora_name, lora_weight=lora_weight) persona_id = f"card_{saved['card_id']}" from services.personas import create_persona, get_persona existing = await get_persona(persona_id) if not existing: await create_persona( persona_id=persona_id, name=saved["name"], emoji="🎭", description=saved["description"][:80] or "Character card", prompt=build_system_prompt(saved), sd_enabled=True, lora_name=lora_name, lora_weight=lora_weight, appearance_tags=saved.get("appearance_tags", ""), avatar_path=saved.get("avatar_path", ""), ) return saved def _save_avatar_bytes(png_bytes: bytes, prefix: str) -> str: avatars_dir = Path("static/avatars") avatars_dir.mkdir(parents=True, exist_ok=True) fname = f"{prefix}_{uuid.uuid4().hex[:8]}.png" path = avatars_dir / fname path.write_bytes(png_bytes) return f"avatars/{fname}"