完成大量美化
This commit is contained in:
166
backend/services/character_card_converter.py
Normal file
166
backend/services/character_card_converter.py
Normal file
@@ -0,0 +1,166 @@
|
||||
"""
|
||||
角色卡格式转换器
|
||||
支持 SillyTavern V2/V3 格式与内部格式的双向转换
|
||||
"""
|
||||
import json
|
||||
import base64
|
||||
from typing import Optional, Dict, Any
|
||||
from pathlib import Path
|
||||
from PIL import Image
|
||||
import io
|
||||
|
||||
try:
|
||||
from backend.models.internal import CharacterCard
|
||||
except ImportError:
|
||||
from models.internal import CharacterCard
|
||||
|
||||
|
||||
class CharacterCardConverter:
|
||||
"""角色卡格式转换器"""
|
||||
|
||||
@staticmethod
|
||||
def st_to_internal(st_data: dict, avatar_path: Optional[str] = None) -> CharacterCard:
|
||||
"""
|
||||
SillyTavern 格式 → 内部格式
|
||||
|
||||
Args:
|
||||
st_data: SillyTavern 角色卡数据(V2/V3)
|
||||
avatar_path: 头像路径(可选)
|
||||
|
||||
Returns:
|
||||
CharacterCard 对象
|
||||
"""
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
# 兼容两种传入方式:完整ST格式或直接data
|
||||
if 'spec' in st_data:
|
||||
data = st_data.get('data', {})
|
||||
else:
|
||||
data = st_data
|
||||
|
||||
extensions = data.get('extensions', {})
|
||||
|
||||
return CharacterCard(
|
||||
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=[], # ST没有categories
|
||||
worldInfoId=extensions.get('world'),
|
||||
outputSchema=None, # ST不支持结构化输出
|
||||
avatarPath=avatar_path,
|
||||
alternate_greetings=data.get('alternate_greetings', []),
|
||||
tags=data.get('tags', []),
|
||||
createdAt=int(datetime.now().timestamp()),
|
||||
updatedAt=int(datetime.now().timestamp()),
|
||||
lastChatAt=None,
|
||||
isFavorite=extensions.get('fav', False),
|
||||
version=1
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def internal_to_st(character: CharacterCard) -> dict:
|
||||
"""
|
||||
内部格式 → SillyTavern V3 格式
|
||||
|
||||
Args:
|
||||
character: CharacterCard 对象
|
||||
|
||||
Returns:
|
||||
SillyTavern V3 格式字典
|
||||
"""
|
||||
return {
|
||||
"spec": "chara_card_v3",
|
||||
"spec_version": "3.0",
|
||||
"data": {
|
||||
"name": character.name,
|
||||
"description": character.description,
|
||||
"personality": character.personality,
|
||||
"scenario": character.scenario,
|
||||
"first_mes": character.first_mes,
|
||||
"mes_example": character.mes_example,
|
||||
"alternate_greetings": character.alternate_greetings or [],
|
||||
"tags": character.tags or [],
|
||||
"creator_notes": "",
|
||||
"system_prompt": "",
|
||||
"post_history_instructions": "",
|
||||
"extensions": {
|
||||
"world": character.worldInfoId,
|
||||
"talkativeness": 0.5,
|
||||
"fav": character.isFavorite
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def export_as_png(character: CharacterCard, avatar_path: Optional[str] = None, use_default_avatar: bool = False) -> bytes:
|
||||
"""
|
||||
导出为 SillyTavern PNG 格式
|
||||
|
||||
Args:
|
||||
character: CharacterCard 对象
|
||||
avatar_path: 头像图片路径(可选)
|
||||
use_default_avatar: 是否使用默认头像(不嵌入JSON数据)
|
||||
|
||||
Returns:
|
||||
PNG 文件的二进制数据
|
||||
"""
|
||||
# 1. 创建/加载图片
|
||||
if avatar_path and Path(avatar_path).exists():
|
||||
img = Image.open(avatar_path)
|
||||
else:
|
||||
# 创建默认图片(400x600像素,灰色背景)
|
||||
img = Image.new('RGB', (400, 600), color=(73, 109, 137))
|
||||
|
||||
# 确保是 RGBA 模式
|
||||
if img.mode != 'RGBA':
|
||||
img = img.convert('RGBA')
|
||||
|
||||
# 2. 如果不是默认头像,才嵌入 JSON 数据
|
||||
if not use_default_avatar:
|
||||
st_data = CharacterCardConverter.internal_to_st(character)
|
||||
json_str = json.dumps(st_data, ensure_ascii=False)
|
||||
base64_data = base64.b64encode(json_str.encode('utf-8')).decode('ascii')
|
||||
img.text['ccv3'] = base64_data
|
||||
|
||||
# 3. 保存到字节流
|
||||
buffer = io.BytesIO()
|
||||
img.save(buffer, format='PNG')
|
||||
buffer.seek(0)
|
||||
|
||||
return buffer.read()
|
||||
|
||||
@staticmethod
|
||||
def extract_from_png(png_data: bytes) -> Optional[dict]:
|
||||
"""
|
||||
从 PNG 文件中提取嵌入的角色数据
|
||||
|
||||
Args:
|
||||
png_data: PNG 文件的二进制数据
|
||||
|
||||
Returns:
|
||||
SillyTavern 格式字典,如果没有嵌入数据则返回 None
|
||||
"""
|
||||
try:
|
||||
img = Image.open(io.BytesIO(png_data))
|
||||
|
||||
# 尝试 V3 格式 (ccv3)
|
||||
if 'ccv3' in img.text:
|
||||
json_str = base64.b64decode(img.text['ccv3']).decode('utf-8')
|
||||
return json.loads(json_str)
|
||||
|
||||
# 尝试 V2 格式 (chara)
|
||||
elif 'chara' in img.text:
|
||||
json_str = base64.b64decode(img.text['chara']).decode('utf-8')
|
||||
return json.loads(json_str)
|
||||
|
||||
# 没有嵌入数据
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
print(f"解析PNG失败: {e}")
|
||||
return None
|
||||
318
backend/services/character_service.py
Normal file
318
backend/services/character_service.py
Normal file
@@ -0,0 +1,318 @@
|
||||
"""
|
||||
角色卡服务 - 严格按照 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', []),
|
||||
worldInfoId=data.get('worldInfoId'),
|
||||
outputSchema=data.get('outputSchema'),
|
||||
avatarPath=avatar_path,
|
||||
alternate_greetings=data.get('alternate_greetings', []),
|
||||
tags=data.get('tags', []),
|
||||
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: 更新的字段
|
||||
|
||||
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)
|
||||
|
||||
# 合并更新
|
||||
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)
|
||||
383
backend/services/chat_service.py
Normal file
383
backend/services/chat_service.py
Normal file
@@ -0,0 +1,383 @@
|
||||
"""
|
||||
聊天服务 - 处理聊天记录的读写操作
|
||||
|
||||
基于 SillyTavern JSONL 格式的聊天记录管理
|
||||
"""
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Any
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ChatService:
|
||||
"""聊天服务类,处理聊天记录的CRUD操作"""
|
||||
|
||||
def __init__(self, data_path: Path):
|
||||
"""
|
||||
初始化聊天服务
|
||||
|
||||
Args:
|
||||
data_path: 数据目录路径
|
||||
"""
|
||||
self.data_path = data_path
|
||||
self.chat_dir = data_path / "chat"
|
||||
self.chat_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def list_all_chats(self) -> Dict[str, List[Dict]]:
|
||||
"""
|
||||
获取所有角色和聊天列表
|
||||
|
||||
Returns:
|
||||
Dict[str, List[Dict]]: 字典结构,键是角色名称,值是该角色的聊天信息列表
|
||||
"""
|
||||
result = {}
|
||||
|
||||
if not self.chat_dir.exists():
|
||||
logger.warning(f"聊天目录不存在: {self.chat_dir}")
|
||||
return result
|
||||
|
||||
for role_dir in self.chat_dir.iterdir():
|
||||
try:
|
||||
if role_dir.is_dir():
|
||||
chats = []
|
||||
|
||||
for chat_file in role_dir.glob("*.jsonl"):
|
||||
chat_info = self._get_chat_summary(role_dir.name, chat_file.stem)
|
||||
if chat_info:
|
||||
chats.append(chat_info)
|
||||
|
||||
if chats:
|
||||
result[role_dir.name] = chats
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"处理角色目录 {role_dir.name} 时出错: {str(e)}")
|
||||
continue
|
||||
|
||||
return {"chat": [{"role_name": role, **chat} for role, chats in result.items() for chat in chats]}
|
||||
|
||||
def _get_chat_summary(self, role_name: str, chat_name: str) -> Optional[Dict]:
|
||||
"""
|
||||
获取聊天摘要信息
|
||||
|
||||
Args:
|
||||
role_name: 角色名称
|
||||
chat_name: 聊天名称
|
||||
|
||||
Returns:
|
||||
Dict: 聊天摘要信息,如果文件不存在则返回None
|
||||
"""
|
||||
chat_file = self.chat_dir / role_name / f"{chat_name}.jsonl"
|
||||
|
||||
if not chat_file.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(chat_file, 'r', encoding='utf-8') as f:
|
||||
lines = f.readlines()
|
||||
|
||||
if not lines:
|
||||
return None
|
||||
|
||||
# 第一行是header
|
||||
header = json.loads(lines[0])
|
||||
|
||||
# 计算消息数量(排除header)
|
||||
message_count = len(lines) - 1
|
||||
|
||||
# 获取最后修改时间
|
||||
last_modified = datetime.fromtimestamp(
|
||||
chat_file.stat().st_mtime
|
||||
).isoformat()
|
||||
|
||||
# 获取最后一条消息预览
|
||||
last_message = ""
|
||||
if message_count > 0:
|
||||
try:
|
||||
last_msg_data = json.loads(lines[-1])
|
||||
last_message = last_msg_data.get("mes", "")
|
||||
except:
|
||||
pass
|
||||
|
||||
return {
|
||||
"chat_name": chat_name,
|
||||
"user_name": header.get("user_name", "User"),
|
||||
"character_name": header.get("character_name", ""),
|
||||
"last_modified": last_modified,
|
||||
"message_count": message_count,
|
||||
"last_message": last_message
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"读取聊天摘要失败 {role_name}/{chat_name}: {str(e)}")
|
||||
return None
|
||||
|
||||
def get_chat(self, role_name: str, chat_name: str) -> Dict[str, Any]:
|
||||
"""
|
||||
获取指定聊天的完整内容
|
||||
|
||||
Args:
|
||||
role_name: 角色名称
|
||||
chat_name: 聊天名称
|
||||
|
||||
Returns:
|
||||
Dict: 包含metadata和messages的字典
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: 聊天文件不存在
|
||||
"""
|
||||
chat_file = self.chat_dir / role_name / f"{chat_name}.jsonl"
|
||||
|
||||
if not chat_file.exists():
|
||||
raise FileNotFoundError(f"Chat not found: {role_name}/{chat_name}")
|
||||
|
||||
try:
|
||||
with open(chat_file, 'r', encoding='utf-8') as f:
|
||||
lines = f.readlines()
|
||||
|
||||
if not lines:
|
||||
raise ValueError(f"Empty chat file: {role_name}/{chat_name}")
|
||||
|
||||
# 第一行是header
|
||||
header = json.loads(lines[0])
|
||||
|
||||
# 解析消息
|
||||
messages = []
|
||||
for i, line in enumerate(lines[1:], start=1):
|
||||
if line.strip(): # 跳过空行
|
||||
msg_data = json.loads(line)
|
||||
# 确保有floor字段
|
||||
if "floor" not in msg_data:
|
||||
msg_data["floor"] = i
|
||||
|
||||
messages.append(msg_data)
|
||||
|
||||
return {
|
||||
"metadata": {
|
||||
"user_name": header.get("user_name", "User"),
|
||||
"character_name": header.get("character_name", ""),
|
||||
"chat_id": header.get("chat_id_hash", ""),
|
||||
"integrity": header.get("integrity", "")
|
||||
},
|
||||
"messages": messages
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"读取聊天失败 {role_name}/{chat_name}: {str(e)}")
|
||||
raise
|
||||
|
||||
def create_chat(self, role_name: str, chat_name: str, metadata: Dict = None) -> Dict:
|
||||
"""
|
||||
创建新聊天
|
||||
|
||||
Args:
|
||||
role_name: 角色名称
|
||||
chat_name: 聊天名称
|
||||
metadata: 聊天元数据
|
||||
|
||||
Returns:
|
||||
Dict: 创建的聊天信息
|
||||
|
||||
Raises:
|
||||
FileExistsError: 聊天已存在
|
||||
"""
|
||||
chat_file = self.chat_dir / role_name / f"{chat_name}.jsonl"
|
||||
|
||||
if chat_file.exists():
|
||||
raise FileExistsError(f"Chat already exists: {role_name}/{chat_name}")
|
||||
|
||||
# 创建角色目录
|
||||
chat_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 构建header
|
||||
header = {
|
||||
"user_name": metadata.get("user_name", "User") if metadata else "User",
|
||||
"character_name": metadata.get("character_name", role_name) if metadata else role_name,
|
||||
"integrity": str(uuid.uuid4()),
|
||||
"chat_id_hash": str(uuid.uuid4()),
|
||||
"note_prompt": "",
|
||||
"note_interval": 0,
|
||||
"note_position": 0,
|
||||
"note_depth": 0,
|
||||
"note_role": 0,
|
||||
"extensions": {},
|
||||
"timedWorldInfo": {},
|
||||
"variables": {},
|
||||
"tainted": False,
|
||||
"lastInContextMessageId": -1
|
||||
}
|
||||
|
||||
# 写入header
|
||||
with open(chat_file, 'w', encoding='utf-8') as f:
|
||||
f.write(json.dumps(header, ensure_ascii=False) + '\n')
|
||||
|
||||
return {
|
||||
"role_name": role_name,
|
||||
"chat_name": chat_name,
|
||||
"metadata": {
|
||||
"user_name": header["user_name"],
|
||||
"character_name": header["character_name"]
|
||||
}
|
||||
}
|
||||
|
||||
def add_message(self, role_name: str, chat_name: str, message_data: Dict) -> Dict:
|
||||
"""
|
||||
向聊天添加新消息
|
||||
|
||||
Args:
|
||||
role_name: 角色名称
|
||||
chat_name: 聊天名称
|
||||
message_data: 消息数据
|
||||
|
||||
Returns:
|
||||
Dict: 添加的消息
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: 聊天不存在
|
||||
"""
|
||||
chat_file = self.chat_dir / role_name / f"{chat_name}.jsonl"
|
||||
|
||||
if not chat_file.exists():
|
||||
raise FileNotFoundError(f"Chat not found: {role_name}/{chat_name}")
|
||||
|
||||
try:
|
||||
# 读取现有消息以确定floor
|
||||
with open(chat_file, 'r', encoding='utf-8') as f:
|
||||
lines = f.readlines()
|
||||
|
||||
# 计算下一个floor号
|
||||
next_floor = len(lines) - 1 # 减去header行
|
||||
|
||||
# 构建完整的消息数据
|
||||
full_message = {
|
||||
"name": message_data.get("name", "User"),
|
||||
"is_user": message_data.get("is_user", True),
|
||||
"is_system": message_data.get("is_system", False),
|
||||
"floor": next_floor,
|
||||
"send_date": message_data.get("send_date", str(int(datetime.now().timestamp() * 1000))),
|
||||
"mes": message_data.get("mes", ""),
|
||||
"extra": message_data.get("extra", {}),
|
||||
"swipes": message_data.get("swipes", []),
|
||||
"swipe_id": message_data.get("swipe_id", 0),
|
||||
"force_avatar": None,
|
||||
"variables": [],
|
||||
"variables_initialized": [],
|
||||
"is_ejs_processed": []
|
||||
}
|
||||
|
||||
# 追加消息到文件
|
||||
with open(chat_file, 'a', encoding='utf-8') as f:
|
||||
f.write(json.dumps(full_message, ensure_ascii=False) + '\n')
|
||||
|
||||
return full_message
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"添加消息失败 {role_name}/{chat_name}: {str(e)}")
|
||||
raise
|
||||
|
||||
def update_message(self, role_name: str, chat_name: str, floor: int, update_data: Dict) -> Dict:
|
||||
"""
|
||||
更新指定楼层的消息
|
||||
|
||||
Args:
|
||||
role_name: 角色名称
|
||||
chat_name: 聊天名称
|
||||
floor: 楼层号
|
||||
update_data: 更新的数据
|
||||
|
||||
Returns:
|
||||
Dict: 更新后的消息
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: 聊天不存在
|
||||
ValueError: 楼层不存在
|
||||
"""
|
||||
chat_file = self.chat_dir / role_name / f"{chat_name}.jsonl"
|
||||
|
||||
if not chat_file.exists():
|
||||
raise FileNotFoundError(f"Chat not found: {role_name}/{chat_name}")
|
||||
|
||||
try:
|
||||
# 读取所有行
|
||||
with open(chat_file, 'r', encoding='utf-8') as f:
|
||||
lines = f.readlines()
|
||||
|
||||
# 找到对应的消息行(floor + 1,因为第0行是header)
|
||||
message_line_index = floor + 1
|
||||
|
||||
if message_line_index >= len(lines):
|
||||
raise ValueError(f"Floor {floor} not found in chat")
|
||||
|
||||
# 解析并更新消息
|
||||
msg_data = json.loads(lines[message_line_index])
|
||||
msg_data.update(update_data)
|
||||
|
||||
# 写回文件
|
||||
lines[message_line_index] = json.dumps(msg_data, ensure_ascii=False) + '\n'
|
||||
|
||||
with open(chat_file, 'w', encoding='utf-8') as f:
|
||||
f.writelines(lines)
|
||||
|
||||
return msg_data
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"更新消息失败 {role_name}/{chat_name}/{floor}: {str(e)}")
|
||||
raise
|
||||
|
||||
def delete_message(self, role_name: str, chat_name: str, floor: int) -> Dict:
|
||||
"""
|
||||
删除指定楼层的消息
|
||||
|
||||
Args:
|
||||
role_name: 角色名称
|
||||
chat_name: 聊天名称
|
||||
floor: 楼层号
|
||||
|
||||
Returns:
|
||||
Dict: 被删除的消息
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: 聊天不存在
|
||||
ValueError: 楼层不存在
|
||||
"""
|
||||
chat_file = self.chat_dir / role_name / f"{chat_name}.jsonl"
|
||||
|
||||
if not chat_file.exists():
|
||||
raise FileNotFoundError(f"Chat not found: {role_name}/{chat_name}")
|
||||
|
||||
try:
|
||||
# 读取所有行
|
||||
with open(chat_file, 'r', encoding='utf-8') as f:
|
||||
lines = f.readlines()
|
||||
|
||||
# 找到对应的消息行
|
||||
message_line_index = floor + 1
|
||||
|
||||
if message_line_index >= len(lines):
|
||||
raise ValueError(f"Floor {floor} not found in chat")
|
||||
|
||||
# 保存被删除的消息
|
||||
deleted_msg = json.loads(lines[message_line_index])
|
||||
|
||||
# 删除该行
|
||||
del lines[message_line_index]
|
||||
|
||||
# 重新编号后续消息的floor
|
||||
for i in range(message_line_index, len(lines)):
|
||||
if lines[i].strip(): # 跳过空行
|
||||
msg_data = json.loads(lines[i])
|
||||
msg_data["floor"] = i - 1 # 重新计算floor
|
||||
lines[i] = json.dumps(msg_data, ensure_ascii=False) + '\n'
|
||||
|
||||
# 写回文件
|
||||
with open(chat_file, 'w', encoding='utf-8') as f:
|
||||
f.writelines(lines)
|
||||
|
||||
return deleted_msg
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"删除消息失败 {role_name}/{chat_name}/{floor}: {str(e)}")
|
||||
raise
|
||||
@@ -60,10 +60,14 @@ class WorldBookService:
|
||||
with open(json_file, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
# 内部格式:entries 是列表
|
||||
entries = data.get("entries", [])
|
||||
entries_count = len(entries) if isinstance(entries, list) else 0
|
||||
|
||||
worldbooks.append({
|
||||
"name": data.get("name", json_file.stem),
|
||||
"description": data.get("description", ""),
|
||||
"entries_count": len(data.get("entries", [])),
|
||||
"entries_count": entries_count,
|
||||
"createdAt": data.get("createdAt", 0),
|
||||
"updatedAt": data.get("updatedAt", 0)
|
||||
})
|
||||
@@ -165,21 +169,41 @@ class WorldBookService:
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def list_entries(name: str) -> List[Dict[str, Any]]:
|
||||
def list_entries(name: str, page: int = 1, page_size: int = 20) -> Dict[str, Any]:
|
||||
"""
|
||||
获取世界书的所有条目
|
||||
获取世界书的条目列表(支持分页)
|
||||
|
||||
Args:
|
||||
name: 世界书名称
|
||||
page: 页码,从1开始
|
||||
page_size: 每页数量,默认20
|
||||
|
||||
Returns:
|
||||
条目列表
|
||||
包含条目列表和分页信息的字典
|
||||
"""
|
||||
data = WorldBookService._load_worldbook(name)
|
||||
if not data:
|
||||
raise FileNotFoundError(f"Worldbook '{name}' not found")
|
||||
|
||||
return data.get("entries", [])
|
||||
# 内部格式:entries 是列表
|
||||
all_entries = data.get("entries", [])
|
||||
if not isinstance(all_entries, list):
|
||||
all_entries = []
|
||||
|
||||
total = len(all_entries)
|
||||
|
||||
# 计算分页
|
||||
start_idx = (page - 1) * page_size
|
||||
end_idx = start_idx + page_size
|
||||
paginated_entries = all_entries[start_idx:end_idx]
|
||||
|
||||
return {
|
||||
"entries": paginated_entries,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"total_pages": (total + page_size - 1) // page_size # 向上取整
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def get_entry(name: str, uid: str) -> Dict[str, Any]:
|
||||
@@ -197,8 +221,13 @@ class WorldBookService:
|
||||
if not data:
|
||||
raise FileNotFoundError(f"Worldbook '{name}' not found")
|
||||
|
||||
for entry in data.get("entries", []):
|
||||
if entry.get("uid") == uid:
|
||||
# 内部格式:entries 是列表
|
||||
entries = data.get("entries", [])
|
||||
if not isinstance(entries, list):
|
||||
entries = []
|
||||
|
||||
for entry in entries:
|
||||
if entry.get("uid") == uid or str(entry.get("uid")) == uid:
|
||||
return entry
|
||||
|
||||
raise FileNotFoundError(f"Entry '{uid}' not found in worldbook '{name}'")
|
||||
|
||||
Reference in New Issue
Block a user