世界书部分基本完成

This commit is contained in:
2026-04-07 01:56:13 +08:00
parent e8dedb5ec4
commit 4f9cf4b725
10 changed files with 2505 additions and 862 deletions

View File

@@ -1,170 +1,571 @@
from fastapi import APIRouter, HTTPException, status
# 标准库导入
import os
import shutil
import logging
from pathlib import Path
from typing import Dict, List
from backend.core.models.WorldBook import WorldBook
from backend.core.models.WorldItem import WorldInfoEntry
from typing import List, Dict, Any, Optional
# 第三方库导入
from fastapi import APIRouter, HTTPException, UploadFile, File, Form
from fastapi.responses import JSONResponse, FileResponse
# 本地模块导入
from backend.core.models.WorldBook import WorldBook
from backend.core.models.WorldItem import (
WorldInfoEntry,
TriggerConfig,
KeywordTriggerConfig,
RAGTriggerConfig,
ConditionTriggerConfig,
TriggerStrategy
)
from backend.core.config import settings
# 配置日志
logger = logging.getLogger(__name__)
# 创建路由器
router = APIRouter(prefix="/worldbooks", tags=["worldbooks"])
# 确保世界书目录存在 (由 config.py 中的 settings.ensure_directories() 统一处理,此处保留作为双重保险)
os.makedirs(settings.WORLDBOOKS_PATH, exist_ok=True)
# ========== 世界书基础路由 ==========
@router.get("", response_model=Dict[str, List[Dict]])
@router.get("/", response_model=List[Dict[str, Any]])
async def list_worldbooks():
"""获取所有世界书列表"""
worldbook_dir = Path("data/worldbooks")
if not worldbook_dir.exists():
return {"worldbooks": []}
worldbooks = []
for wb_file in worldbook_dir.glob("*.json"):
try:
worldbook = WorldBook.from_sillytavern_json(str(wb_file))
worldbooks.append(worldbook.to_summary_dict())
except Exception as e:
print(f"警告: 跳过文件 {wb_file.name},加载失败: {e}")
continue
return {"worldbooks": worldbooks}
@router.get("/{worldbook_uid}")
async def get_worldbook(worldbook_uid: str):
"""获取指定世界书完整内容"""
worldbook_path = Path("data/worldbooks") / f"{worldbook_uid}.json"
if not worldbook_path.exists():
raise HTTPException(status_code=404, detail="WorldBook not found")
"""
获取所有世界书的列表
Returns:
List[Dict[str, Any]]: 世界书列表
"""
try:
worldbook = WorldBook.from_sillytavern_json(str(worldbook_path))
return worldbook.to_dict()
worldbooks = []
search_dir = settings.WORLDBOOKS_PATH
# 检查目录是否存在
if not os.path.exists(search_dir):
logger.warning(f"目录不存在: {search_dir}")
return []
for filename in os.listdir(search_dir):
if filename.endswith(".json"):
file_path = os.path.join(search_dir, filename)
try:
# 加载世界书基本信息
# 传入文件名(不带扩展名)
world_book = WorldBook.load(Path(file_path).stem)
worldbooks.append(world_book.to_summary_dict())
except Exception as e:
logger.warning(f"加载世界书 {filename} 失败: {str(e)}")
continue
logger.info(f"获取世界书列表: 共 {len(worldbooks)}")
return worldbooks
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to load worldbook: {str(e)}")
logger.error(f"获取世界书列表失败: {str(e)}")
raise HTTPException(status_code=500, detail=f"获取世界书列表失败: {str(e)}")
@router.post("", status_code=status.HTTP_201_CREATED)
async def create_worldbook(worldbook_data: Dict):
"""创建新世界书"""
worldbook_dir = Path("data/worldbooks")
worldbook_dir.mkdir(parents=True, exist_ok=True)
@router.get("/{name}", response_model=Dict[str, Any])
async def get_worldbook(name: str):
"""
获取指定名称的世界书
worldbook = WorldBook.from_dict(worldbook_data)
worldbook_path = worldbook_dir / f"{worldbook.uid}.json"
if worldbook_path.exists():
raise HTTPException(status_code=400, detail="WorldBook already exists")
worldbook.to_sillytavern_json(str(worldbook_path))
return {"message": "WorldBook created successfully", "uid": worldbook.uid}
@router.put("/{worldbook_uid}")
async def update_worldbook(worldbook_uid: str, update_data: Dict):
"""更新世界书基本信息"""
worldbook_path = Path("data/worldbooks") / f"{worldbook_uid}.json"
if not worldbook_path.exists():
raise HTTPException(status_code=404, detail="WorldBook not found")
Args:
name: 世界书名称
Returns:
Dict[str, Any]: 世界书数据
"""
try:
worldbook = WorldBook.from_sillytavern_json(str(worldbook_path))
for key, value in update_data.items():
if hasattr(worldbook, key):
setattr(worldbook, key, value)
worldbook.to_sillytavern_json(str(worldbook_path))
return {"message": "WorldBook updated successfully"}
if not WorldBook.exists(name):
raise HTTPException(status_code=404, detail=f"世界书 '{name}' 不存在")
world_book = WorldBook.load(name)
logger.info(f"获取世界书: {name}")
return world_book.to_dict()
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to update worldbook: {str(e)}")
logger.error(f"获取世界书 {name} 失败: {str(e)}")
raise HTTPException(status_code=500, detail=f"获取世界书失败: {str(e)}")
@router.delete("/{worldbook_uid}")
async def delete_worldbook(worldbook_uid: str):
"""删除世界书"""
worldbook_path = Path("data/worldbooks") / f"{worldbook_uid}.json"
if not worldbook_path.exists():
raise HTTPException(status_code=404, detail="WorldBook not found")
worldbook_path.unlink()
return {"message": "WorldBook deleted successfully"}
@router.post("/", response_model=Dict[str, Any])
async def create_worldbook(
name: str = Form(...),
description: str = Form(""),
file: Optional[UploadFile] = File(None)
):
"""
创建新世界书
Args:
name: 世界书名称
description: 世界书描述
file: 可选的上传文件SillyTavern 格式)
# ========== 世界书条目路由 ==========
@router.get("/{worldbook_uid}/entries")
async def list_worldbook_entries(worldbook_uid: str):
"""获取世界书所有条目列表"""
worldbook_path = Path("data/worldbooks") / f"{worldbook_uid}.json"
if not worldbook_path.exists():
raise HTTPException(status_code=404, detail="WorldBook not found")
Returns:
Dict[str, Any]: 创建的世界书数据
"""
try:
worldbook = WorldBook.from_sillytavern_json(str(worldbook_path))
return {"entries": [entry.dict() for entry in worldbook.entries]}
# 如果上传了文件,从文件导入
if file:
# 保存临时文件
temp_path = os.path.join(settings.WORLDBOOKS_PATH, f"temp_{file.filename}")
with open(temp_path, "wb") as buffer:
shutil.copyfileobj(file.file, buffer)
try:
# 从文件加载世界书
world_book = WorldBook.load(Path(temp_path).stem)
# 更新名称和描述
world_book.name = name
world_book.description = description
# 保存世界书
world_book.save()
logger.info(f"从文件创建世界书: {name}")
finally:
# 删除临时文件
if os.path.exists(temp_path):
os.remove(temp_path)
else:
# 创建空世界书
world_book = WorldBook.create_empty(name, description)
logger.info(f"创建空世界书: {name}")
return world_book.to_dict()
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to load entries: {str(e)}")
logger.error(f"创建世界书 {name} 失败: {str(e)}")
raise HTTPException(status_code=500, detail=f"创建世界书失败: {str(e)}")
@router.get("/{worldbook_uid}/entries/{entry_uid}")
async def get_worldbook_entry(worldbook_uid: str, entry_uid: str):
"""获取指定条目详情"""
worldbook_path = Path("data/worldbooks") / f"{worldbook_uid}.json"
if not worldbook_path.exists():
raise HTTPException(status_code=404, detail="WorldBook not found")
@router.put("/{name}", response_model=Dict[str, Any])
async def update_worldbook(
name: str,
description: Optional[str] = Form(None),
file: Optional[UploadFile] = File(None)
):
"""
更新世界书
Args:
name: 世界书名称
description: 世界书描述(可选)
file: 可选的上传文件SillyTavern 格式)
Returns:
Dict[str, Any]: 更新后的世界书数据
"""
try:
worldbook = WorldBook.from_sillytavern_json(str(worldbook_path))
entry = worldbook.get_entry(entry_uid)
if not entry:
raise HTTPException(status_code=404, detail="Entry not found")
# 检查世界书是否存在
if not WorldBook.exists(name):
raise HTTPException(status_code=404, detail=f"世界书 '{name}' 不存在")
# 加载世界书
world_book = WorldBook.load(name)
# 如果上传了文件,从文件导入并合并
if file:
# 保存临时文件
temp_path = os.path.join(settings.WORLDBOOKS_PATH, f"temp_{file.filename}")
with open(temp_path, "wb") as buffer:
shutil.copyfileobj(file.file, buffer)
try:
# 从文件加载世界书
imported_book = WorldBook.load(Path(temp_path).stem)
# 合并条目
world_book.merge_from_book(imported_book)
logger.info(f"从文件更新世界书: {name}")
finally:
# 删除临时文件
if os.path.exists(temp_path):
os.remove(temp_path)
# 更新描述(如果提供)
if description is not None:
world_book.description = description
# 保存世界书
world_book.save()
logger.info(f"更新世界书: {name}")
return world_book.to_dict()
except HTTPException:
raise
except Exception as e:
logger.error(f"更新世界书 {name} 失败: {str(e)}")
raise HTTPException(status_code=500, detail=f"更新世界书失败: {str(e)}")
@router.delete("/{name}")
async def delete_worldbook(name: str):
"""
删除世界书
Args:
name: 世界书名称
Returns:
Dict[str, Any]: 删除结果
"""
try:
# 检查世界书是否存在
if not WorldBook.exists(name):
raise HTTPException(status_code=404, detail=f"世界书 '{name}' 不存在")
# 获取文件路径
file_path = WorldBook.get_file_path(name)
# 删除文件
os.remove(file_path)
logger.info(f"删除世界书: {name}")
return {"success": True, "message": f"世界书 '{name}' 已删除"}
except HTTPException:
raise
except Exception as e:
logger.error(f"删除世界书 {name} 失败: {str(e)}")
raise HTTPException(status_code=500, detail=f"删除世界书失败: {str(e)}")
@router.get("/{name}/entries", response_model=List[Dict[str, Any]])
async def list_worldbook_entries(name: str):
"""
获取世界书的所有条目(包括已禁用的条目)
Args:
name: 世界书名称
Returns:
List[Dict[str, Any]]: 条目列表
"""
try:
# 检查世界书是否存在
if not WorldBook.exists(name):
raise HTTPException(status_code=404, detail=f"世界书 '{name}' 不存在")
# 加载世界书
world_book = WorldBook.load(name)
# 获取所有条目的核心信息
entries = world_book.get_all_entries()
logger.info(f"获取世界书 {name} 的所有条目: 共 {len(entries)}")
return entries
except HTTPException:
raise
except Exception as e:
logger.error(f"获取世界书 {name} 的条目失败: {str(e)}")
raise HTTPException(status_code=500, detail=f"获取世界书条目失败: {str(e)}")
@router.get("/{name}/entries/{uid}", response_model=Dict[str, Any])
async def get_worldbook_entry(name: str, uid: int):
"""
获取世界书的指定条目
Args:
name: 世界书名称
uid: 条目 UID
Returns:
Dict[str, Any]: 条目数据
"""
try:
# 检查世界书是否存在
if not WorldBook.exists(name):
raise HTTPException(status_code=404, detail=f"世界书 '{name}' 不存在")
# 加载世界书
world_book = WorldBook.load(name)
# 获取条目
entry = world_book.get_entry(uid)
if entry is None:
raise HTTPException(status_code=404, detail=f"条目 UID {uid} 不存在")
logger.info(f"获取世界书 {name} 的条目: UID={uid}")
return entry.dict()
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to load entry: {str(e)}")
logger.error(f"获取世界书 {name} 的条目 {uid} 失败: {str(e)}")
raise HTTPException(status_code=500, detail=f"获取世界书条目失败: {str(e)}")
@router.post("/{worldbook_uid}/entries", status_code=status.HTTP_201_CREATED)
async def add_worldbook_entry(worldbook_uid: str, entry_data: Dict):
"""向世界书添加新条目"""
worldbook_path = Path("data/worldbooks") / f"{worldbook_uid}.json"
if not worldbook_path.exists():
raise HTTPException(status_code=404, detail="WorldBook not found")
@router.post("/{name}/entries", response_model=Dict[str, Any])
async def create_worldbook_entry(name: str, entry_data: Dict[str, Any]):
"""
在世界书中创建新条目
Args:
name: 世界书名称
entry_data: 条目数据
Returns:
Dict[str, Any]: 创建的条目数据
"""
try:
worldbook = WorldBook.from_sillytavern_json(str(worldbook_path))
# 检查世界书是否存在
if not WorldBook.exists(name):
raise HTTPException(status_code=404, detail=f"世界书 '{name}' 不存在")
# 加载世界书
world_book = WorldBook.load(name)
# 处理触发配置数据
trigger_data = entry_data.pop("trigger_config", None)
if trigger_data and "triggers" in trigger_data:
# 创建新的触发配置对象
trigger_config = TriggerConfig()
# 处理每个触发策略
for strategy_str, trigger_info in trigger_data["triggers"].items():
try:
strategy = TriggerStrategy(strategy_str)
enabled = trigger_info[0] if isinstance(trigger_info, list) and len(trigger_info) > 0 else False
config_data = trigger_info[1] if isinstance(trigger_info, list) and len(trigger_info) > 1 else None
# 根据触发策略创建对应的配置对象
if strategy == TriggerStrategy.KEYWORD and config_data:
config = KeywordTriggerConfig(**config_data)
elif strategy == TriggerStrategy.RAG and config_data:
config = RAGTriggerConfig(**config_data)
elif strategy == TriggerStrategy.CONDITION and config_data:
config = ConditionTriggerConfig(**config_data)
else:
config = None
# 设置触发策略
trigger_config.set_trigger(strategy, enabled, config)
except Exception as e:
logger.warning(f"处理触发策略 {strategy_str} 失败: {str(e)}")
continue
# 设置触发配置
entry_data["trigger_config"] = trigger_config
# 创建条目
entry = WorldInfoEntry(**entry_data)
worldbook.add_entry(entry)
worldbook.to_sillytavern_json(str(worldbook_path))
return {"message": "Entry added successfully", "entry_uid": entry.uid}
# 添加条目
world_book.add_entry(entry)
# 保存世界书
world_book.save()
logger.info(f"在世界书 {name} 中创建条目: UID={entry.uid}")
return entry.dict()
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to add entry: {str(e)}")
logger.error(f"在世界书 {name} 中创建条目失败: {str(e)}")
raise HTTPException(status_code=500, detail=f"创建世界书条目失败: {str(e)}")
@router.put("/{worldbook_uid}/entries/{entry_uid}")
async def update_worldbook_entry(worldbook_uid: str, entry_uid: str, update_data: Dict):
"""更新指定条目"""
worldbook_path = Path("data/worldbooks") / f"{worldbook_uid}.json"
if not worldbook_path.exists():
raise HTTPException(status_code=404, detail="WorldBook not found")
@router.put("/{name}/entries/{uid}", response_model=Dict[str, Any])
async def update_worldbook_entry(name: str, uid: int, entry_data: Dict[str, Any]):
"""
更新世界书的指定条目
Args:
name: 世界书名称
uid: 条目 UID
entry_data: 条目数据
Returns:
Dict[str, Any]: 更新后的条目数据
"""
try:
worldbook = WorldBook.from_sillytavern_json(str(worldbook_path))
if not worldbook.update_entry(entry_uid, **update_data):
raise HTTPException(status_code=404, detail="Entry not found")
worldbook.to_sillytavern_json(str(worldbook_path))
return {"message": "Entry updated successfully"}
# 检查世界书是否存在
if not WorldBook.exists(name):
raise HTTPException(status_code=404, detail=f"世界书 '{name}' 不存在")
# 加载世界书
world_book = WorldBook.load(name)
# 检查条目是否存在
if world_book.get_entry(uid) is None:
raise HTTPException(status_code=404, detail=f"条目 UID {uid} 不存在")
# 处理触发配置数据
trigger_data = entry_data.pop("trigger_config", None)
if trigger_data and "triggers" in trigger_data:
# 创建新的触发配置对象
trigger_config = TriggerConfig()
# 处理每个触发策略
for strategy_str, trigger_info in trigger_data["triggers"].items():
try:
strategy = TriggerStrategy(strategy_str)
enabled = trigger_info[0] if isinstance(trigger_info, list) and len(trigger_info) > 0 else False
config_data = trigger_info[1] if isinstance(trigger_info, list) and len(trigger_info) > 1 else None
# 根据触发策略创建对应的配置对象
if strategy == TriggerStrategy.KEYWORD and config_data:
config = KeywordTriggerConfig(**config_data)
elif strategy == TriggerStrategy.RAG and config_data:
config = RAGTriggerConfig(**config_data)
elif strategy == TriggerStrategy.CONDITION and config_data:
config = ConditionTriggerConfig(**config_data)
else:
config = None
# 设置触发策略
trigger_config.set_trigger(strategy, enabled, config)
except Exception as e:
logger.warning(f"处理触发策略 {strategy_str} 失败: {str(e)}")
continue
# 设置触发配置
entry_data["trigger_config"] = trigger_config
# 过滤无效字段,只保留 WorldInfoEntry 中存在的字段
valid_fields = WorldInfoEntry.__fields__.keys()
filtered_data = {k: v for k, v in entry_data.items() if k in valid_fields}
# 更新条目
success = world_book.update_entry(uid, **filtered_data)
if not success:
raise HTTPException(status_code=500, detail="更新条目失败")
# 保存世界书
world_book.save()
# 获取更新后的条目
entry = world_book.get_entry(uid)
logger.info(f"更新世界书 {name} 的条目: UID={uid}")
return entry.dict()
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to update entry: {str(e)}")
logger.error(f"更新世界书 {name} 的条目 {uid} 失败: {str(e)}")
raise HTTPException(status_code=500, detail=f"更新世界书条目失败: {str(e)}")
@router.delete("/{worldbook_uid}/entries/{entry_uid}")
async def delete_worldbook_entry(worldbook_uid: str, entry_uid: str):
"""从世界书删除指定条目"""
worldbook_path = Path("data/worldbooks") / f"{worldbook_uid}.json"
if not worldbook_path.exists():
raise HTTPException(status_code=404, detail="WorldBook not found")
@router.delete("/{name}/entries/{uid}")
async def delete_worldbook_entry(name: str, uid: int):
"""
删除世界书的指定条目
Args:
name: 世界书名称
uid: 条目 UID
Returns:
Dict[str, Any]: 删除结果
"""
try:
worldbook = WorldBook.from_sillytavern_json(str(worldbook_path))
if not worldbook.remove_entry(entry_uid):
raise HTTPException(status_code=404, detail="Entry not found")
worldbook.to_sillytavern_json(str(worldbook_path))
return {"message": "Entry deleted successfully"}
# 检查世界书是否存在
if not WorldBook.exists(name):
raise HTTPException(status_code=404, detail=f"世界书 '{name}' 不存在")
# 加载世界书
world_book = WorldBook.load(name)
# 删除条目
success = world_book.remove_entry(uid)
if not success:
raise HTTPException(status_code=404, detail=f"条目 UID {uid} 不存在")
# 保存世界书
world_book.save()
logger.info(f"删除世界书 {name} 的条目: UID={uid}")
return {"success": True, "message": f"条目 UID {uid} 已删除"}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to delete entry: {str(e)}")
logger.error(f"删除世界书 {name} 的条目 {uid} 失败: {str(e)}")
raise HTTPException(status_code=500, detail=f"删除世界书条目失败: {str(e)}")
@router.post("/{name}/import", response_model=Dict[str, Any])
async def import_worldbook(name: str, file: UploadFile = File(...)):
"""
从文件导入世界书
Args:
name: 世界书名称
file: 上传的文件SillyTavern 格式)
Returns:
Dict[str, Any]: 导入的世界书数据
"""
try:
# 保存临时文件
temp_path = os.path.join(settings.WORLDBOOKS_PATH, f"temp_{file.filename}")
with open(temp_path, "wb") as buffer:
shutil.copyfileobj(file.file, buffer)
try:
# 从文件加载世界书
world_book = WorldBook.load(Path(temp_path).stem)
# 如果世界书已存在,合并条目
if WorldBook.exists(name):
existing_book = WorldBook.load(name)
existing_book.merge_from_book(world_book)
# 保存合并后的世界书
existing_book.save()
world_book = existing_book
logger.info(f"导入并合并世界书: {name}")
else:
# 设置名称并保存
world_book.name = name
world_book.save()
logger.info(f"导入新世界书: {name}")
return world_book.to_dict()
finally:
# 删除临时文件
if os.path.exists(temp_path):
os.remove(temp_path)
except Exception as e:
logger.error(f"导入世界书 {name} 失败: {str(e)}")
raise HTTPException(status_code=500, detail=f"导入世界书失败: {str(e)}")
@router.get("/{name}/export")
async def export_worldbook(name: str):
"""
导出世界书为 SillyTavern 格式
Args:
name: 世界书名称
Returns:
FileResponse: 导出的文件
"""
try:
# 检查世界书是否存在
if not WorldBook.exists(name):
raise HTTPException(status_code=404, detail=f"世界书 '{name}' 不存在")
# 加载世界书
world_book = WorldBook.load(name)
# 创建导出文件路径
export_path = os.path.join(settings.WORLDBOOKS_PATH, f"export_{name}.json")
# 导出为 SillyTavern 格式
world_book.to_sillytavern_json(export_path)
logger.info(f"导出世界书: {name}")
# 返回文件
return FileResponse(
path=export_path,
filename=f"{name}.json",
media_type="application/json"
)
except HTTPException:
raise
except Exception as e:
logger.error(f"导出世界书 {name} 失败: {str(e)}")
raise HTTPException(status_code=500, detail=f"导出世界书失败: {str(e)}")