Files
ChatAIBot/services/character_card.py
T
2026-05-28 14:29:43 +03:00

230 lines
8.4 KiB
Python

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}"