346 lines
12 KiB
Python
346 lines
12 KiB
Python
"""
|
||
角色卡服务 - 严格按照 internal.py 的数据结构
|
||
每个角色一个文件夹,包含 character.json、avatar.png 和 chats/
|
||
"""
|
||
import json
|
||
from pathlib import Path
|
||
from typing import List, Optional
|
||
from datetime import datetime
|
||
import uuid
|
||
|
||
try:
|
||
from backend.models.internal import CharacterCard
|
||
from backend.core.config import settings
|
||
from backend.services.character_card_converter import CharacterCardConverter
|
||
except ImportError:
|
||
from models.internal import CharacterCard
|
||
from core.config import settings
|
||
from services.character_card_converter import CharacterCardConverter
|
||
|
||
|
||
class CharacterService:
|
||
"""角色卡管理服务"""
|
||
|
||
def __init__(self):
|
||
self.characters_dir = settings.CHARACTERS_PATH
|
||
self.converter = CharacterCardConverter()
|
||
|
||
# 确保目录存在
|
||
self.characters_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
def scan_all_characters(self) -> List[CharacterCard]:
|
||
"""
|
||
扫描所有角色卡
|
||
|
||
Returns:
|
||
按 lastChatAt 排序的角色卡列表(最新的在前)
|
||
"""
|
||
characters = []
|
||
|
||
for char_folder in self.characters_dir.iterdir():
|
||
if not char_folder.is_dir():
|
||
continue
|
||
|
||
try:
|
||
character = self._load_character_from_folder(char_folder)
|
||
if character:
|
||
characters.append(character)
|
||
except Exception as e:
|
||
print(f"加载角色卡失败 {char_folder.name}: {e}")
|
||
continue
|
||
|
||
# 按最后聊天时间排序(None 排最后)
|
||
characters.sort(
|
||
key=lambda c: c.lastChatAt or 0,
|
||
reverse=True
|
||
)
|
||
|
||
return characters
|
||
|
||
def _load_character_from_folder(self, folder: Path) -> Optional[CharacterCard]:
|
||
"""
|
||
从文件夹加载角色卡
|
||
|
||
Args:
|
||
folder: 角色文件夹路径
|
||
|
||
Returns:
|
||
CharacterCard 对象或 None
|
||
"""
|
||
# 1. 读取 character.json(必须存在)
|
||
char_file = folder / "character.json"
|
||
if not char_file.exists():
|
||
return None
|
||
|
||
with open(char_file, 'r', encoding='utf-8') as f:
|
||
data = json.load(f)
|
||
|
||
# 2. 检查是否有 avatar.png
|
||
avatar_path = None
|
||
avatar_file = folder / "avatar.png"
|
||
if avatar_file.exists():
|
||
# 存储相对路径,用于前端访问
|
||
avatar_path = f"/api/characters/{folder.name}/avatar"
|
||
|
||
# 3. 计算最后聊天时间
|
||
last_chat_at = self._get_last_chat_timestamp(folder)
|
||
|
||
# 4. 构建 CharacterCard 对象(严格按照数据结构)
|
||
character = CharacterCard(
|
||
id=data.get('id', str(uuid.uuid4())),
|
||
name=data['name'],
|
||
description=data.get('description', ''),
|
||
personality=data.get('personality', ''),
|
||
scenario=data.get('scenario', ''),
|
||
first_mes=data.get('first_mes', ''),
|
||
mes_example=data.get('mes_example', ''),
|
||
categories=data.get('categories', []),
|
||
tags=data.get('tags', []), # ✅ 使用标签数组
|
||
worldInfoId=data.get('worldInfoId'),
|
||
outputSchema=data.get('outputSchema'),
|
||
avatarPath=avatar_path,
|
||
alternate_greetings=data.get('alternate_greetings', []),
|
||
tableMaintenancePrompt=data.get('tableMaintenancePrompt'),
|
||
imageGenerationPrompt=data.get('imageGenerationPrompt'),
|
||
tableHeaders=data.get('tableHeaders'), # ✅ 动态表格表头
|
||
tableDefaults=data.get('tableDefaults'), # ✅ 动态表格默认值
|
||
createdAt=data.get('createdAt', int(datetime.now().timestamp())),
|
||
updatedAt=data.get('updatedAt', int(datetime.now().timestamp())),
|
||
lastChatAt=last_chat_at,
|
||
isFavorite=data.get('isFavorite', False),
|
||
version=data.get('version', 1)
|
||
)
|
||
|
||
return character
|
||
|
||
def _get_last_chat_timestamp(self, char_folder: Path) -> Optional[int]:
|
||
"""
|
||
获取角色的最后聊天时间戳
|
||
|
||
通过扫描 chats 目录下所有 .jsonl 文件的修改时间
|
||
"""
|
||
chats_dir = char_folder / "chats"
|
||
if not chats_dir.exists():
|
||
return None
|
||
|
||
latest_time = None
|
||
|
||
for chat_file in chats_dir.glob("*.jsonl"):
|
||
file_mtime = int(chat_file.stat().st_mtime)
|
||
if latest_time is None or file_mtime > latest_time:
|
||
latest_time = file_mtime
|
||
|
||
return latest_time
|
||
|
||
def get_character_by_name(self, name: str) -> Optional[CharacterCard]:
|
||
"""根据角色名获取角色卡"""
|
||
char_folder = self.characters_dir / name
|
||
if not char_folder.exists():
|
||
return None
|
||
|
||
return self._load_character_from_folder(char_folder)
|
||
|
||
def create_character(self, character_data: dict) -> CharacterCard:
|
||
"""
|
||
创建新角色卡
|
||
|
||
Args:
|
||
character_data: 角色数据字典
|
||
|
||
Returns:
|
||
创建的 CharacterCard 对象
|
||
"""
|
||
# 生成唯一ID
|
||
if 'id' not in character_data:
|
||
character_data['id'] = str(uuid.uuid4())
|
||
|
||
# 设置时间戳
|
||
now = int(datetime.now().timestamp())
|
||
character_data['createdAt'] = now
|
||
character_data['updatedAt'] = now
|
||
character_data['lastChatAt'] = None
|
||
|
||
# 创建文件夹
|
||
char_name = character_data['name']
|
||
char_folder = self.characters_dir / char_name
|
||
char_folder.mkdir(parents=True, exist_ok=True)
|
||
|
||
# 创建 chats 目录
|
||
chats_dir = char_folder / "chats"
|
||
chats_dir.mkdir(exist_ok=True)
|
||
|
||
# 保存 character.json
|
||
char_file = char_folder / "character.json"
|
||
with open(char_file, 'w', encoding='utf-8') as f:
|
||
json.dump(character_data, f, ensure_ascii=False, indent=2)
|
||
|
||
return self._load_character_from_folder(char_folder)
|
||
|
||
def update_character(self, name: str, updates: dict) -> CharacterCard:
|
||
"""
|
||
更新角色卡
|
||
|
||
Args:
|
||
name: 角色名(旧名称,用于定位文件夹)
|
||
updates: 更新的字段(可以包含 name 字段来重命名)
|
||
|
||
Returns:
|
||
更新后的 CharacterCard 对象
|
||
"""
|
||
char_folder = self.characters_dir / name
|
||
char_file = char_folder / "character.json"
|
||
|
||
if not char_file.exists():
|
||
raise FileNotFoundError(f"角色卡不存在: {name}")
|
||
|
||
# 读取现有数据
|
||
with open(char_file, 'r', encoding='utf-8') as f:
|
||
existing_data = json.load(f)
|
||
|
||
# 检查是否需要重命名
|
||
new_name = updates.get('name')
|
||
needs_rename = new_name and new_name != name
|
||
|
||
if needs_rename:
|
||
# 验证新名称是否合法
|
||
if not new_name or new_name.strip() == '':
|
||
raise ValueError("角色名不能为空")
|
||
|
||
# 检查新名称是否已存在
|
||
new_folder = self.characters_dir / new_name
|
||
if new_folder.exists():
|
||
raise FileExistsError(f"角色 '{new_name}' 已存在")
|
||
|
||
# 重命名文件夹
|
||
try:
|
||
import shutil
|
||
shutil.move(str(char_folder), str(new_folder))
|
||
char_folder = new_folder
|
||
char_file = char_folder / "character.json"
|
||
except Exception as e:
|
||
raise RuntimeError(f"重命名文件夹失败: {str(e)}")
|
||
|
||
# 合并更新
|
||
existing_data.update(updates)
|
||
existing_data['updatedAt'] = int(datetime.now().timestamp())
|
||
|
||
# 保存
|
||
with open(char_file, 'w', encoding='utf-8') as f:
|
||
json.dump(existing_data, f, ensure_ascii=False, indent=2)
|
||
|
||
return self._load_character_from_folder(char_folder)
|
||
|
||
def delete_character(self, name: str) -> bool:
|
||
"""
|
||
删除角色卡(包括所有聊天记录)
|
||
|
||
Args:
|
||
name: 角色名
|
||
|
||
Returns:
|
||
是否成功删除
|
||
"""
|
||
char_folder = self.characters_dir / name
|
||
if not char_folder.exists():
|
||
return False
|
||
|
||
import shutil
|
||
shutil.rmtree(char_folder)
|
||
return True
|
||
|
||
def save_avatar(self, name: str, image_data: bytes) -> str:
|
||
"""
|
||
保存角色头像
|
||
|
||
Args:
|
||
name: 角色名
|
||
image_data: 图片二进制数据
|
||
|
||
Returns:
|
||
头像访问路径
|
||
"""
|
||
char_folder = self.characters_dir / name
|
||
avatar_file = char_folder / "avatar.png"
|
||
|
||
with open(avatar_file, 'wb') as f:
|
||
f.write(image_data)
|
||
|
||
return f"/api/characters/{name}/avatar"
|
||
|
||
def import_from_png(self, png_data: bytes, filename: str) -> CharacterCard:
|
||
"""
|
||
从 SillyTavern PNG 导入角色卡
|
||
|
||
Args:
|
||
png_data: PNG 文件二进制数据
|
||
filename: 原始文件名
|
||
|
||
Returns:
|
||
创建的 CharacterCard 对象
|
||
"""
|
||
# 1. 提取嵌入数据
|
||
st_data = self.converter.extract_from_png(png_data)
|
||
if not st_data:
|
||
raise ValueError("PNG文件中没有嵌入角色数据")
|
||
|
||
# 2. 转换为内部格式
|
||
character = self.converter.st_to_internal(st_data)
|
||
|
||
# 3. 创建角色文件夹
|
||
char_name = character.name
|
||
char_folder = self.characters_dir / char_name
|
||
char_folder.mkdir(parents=True, exist_ok=True)
|
||
|
||
# 4. 保存 PNG 作为 avatar.png
|
||
avatar_file = char_folder / "avatar.png"
|
||
with open(avatar_file, 'wb') as f:
|
||
f.write(png_data)
|
||
|
||
# 5. 保存 character.json
|
||
char_file = char_folder / "character.json"
|
||
with open(char_file, 'w', encoding='utf-8') as f:
|
||
json.dump(character.dict(), f, ensure_ascii=False, indent=2)
|
||
|
||
# 6. 创建 chats 目录
|
||
(char_folder / "chats").mkdir(exist_ok=True)
|
||
|
||
return character
|
||
|
||
def export_as_png(self, name: str) -> bytes:
|
||
"""
|
||
导出角色为 SillyTavern PNG 格式
|
||
|
||
Args:
|
||
name: 角色名
|
||
|
||
Returns:
|
||
PNG 文件二进制数据
|
||
"""
|
||
character = self.get_character_by_name(name)
|
||
if not character:
|
||
raise FileNotFoundError(f"角色 '{name}' 不存在")
|
||
|
||
# 获取头像路径
|
||
avatar_path = None
|
||
if character.avatarPath:
|
||
# 从路径中提取文件名
|
||
avatar_filename = character.avatarPath.split('/')[-1].split('?')[0]
|
||
char_folder = self.characters_dir / name
|
||
avatar_file = char_folder / avatar_filename
|
||
if avatar_file.exists():
|
||
avatar_path = str(avatar_file)
|
||
|
||
# 如果没有头像,使用默认图片
|
||
use_default = False
|
||
if not avatar_path:
|
||
default_avatar = self.characters_dir / "defult.png"
|
||
if default_avatar.exists():
|
||
avatar_path = str(default_avatar)
|
||
use_default = True
|
||
print(f"使用默认头像: {avatar_path}")
|
||
else:
|
||
print("警告: 没有找到默认头像")
|
||
|
||
# 生成 PNG
|
||
return self.converter.export_as_png(character, avatar_path, use_default_avatar=use_default)
|