293 lines
8.3 KiB
Python
293 lines
8.3 KiB
Python
"""
|
||
角色卡 API 路由
|
||
"""
|
||
from fastapi import APIRouter, HTTPException, UploadFile, File
|
||
from fastapi.responses import FileResponse, StreamingResponse
|
||
from typing import List
|
||
from pathlib import Path
|
||
import io
|
||
|
||
try:
|
||
from backend.services.character_service import CharacterService
|
||
except ImportError:
|
||
from services.character_service import CharacterService
|
||
|
||
router = APIRouter(prefix="/characters", tags=["characters"])
|
||
character_service = CharacterService()
|
||
|
||
|
||
@router.get("/", response_model=List[dict])
|
||
async def list_characters():
|
||
"""
|
||
获取所有角色卡列表
|
||
|
||
Returns:
|
||
按最后聊天时间排序的角色卡列表
|
||
"""
|
||
characters = character_service.scan_all_characters()
|
||
return [c.dict() for c in characters]
|
||
|
||
|
||
@router.get("/{name}", response_model=dict)
|
||
async def get_character(name: str):
|
||
"""
|
||
获取指定角色卡
|
||
|
||
Args:
|
||
name: 角色名(URL编码)
|
||
"""
|
||
character = character_service.get_character_by_name(name)
|
||
if not character:
|
||
raise HTTPException(status_code=404, detail=f"角色 '{name}' 不存在")
|
||
|
||
return character.dict()
|
||
|
||
|
||
@router.post("/", response_model=dict)
|
||
async def create_character(character_data: dict):
|
||
"""
|
||
创建新角色卡
|
||
|
||
Request Body:
|
||
{
|
||
"name": "角色名",
|
||
"description": "描述",
|
||
"personality": "性格",
|
||
"scenario": "场景",
|
||
"first_mes": "开场白",
|
||
"categories": ["分类1", "分类2"],
|
||
"tags": ["tag1", "tag2"]
|
||
}
|
||
"""
|
||
try:
|
||
character = character_service.create_character(character_data)
|
||
return {
|
||
"success": True,
|
||
"character": character.dict()
|
||
}
|
||
except Exception as e:
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
|
||
@router.put("/{name}", response_model=dict)
|
||
async def update_character(name: str, updates: dict):
|
||
"""
|
||
更新角色卡
|
||
|
||
Args:
|
||
name: 角色名
|
||
updates: 要更新的字段
|
||
"""
|
||
try:
|
||
character = character_service.update_character(name, updates)
|
||
return {
|
||
"success": True,
|
||
"character": character.dict()
|
||
}
|
||
except FileNotFoundError:
|
||
raise HTTPException(status_code=404, detail=f"角色 '{name}' 不存在")
|
||
except Exception as e:
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
|
||
@router.delete("/{name}")
|
||
async def delete_character(name: str):
|
||
"""
|
||
删除角色卡及其所有聊天记录
|
||
"""
|
||
success = character_service.delete_character(name)
|
||
if not success:
|
||
raise HTTPException(status_code=404, detail=f"角色 '{name}' 不存在")
|
||
|
||
return {"success": True, "message": f"角色 '{name}' 已删除"}
|
||
|
||
|
||
@router.get("/{name}/avatar")
|
||
async def get_avatar(name: str):
|
||
"""
|
||
获取角色头像
|
||
|
||
Returns:
|
||
PNG 图片文件或 404
|
||
"""
|
||
char_folder = character_service.characters_dir / name
|
||
avatar_file = char_folder / "avatar.png"
|
||
|
||
if not avatar_file.exists():
|
||
# 返回默认头像
|
||
default_avatar = Path("data/images/avatars/fallback.png")
|
||
if default_avatar.exists():
|
||
return FileResponse(default_avatar, media_type="image/png")
|
||
raise HTTPException(status_code=404, detail="头像不存在")
|
||
|
||
return FileResponse(avatar_file, media_type="image/png")
|
||
|
||
|
||
@router.post("/{name}/avatar")
|
||
async def upload_avatar(name: str, file: UploadFile = File(...)):
|
||
"""
|
||
上传角色头像
|
||
|
||
Args:
|
||
name: 角色名
|
||
file: PNG 图片文件
|
||
"""
|
||
# 验证文件类型
|
||
if not file.content_type.startswith('image/'):
|
||
raise HTTPException(status_code=400, detail="只支持图片文件")
|
||
|
||
# 检查角色是否存在
|
||
character = character_service.get_character_by_name(name)
|
||
if not character:
|
||
raise HTTPException(status_code=404, detail=f"角色 '{name}' 不存在")
|
||
|
||
# 保存图片
|
||
image_data = await file.read()
|
||
avatar_path = character_service.save_avatar(name, image_data)
|
||
|
||
return {
|
||
"success": True,
|
||
"avatar_path": avatar_path
|
||
}
|
||
|
||
|
||
@router.get("/{name}/chats")
|
||
async def list_chats(name: str):
|
||
"""
|
||
获取角色的所有聊天列表
|
||
|
||
Returns:
|
||
聊天文件列表(包含最后一条消息预览)
|
||
"""
|
||
char_folder = character_service.characters_dir / name
|
||
chats_dir = char_folder / "chats"
|
||
|
||
if not chats_dir.exists():
|
||
return {"chats": []}
|
||
|
||
import json
|
||
from datetime import datetime
|
||
|
||
chats = []
|
||
for chat_file in chats_dir.glob("*.jsonl"):
|
||
try:
|
||
with open(chat_file, 'r', encoding='utf-8') as f:
|
||
lines = f.readlines()
|
||
|
||
if not lines:
|
||
continue
|
||
|
||
# 第一行是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
|
||
|
||
chats.append({
|
||
"chat_name": chat_file.stem,
|
||
"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:
|
||
# 如果解析失败,使用基本信息
|
||
chats.append({
|
||
"chat_name": chat_file.stem,
|
||
"last_modified": datetime.fromtimestamp(chat_file.stat().st_mtime).isoformat(),
|
||
"message_count": 0,
|
||
"last_message": ""
|
||
})
|
||
|
||
# 按修改时间排序
|
||
chats.sort(key=lambda c: c.get('last_modified', ''), reverse=True)
|
||
|
||
return {"chats": chats}
|
||
|
||
|
||
@router.post("/import")
|
||
async def import_character(file: UploadFile = File(...)):
|
||
"""
|
||
导入角色卡(支持 PNG 或 JSON)
|
||
|
||
- PNG: 自动提取嵌入数据,创建文件夹
|
||
- JSON: 创建文件夹并保存
|
||
"""
|
||
content = await file.read()
|
||
filename = file.filename
|
||
|
||
if filename.endswith('.png'):
|
||
# 导入 PNG
|
||
try:
|
||
character = character_service.import_from_png(content, filename)
|
||
|
||
return {
|
||
"success": True,
|
||
"character": character.dict(),
|
||
"format": "png_embedded"
|
||
}
|
||
|
||
except ValueError as e:
|
||
raise HTTPException(status_code=400, detail=str(e))
|
||
except Exception as e:
|
||
raise HTTPException(status_code=500, detail=f"导入失败: {str(e)}")
|
||
|
||
elif filename.endswith('.json'):
|
||
# 导入 JSON
|
||
try:
|
||
import json
|
||
data = json.loads(content.decode('utf-8'))
|
||
|
||
character = character_service.create_character(data)
|
||
|
||
return {
|
||
"success": True,
|
||
"character": character.dict(),
|
||
"format": "json"
|
||
}
|
||
|
||
except Exception as e:
|
||
raise HTTPException(status_code=500, detail=f"导入失败: {str(e)}")
|
||
|
||
else:
|
||
raise HTTPException(status_code=400, detail="不支持的文件格式")
|
||
|
||
|
||
@router.post("/{name}/export/png")
|
||
async def export_character_as_png(name: str):
|
||
"""
|
||
导出角色为 SillyTavern PNG 格式
|
||
|
||
Returns:
|
||
PNG 文件下载
|
||
"""
|
||
try:
|
||
png_data = character_service.export_as_png(name)
|
||
|
||
return StreamingResponse(
|
||
io.BytesIO(png_data),
|
||
media_type="image/png",
|
||
headers={
|
||
"Content-Disposition": f"attachment; filename={name}.png"
|
||
}
|
||
)
|
||
except FileNotFoundError:
|
||
raise HTTPException(status_code=404, detail=f"角色 '{name}' 不存在")
|
||
except Exception as e:
|
||
raise HTTPException(status_code=500, detail=f"导出失败: {str(e)}")
|