""" 角色卡服务 - 严格按照 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)