世界书部分基本完成

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,7 +1,7 @@
from fastapi import APIRouter, HTTPException, status
from backend.core.models.chat_history import ChatHistory, Message
router = APIRouter(prefix="/chats", tags=["chats"])
router = APIRouter(prefix="/chat", tags=["chat"])
# ========== 聊天历史基础路由 ==========

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)}")

View File

@@ -3,46 +3,77 @@ from pathlib import Path
from dotenv import load_dotenv
# 1. 动态计算项目根目录
# 假设 config.py 位于 backend/ 目录下
# 假设 config.py 位于 backend/core/ 目录下
# __file__ 指向本文件的绝对路径
# .parent 指向 backend/ 目录
# .parent.parent 指向项目根目录 (即包含 backend/ 和 frontend/ 的目录)
# .parent 指向 backend/core/ 目录
# .parent.parent 指向 backend/ 目录
# .parent.parent.parent 指向项目根目录 (即包含 backend/ 和 frontend/ 的目录)
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
# 2. 加载 .env 文件
# 假设 .env 文件位于项目根目录下
load_dotenv(PROJECT_ROOT / ".env")
class Settings:
# --- 主模型配置 ---
MAIN_LLM_API_KEY = os.getenv("MAIN_LLM_API_KEY")
MAIN_LLM_MODEL = os.getenv("MAIN_LLM_MODEL", "gpt-3.5-turbo")
MAIN_LLM_BASE_URL = os.getenv("MAIN_LLM_BASE_URL", "https://api.openai.com/v1")
MAIN_LLM_MAX_TOKENS = int(os.getenv("MAIN_LLM_MAX_TOKENS", "4096"))
MAIN_LLM_STREAM = os.getenv("MAIN_LLM_STREAM", "true").lower() == "true"
# --- 主模型配置 ---
MAIN_LLM_API_KEY = os.getenv("MAIN_LLM_API_KEY")
MAIN_LLM_MODEL = os.getenv("MAIN_LLM_MODEL", "gpt-3.5-turbo")
MAIN_LLM_BASE_URL = os.getenv("MAIN_LLM_BASE_URL", "https://api.openai.com/v1")
MAIN_LLM_MAX_TOKENS = int(os.getenv("MAIN_LLM_MAX_TOKENS", "4096"))
MAIN_LLM_STREAM = os.getenv("MAIN_LLM_STREAM", "true").lower() == "true"
# --- 路径配置 (核心修改) ---
# --- 路径配置 (核心修改) ---
# 强制使用计算出的项目根目录,不再依赖 .env 中的 BASE_PATH
BASE_PATH = PROJECT_ROOT
# 强制使用计算出的项目根目录,不再依赖 .env 中的 BASE_PATH
BASE_PATH = PROJECT_ROOT
# 数据目录:固定为根目录下的 data 文件夹
# 即使 .env 里写了 DATA_PATH=/data这里也会强制指向项目根目录下的 data
DATA_PATH = BASE_PATH / "data"
# 数据目录:固定为根目录下的 data 文件夹
DATA_PATH = BASE_PATH / "data"
# --- 核心数据文件路径 ---
STATE_FILE = DATA_PATH / "state.json"
SCHEMA_FILE = DATA_PATH / "schema.json"
PRESETS_FILE = DATA_PATH / "presets.json"
REGEX_FILE = DATA_PATH / "regex_rules.json"
VECTORSTORE_PATH = DATA_PATH / "vectorstore"
# --- 业务数据目录 ---
# 世界书目录
WORLDBOOKS_PATH = DATA_PATH / "worldbooks"
# 预设目录
PRESET_PATH = DATA_PATH / "preset"
# 聊天记录目录
CHAT_PATH = DATA_PATH / "chat"
# 临时文件目录
TEMP_PATH = DATA_PATH / "temp"
def ensure_directories(self):
"""确保所有配置的目录存在,如果不存在则创建"""
directories = [
self.DATA_PATH,
self.WORLDBOOKS_PATH,
self.PRESET_PATH,
self.CHAT_PATH,
self.TEMP_PATH,
]
for directory in directories:
directory.mkdir(parents=True, exist_ok=True)
# 其他文件路径:基于 DATA_PATH 拼接
STATE_FILE = DATA_PATH / "state.json"
SCHEMA_FILE = DATA_PATH / "schema.json"
PRESETS_FILE = DATA_PATH / "presets.json"
REGEX_FILE = DATA_PATH / "regex_rules.json"
VECTORSTORE_PATH = DATA_PATH / "vectorstore"
settings = Settings()
# 初始化时自动创建必要的目录
settings.ensure_directories()
if __name__ == '__main__':
settings = Settings()
print(f"项目根目录: {settings.BASE_PATH}")
print(f"数据目录: {settings.DATA_PATH}")
print(f"聊天目录: {settings.DATA_PATH / 'chat'}")
settings = Settings()
print(f"项目根目录: {settings.BASE_PATH}")
print(f"数据目录: {settings.DATA_PATH}")
print(f"世界书目录: {settings.WORLDBOOKS_PATH}")
print(f"预设目录: {settings.PRESETS_PATH}")
print(f"聊天目录: {settings.CHAT_PATH}")

View File

@@ -1,8 +1,14 @@
import json
import os
import logging
from typing import Dict, List, Optional, Any
from pathlib import Path
from pydantic import BaseModel, Field, validator
from .WorldItem import WorldInfoEntry, TriggerStrategy, PositionMode
from .WorldItem import WorldInfoEntry, TriggerStrategy
from backend.core.config import settings
# 配置日志
logger = logging.getLogger(__name__)
class WorldBook(BaseModel):
@@ -11,40 +17,99 @@ class WorldBook(BaseModel):
管理多个世界书条目,支持导入导出 SillyTavern 格式
"""
# 世界书基本信息
uid: str = Field(..., description="世界书唯一标识符")
name: str = Field(..., description="世界书名称")
description: str = Field("", description="世界书描述")
# 条目集合
entries: List[WorldInfoEntry] = Field(
default_factory=list,
description="世界书条目列表"
entries: Dict[str, WorldInfoEntry] = Field(
default_factory=dict,
description="世界书条目字典对象 (Key-Value Map)"
)
# 全局配置
enabled: bool = Field(True, description="是否启用")
scan_depth: int = Field(4, description="默认扫描深度")
rag_threshold: float = Field(0.75, description="默认RAG相似度阈值")
@validator('entries')
def validate_entries_unique_uid(cls, v):
uids = [entry.uid for entry in v]
"""验证条目 UID 的唯一性"""
uids = [entry.uid for entry in v.values()]
if len(uids) != len(set(uids)):
logger.error("验证失败: 条目 UID 必须唯一")
raise ValueError("条目 UID 必须唯一")
return v
@classmethod
def get_file_path(cls, name: str) -> str:
"""
根据世界书名称获取文件路径
Args:
name: 世界书名称
Returns:
str: 完整的文件路径
"""
# 使用配置中的 WORLDBOOKS_PATH
return str(settings.WORLDBOOKS_PATH / f"{name}.json")
@classmethod
def exists(cls, name: str) -> bool:
"""
检查指定名称的世界书文件是否存在
Args:
name: 世界书名称
Returns:
bool: 文件是否存在
"""
file_path = cls.get_file_path(name)
return os.path.exists(file_path)
@classmethod
def create_empty(cls, name: str) -> 'WorldBook':
"""
创建并保存一个空白的世界书
Args:
name: 世界书名称
Returns:
WorldBook: 创建的世界书对象
Raises:
ValueError: 世界书已存在
IOError: 文件写入失败
"""
# 检查世界书是否已存在
if cls.exists(name):
raise ValueError(f"世界书 '{name}' 已存在")
# 创建空白世界书对象
world_book = cls(
name=name,
)
# 保存世界书
world_book.save()
logger.info(f"创建空白世界书: {name}")
return world_book
def add_entry(self, entry: WorldInfoEntry) -> None:
"""
添加世界书条目
Args:
entry: 世界书条目对象
"""
if any(e.uid == entry.uid for e in self.entries):
raise ValueError(f"条目 UID {entry.uid} 已存在")
self.entries.append(entry)
def remove_entry(self, uid: str) -> bool:
Raises:
ValueError: 条目 UID 已存在
"""
entry_key = str(entry.uid)
if entry_key in self.entries:
error_msg = f"添加条目失败: 条目 UID {entry.uid} 已存在于世界书 {self.name}"
logger.error(error_msg)
raise ValueError(error_msg)
self.entries[entry_key] = entry
logger.debug(f"已添加条目: UID={entry.uid}, 世界书={self.name}")
def remove_entry(self, uid: int) -> bool:
"""
移除世界书条目
@@ -54,11 +119,15 @@ class WorldBook(BaseModel):
Returns:
bool: 是否成功移除
"""
original_length = len(self.entries)
self.entries = [e for e in self.entries if e.uid != uid]
return len(self.entries) < original_length
entry_key = str(uid)
if entry_key in self.entries:
del self.entries[entry_key]
logger.info(f"已从世界书 {self.name} 移除条目: UID={uid}")
return True
logger.warning(f"尝试移除不存在的条目: 世界书 {self.name} 中未找到 UID={uid}")
return False
def get_entry(self, uid: str) -> Optional[WorldInfoEntry]:
def get_entry(self, uid: int) -> Optional[WorldInfoEntry]:
"""
获取指定 UID 的世界书条目
@@ -68,12 +137,15 @@ class WorldBook(BaseModel):
Returns:
Optional[WorldInfoEntry]: 找到的条目,未找到返回 None
"""
for entry in self.entries:
if entry.uid == uid:
return entry
return None
entry_key = str(uid)
entry = self.entries.get(entry_key)
if entry:
logger.debug(f"从世界书 {self.name} 获取条目: UID={uid}")
else:
logger.debug(f"在世界书 {self.name} 中未找到条目: UID={uid}")
return entry
def update_entry(self, uid: str, **kwargs) -> bool:
def update_entry(self, uid: int, **kwargs) -> bool:
"""
更新世界书条目
@@ -86,30 +158,32 @@ class WorldBook(BaseModel):
"""
entry = self.get_entry(uid)
if entry is None:
logger.warning(f"更新条目失败: 在世界书 {self.name} 中未找到 UID={uid}")
return False
for key, value in kwargs.items():
if hasattr(entry, key):
setattr(entry, key, value)
logger.info(f"已更新世界书 {self.name} 中的条目: UID={uid}, 更新字段={list(kwargs.keys())}")
return True
def filter_by_position(self, position_mode: PositionMode, position_value: int) -> List[WorldInfoEntry]:
def filter_by_position(self, position: int) -> List[WorldInfoEntry]:
"""
根据位置筛选条目
Args:
position_mode: 位置模式
position_value: 位置值
position: 位置
Returns:
List[WorldInfoEntry]: 筛选后的条目列表
"""
return [
entry for entry in self.entries
if entry.position_mode == position_mode and
(entry.position_anchor == position_value or
(position_mode == PositionMode.ABSOLUTE_DEPTH and entry.position_dx == position_value))
filtered_entries = [
entry for entry in self.entries.values()
if entry.position == position
]
logger.debug(
f"在世界书 {self.name} 中按位置筛选: 值={position}, 结果数量={len(filtered_entries)}")
return filtered_entries
def get_summary(self) -> Dict[str, Any]:
"""
@@ -118,19 +192,17 @@ class WorldBook(BaseModel):
Returns:
Dict[str, Any]: 概要信息字典
"""
return {
"uid": self.uid,
summary = {
"name": self.name,
"description": self.description,
"entry_count": len(self.entries),
"enabled": self.enabled,
"scan_depth": self.scan_depth,
"rag_threshold": self.rag_threshold,
"trigger_strategies": {
strategy.value: sum(1 for e in self.entries if e.trigger_strategy == strategy)
strategy.value: sum(1 for e in self.entries.values()
if strategy in e.trigger_config.get_enabled_triggers())
for strategy in TriggerStrategy
}
}
logger.debug(f"获取世界书 {self.name} 的概要信息")
return summary
def to_summary_dict(self) -> Dict[str, Any]:
"""
@@ -139,209 +211,122 @@ class WorldBook(BaseModel):
Returns:
Dict[str, Any]: 包含基本信息的字典
"""
return {
"uid": self.uid,
summary = {
"name": self.name,
"description": self.description,
"entry_count": len(self.entries),
"enabled": self.enabled
}
@classmethod
def from_dict(cls, data: Dict) -> 'WorldBook':
"""
从字典创建 WorldBook 对象,支持多种数据格式
Args:
data: 包含世界书数据的字典
Returns:
WorldBook: 世界书对象
Raises:
ValueError: 数据格式无效
"""
# 处理包含 originalData 的格式
if 'originalData' in data:
original_data = data['originalData']
return cls(
uid=data.get('uid') or original_data.get('name', 'unknown'),
name=original_data.get('name', 'Unknown'),
description=original_data.get('description', ''),
enabled=data.get('enabled', True),
entries=[WorldInfoEntry(**entry) for entry in data.get('entries', {}).values()]
)
# 处理标准格式
return cls(**data)
logger.debug(f"生成世界书 {self.name} 的摘要信息")
return summary
def to_dict(self) -> Dict:
"""
将 WorldBook 转换为字典,兼容多种格式
将 WorldBook 转换为字典
Returns:
Dict: 世界书数据字典
"""
return {
'uid': self.uid,
result = {
'name': self.name,
'description': self.description,
'enabled': self.enabled,
'entries': {entry.uid: entry.dict() for entry in self.entries}
'entries': {uid: entry.dict() for uid, entry in self.entries.items()}
}
logger.debug(f"将世界书 {self.name} 转换为字典")
return result
@classmethod
def from_sillytavern_json(cls, file_path: str) -> 'WorldBook':
@classmethod
def load(cls, name: str) -> 'WorldBook':
"""
SillyTavern 格式的 JSON 文件加载世界书
从文件加载世界书(只有 entries 字段的格式)
Args:
file_path: JSON 文件路径
name: 世界书名称
Returns:
WorldBook: 世界书对象
Raises:
FileNotFoundError: 文件不存在
ValueError: 格式不符合 SillyTavern 标准
ValueError: 格式不符合标准
json.JSONDecodeError: JSON 解析错误
"""
file_path = cls.get_file_path(name)
if not os.path.exists(file_path):
raise FileNotFoundError(f"世界书文件未找到: {file_path}")
error_msg = f"世界书文件未找到: {file_path}"
logger.error(error_msg)
raise FileNotFoundError(error_msg)
with open(file_path, 'r', encoding='utf-8') as f:
raw_data = json.load(f)
try:
with open(file_path, 'r', encoding='utf-8') as f:
raw_data = json.load(f)
# 创建世界书对象
world_name = raw_data.get("name", "")
if not world_name:
# 如果 name 字段不存在或为空,从文件名中提取
file_name = os.path.splitext(os.path.basename(file_path))[0]
world_name = file_name
# 世界书名称始终使用文件名(不包括后缀名)
world_name = name
# 检查是否有 originalData 字段
if "originalData" in raw_data:
# 使用 originalData 格式
original_data = raw_data["originalData"]
world_book = cls(
uid=f"st_{os.path.basename(file_path)}_{int(os.path.getmtime(file_path))}",
name=world_name,
description=f"从 SillyTavern 导入: {file_path}"
)
# 转换 originalData 中的条目
for entry_data in original_data.get("entries", []):
try:
world_entry = WorldInfoEntry(
uid=str(entry_data.get("id", "")),
key=entry_data.get("keys", []),
keysecondary=entry_data.get("secondary_keys", []),
comment=entry_data.get("comment", ""),
content=entry_data.get("content", ""),
trigger_strategy=TriggerStrategy.KEYWORD,
position_mode=PositionMode.ANCHOR if entry_data.get(
"position") == "after_char" else PositionMode.ABSOLUTE_DEPTH,
position_anchor=0,
position_dx=None,
constant=entry_data.get("constant", False),
selective=entry_data.get("selective", True),
order=entry_data.get("insertion_order", 100),
probability=entry_data.get("extensions", {}).get("probability", 100),
useProbability=entry_data.get("extensions", {}).get("useProbability", False),
group=entry_data.get("extensions", {}).get("group", None),
match_whole_words=entry_data.get("use_regex", False),
use_regex=entry_data.get("use_regex", False),
vectorized=False
)
world_book.add_entry(world_entry)
except Exception as e:
print(f"警告: 跳过条目,解析失败: {e}")
else:
# 使用标准 SillyTavern 格式
# 直接使用 entries 字段
entries_dict = raw_data.get("entries", {})
if not isinstance(entries_dict, dict):
raise ValueError("无效的世界书格式:'entries' 字段必须是一个字典。")
if not any(key.isdigit() for key in entries_dict.keys()):
raise ValueError("无效的世界书格式:'entries' 中未找到条目。请确认是 SillyTavern 导出的格式。")
error_msg = "无效的世界书格式:'entries' 字段必须是一个字典。"
logger.error(error_msg)
raise ValueError(error_msg)
# 创建世界书对象
world_book = cls(
uid=f"st_{os.path.basename(file_path)}_{int(os.path.getmtime(file_path))}",
name=world_name,
description=f"从 SillyTavern 导入: {file_path}"
)
# 转换标准格式的条目
for uid, entry_data in entries_dict.items():
try:
position = entry_data.get("position", 0)
position_mode = PositionMode.ANCHOR if position < 6 else PositionMode.ABSOLUTE_DEPTH
world_entry = WorldInfoEntry(
uid=uid,
key=entry_data.get("key", []),
keysecondary=entry_data.get("keysecondary", []),
comment=entry_data.get("comment", ""),
content=entry_data.get("content", ""),
trigger_strategy=TriggerStrategy.KEYWORD,
position_mode=position_mode,
position_anchor=position if position < 6 else 0,
position_dx=position if position >= 6 else None,
constant=entry_data.get("constant", False),
selective=entry_data.get("selective", True),
order=entry_data.get("order", 100),
probability=entry_data.get("probability", 100),
useProbability=entry_data.get("useProbability", False),
group=entry_data.get("group", None),
match_whole_words=entry_data.get("matchWholeWords", False),
use_regex=entry_data.get("useRegex", False),
vectorized=False
)
world_entry = WorldInfoEntry.from_sillytavern_data(entry_data)
world_book.add_entry(world_entry)
except Exception as e:
print(f"警告: 跳过条目 {uid},解析失败: {e}")
logger.warning(f"跳过条目 {uid},解析失败: {e}")
return world_book
logger.info(
f"从文件加载世界书: 文件={file_path}, 名称={world_name}, 条目数={len(world_book.entries)}")
def to_sillytavern_json(self, file_path: str) -> None:
return world_book
except json.JSONDecodeError as e:
error_msg = f"JSON 解析错误: {str(e)}"
logger.error(error_msg)
raise ValueError(error_msg)
except Exception as e:
error_msg = f"从文件加载世界书失败: {str(e)}"
logger.error(error_msg)
raise ValueError(error_msg)
def save(self) -> None:
"""
导出为 SillyTavern 格式的 JSON 文件
保存世界书到文件(只有 entries 字段的格式)
如果文件不存在,会创建新文件;如果文件存在,会更新现有文件
Args:
file_path: 要保存的文件路径
Raises:
IOError: 文件写入失败
"""
entries_dict = {}
file_path = self.get_file_path(self.name)
for entry in self.entries:
# 映射 WorldInfoEntry 到 SillyTavern 格式
position = entry.position_anchor if entry.position_mode == PositionMode.ANCHOR else entry.position_dx
# 确保目录存在
os.makedirs(Path(file_path).parent, exist_ok=True)
entries_dict[entry.uid] = {
"uid": entry.uid,
"key": entry.key,
"keysecondary": entry.keysecondary,
"comment": entry.comment,
"content": entry.content,
"constant": entry.constant,
"selective": entry.selective,
"position": position,
"order": entry.order,
"probability": entry.probability,
"useProbability": entry.useProbability,
"group": entry.group,
"matchWholeWords": entry.match_whole_words,
"useRegex": entry.use_regex,
"preventRecursion": True,
"excludeRecursion": True
try:
# 转换为标准格式
entries_dict = {}
for uid, entry in self.entries.items():
entries_dict[uid] = entry.to_sillytavern_dict()
output_data = {
"entries": entries_dict
}
output_data = {
"entries": entries_dict,
"name": self.name
}
with open(file_path, 'w', encoding='utf-8') as f:
json.dump(output_data, f, ensure_ascii=False, indent=2)
with open(file_path, 'w', encoding='utf-8') as f:
json.dump(output_data, f, ensure_ascii=False, indent=2)
logger.info(
f"世界书已保存: 文件={file_path}, 名称={self.name}, 条目数={len(self.entries)}")
except Exception as e:
error_msg = f"保存世界书失败: {str(e)}"
logger.error(error_msg)
raise IOError(error_msg)
def list_triggers_and_content(self) -> List[Dict[str, Any]]:
"""
@@ -351,24 +336,108 @@ class WorldBook(BaseModel):
List[Dict[str, Any]]: 包含 trigger (key) 和 content 的列表
"""
result = []
for entry in self.entries:
for uid, entry in self.entries.items():
# 获取启用的触发策略
enabled_triggers = entry.trigger_config.get_enabled_triggers()
# 检查是否启用了关键词触发
keyword_enabled, keyword_config = entry.trigger_config.get_trigger(TriggerStrategy.KEYWORD)
triggers = keyword_config.key if keyword_enabled and keyword_config else []
# 检查是否启用了永久触发
constant_enabled, _ = entry.trigger_config.get_trigger(TriggerStrategy.CONSTANT)
result.append({
"uid": entry.uid,
"comment": entry.comment,
"triggers": entry.key,
"triggers": triggers,
"content": entry.content,
"position": entry.position_anchor if entry.position_mode == PositionMode.ANCHOR else entry.position_dx,
"constant": entry.constant,
"trigger_strategy": entry.trigger_strategy.value
"position": entry.position,
"constant": constant_enabled,
"trigger_strategies": [strategy.value for strategy in enabled_triggers]
})
logger.debug(f"列出世界书 {self.name} 的触发词和内容: 条目数={len(result)}")
return result
def get_all_entries(self) -> List[Dict[str, Any]]:
"""
获取所有条目的核心信息(包括已禁用的条目)
Returns:
List[Dict[str, Any]]: 包含核心信息的条目列表
"""
result = []
for uid, entry in self.entries.items():
# 获取启用的触发策略
enabled_triggers = entry.trigger_config.get_enabled_triggers()
# 检查是否启用了永久触发
constant_enabled, _ = entry.trigger_config.get_trigger(TriggerStrategy.CONSTANT)
result.append({
"uid": entry.uid,
"comment": entry.comment,
"content": entry.content,
"constant": constant_enabled,
"position": entry.position,
"order": entry.order,
"role": entry.role,
"disable": entry.disable,
"trigger_strategies": [strategy.value for strategy in enabled_triggers],
"trigger_params": entry.get_trigger_params()
})
logger.debug(f"获取世界书 {self.name} 的所有条目: 条目数={len(result)}")
return result
def merge_from_book(self, other_book: 'WorldBook') -> None:
"""
从另一个世界书合并条目
Args:
other_book: 要合并的世界书对象
"""
for uid, entry in other_book.entries.items():
if uid in self.entries:
# 更新现有条目
for key, value in entry.dict().items():
if key != 'uid': # 不更新 UID
setattr(self.entries[uid], key, value)
else:
# 添加新条目
self.add_entry(entry)
logger.info(f"合并世界书: 从 {other_book.name} 合并到 {self.name}")
def to_sillytavern_json(self, file_path: str) -> None:
"""
导出为 SillyTavern 格式的 JSON 文件
Args:
file_path: 导出文件路径
"""
# 转换为 SillyTavern 格式
entries_dict = {}
for uid, entry in self.entries.items():
entries_dict[uid] = entry.to_sillytavern_dict()
output_data = {
"entries": entries_dict,
"name": self.name
}
with open(file_path, 'w', encoding='utf-8') as f:
json.dump(output_data, f, ensure_ascii=False, indent=2)
logger.info(f"导出世界书为 SillyTavern 格式: 文件={file_path}")
# --- 使用示例 ---
if __name__ == "__main__":
try:
# 从 SillyTavern 格式导入
world_book = WorldBook.from_sillytavern_json('entries.json')
# 创建空白世界书
world_book = WorldBook.create_empty("test_worldbook", "测试世界书")
# 加载世界书
world_book = WorldBook.load("test_worldbook")
# 打印概要
summary = world_book.get_summary()
@@ -383,9 +452,9 @@ if __name__ == "__main__":
content_preview = item['content'][:50].replace('\n', ' ') + "..."
print(f"[{item['position']}] TRIGGERS: {triggers} -> CONTENT: {content_preview}")
# 导出为 SillyTavern 格式
world_book.to_sillytavern_json('exported_world.json')
print("\n✅ 世界书已导出为 exported_world.json")
# 保存世界书
world_book.save()
print(f"\n✅ 世界书已保存")
except Exception as e:
print(f"❌ 错误: {e}")

View File

@@ -1,70 +1,389 @@
import logging
from enum import Enum
from typing import List, Optional
from typing import List, Optional, Dict, Any, Union
from pydantic import BaseModel, Field, validator
from pydantic import Extra
# 配置日志
logger = logging.getLogger(__name__)
class TriggerStrategy(str, Enum):
"""
触发策略枚举
"""
ALL = "all" # 全部触发
KEYWORD = "keyword" # 传统关键词匹配
CONSTANT = "constant" # 永久触发
KEYWORD = "keyword" # 关键词匹配触发
RAG = "rag" # 向量检索触发
CONDITION = "condition" # 逻辑条件触发
class PositionMode(str, Enum):
class KeywordTriggerConfig(BaseModel):
"""
位置模式枚举
关键词触发配置
"""
ANCHOR = "anchor" # 锚点模式 (0-5)
ABSOLUTE_DEPTH = "dx" # 绝对深度模式 (Dx)
key: List[str] = Field(default_factory=list, description="主关键词数组")
keysecondary: List[str] = Field(default_factory=list, description="次要关键词数组")
selective: bool = Field(True, description="是否开启选择性匹配")
selectiveLogic: int = Field(0, description="逻辑模式 (0=OR, 1=AND)")
matchWholeWords: bool = Field(False, description="是否全词匹配")
caseSensitive: bool = Field(False, description="是否区分大小写")
class RAGTriggerConfig(BaseModel):
"""
RAG触发配置
"""
threshold: float = Field(0.75, description="RAG 相似度阈值")
top_k: int = Field(5, description="返回的匹配条目数")
query_template: Optional[str] = Field(None, description="检索用的查询模板")
class ConditionTriggerConfig(BaseModel):
"""
条件触发配置
"""
variable_a: str = Field(..., description="变量a")
operator: str = Field(..., description="运算符 (>, <, =, >=, <=, !=)")
variable_b: str = Field(..., description="变量b")
class TriggerConfig(BaseModel):
"""
触发配置
使用字典结构,键为触发策略,值为[是否启用, 对应配置]的列表
"""
triggers: Dict[TriggerStrategy, List[
Union[bool, Optional[Union[KeywordTriggerConfig, RAGTriggerConfig, ConditionTriggerConfig]]]]] = Field(
default_factory=lambda: {
TriggerStrategy.CONSTANT: [True, None],
TriggerStrategy.KEYWORD: [False, None],
TriggerStrategy.RAG: [False, None],
TriggerStrategy.CONDITION: [False, None]
},
description="触发配置字典,键为触发策略,值为[是否启用, 对应配置]"
)
class Config:
extra = Extra.forbid # 禁止额外字段
def set_trigger(self, strategy: TriggerStrategy, enabled: bool,
config: Optional[Union[KeywordTriggerConfig, RAGTriggerConfig, ConditionTriggerConfig]] = None
):
"""
设置触发策略
Args:
strategy: 触发策略
enabled: 是否启用
config: 对应的配置对象
"""
self.triggers[strategy] = [enabled, config]
def get_trigger(self, strategy: TriggerStrategy) -> List[
Union[bool, Optional[Union[KeywordTriggerConfig, RAGTriggerConfig, ConditionTriggerConfig]]]]:
"""
获取触发策略
Args:
strategy: 触发策略
Returns:
List: [是否启用, 对应配置]
"""
return self.triggers.get(strategy, [False, None])
def get_enabled_triggers(self) -> List[TriggerStrategy]:
"""
获取所有启用的触发策略
Returns:
List[TriggerStrategy]: 启用的触发策略列表
"""
return [strategy for strategy, (enabled, _) in self.triggers.items() if enabled]
class WorldInfoEntry(BaseModel):
"""
世界书条目模型 v2.0
支持 RAG、条件触发及 Dx 绝对位置
兼容 SillyTavern 格式
世界书条目模型
完整兼容所有可能的属性字段
"""
# --- 核心身份 ---
uid: str
key: List[str] = []
keysecondary: List[str] = []
comment: str = ""
content: str
# A. 基础定义 (Base Fields)
uid: int = Field(..., description="唯一标识符")
key: List[str] = Field(default_factory=list, description="条目名")
content: str = Field(..., description="注入到 Prompt 的实际文本内容")
comment: str = Field("", description="备注")
# --- 策略配置 ---
trigger_strategy: TriggerStrategy = TriggerStrategy.KEYWORD
condition_expr: Optional[str] = None # 条件表达式
rag_threshold: float = 0.75 # RAG 相似度阈值
# B. 注入与排序 (Injection & Order)
position: int = Field(0, description="插入位置")
order: int = Field(100, description="注入顺序权重")
depth: int = Field(4, description="扫描深度")
scanDepth: Optional[int] = Field(None, description="显式扫描深度")
addMemo: bool = Field(False, description="是否添加备忘录标记")
# --- 位置与扫描 ---
position_mode: PositionMode = PositionMode.ANCHOR
position_anchor: int = 0 # 锚点值 (0-5)
position_dx: Optional[int] = None # 绝对深度值
scan_depth: int = 4 # 向前扫描 N 条消息
# C. 触发配置 (Trigger Configuration)
trigger_config: TriggerConfig = Field(
default_factory=TriggerConfig,
description="触发配置"
)
# --- 其他配置 ---
constant: bool = False
selective: bool = True
order: int = 100
probability: int = 100
useProbability: bool = False
# D. 高级匹配 (Advanced Matching)
role: int = Field(0, description="角色匹配 (0=Both, 1=User, 2=Assistant)")
useGroupScoring: bool = Field(False, description="是否使用分组评分")
# 高级过滤
group: Optional[str] = None
match_whole_words: bool = False
use_regex: bool = False
vectorized: bool = False # 是否已生成向量 embedding
# E. 分组设置 (Grouping)
group: Optional[str] = Field(None, description="分组名称")
groupOverride: bool = Field(False, description="是否覆盖分组限制")
groupWeight: int = Field(100, description="分组权重")
# SillyTavern 特定字段
enabled: bool = True
position: str = "after_char" # SillyTavern 位置字符串
insertion_order: int = 100 # SillyTavern 插入顺序
# F. 递归与防抖 (Recursion)
excludeRecursion: bool = Field(True, description="排除递归")
preventRecursion: bool = Field(True, description="防止递归")
delayUntilRecursion: bool = Field(False, description="延迟直到递归发生")
@validator('position_dx')
def validate_dx(cls, v, values):
if values.get('position_mode') == PositionMode.ABSOLUTE_DEPTH and v is None:
raise ValueError("当 position_mode 为 ABSOLUTE_DEPTH 时,必须指定 position_dx")
return v
# G. 状态与元数据 (State & Metadata)
disable: bool = Field(False, description="是否禁用该条目")
ignoreBudget: bool = Field(False, description="忽略 Token 预算")
outletName: Optional[str] = Field(None, description="出口名称")
automationId: Optional[str] = Field(None, description="自动化 ID")
sticky: int = Field(0, description="粘性值")
cooldown: int = Field(0, description="冷却时间")
delay: int = Field(0, description="延迟触发")
triggers: List[str] = Field(default_factory=list, description="触发器数组")
displayIndex: int = Field(0, description="UI 显示索引")
vectorized: bool = Field(False, description="是否已向量化")
# H. 兼容性字段 (Compatibility)
matchPersonaDescription: bool = Field(False, description="匹配人设描述")
matchCharacterDescription: bool = Field(False, description="匹配角色描述")
matchCharacterPersonality: bool = Field(False, description="匹配角色性格")
matchCharacterDepthPrompt: bool = Field(False, description="匹配深度提示词")
matchScenario: bool = Field(False, description="匹配场景")
matchCreatorNotes: bool = Field(False, description="匹配作者注释")
# I. 嵌套对象 (Nested Objects)
characterFilter: Optional[Dict[str, Any]] = Field(None, description="角色过滤器配置")
extensions: Optional[Dict[str, Any]] = Field(None, description="扩展属性")
@classmethod
def from_sillytavern_data(cls, data: Dict[str, Any]) -> 'WorldInfoEntry':
"""
从 SillyTavern 格式的数据创建条目
Args:
data: SillyTavern 格式的条目数据
Returns:
WorldInfoEntry: 世界书条目对象
Raises:
ValueError: 数据格式无效
"""
try:
# 提取基本字段
uid = int(data.get("uid", data.get("id", 0))) # 条目唯一标识符
key = data.get("key", data.get("keys", [])) # 条目触发关键词数组,用于匹配和触发该条目
keysecondary = data.get("keysecondary", data.get("secondary_keys", [])) # 次要关键词数组,用于扩展触发条件
comment = data.get("comment", "") # 条目备注说明,用于描述条目的用途和特点
content = data.get("content", "") # 条目的实际内容,将被注入到提示词中
constant = data.get("constant", False) # 是否常驻注入true表示始终注入
selective = data.get("selective", True) # 是否启用选择性匹配true表示仅关键词匹配时注入
order = data.get("order", data.get("insertion_order", 100)) # 条目注入顺序权重,数字越小越优先
# 处理 position 字段,确保为整数类型
position = data.get("position", 0)
if isinstance(position, str):
# 如果是字符串,尝试转换为整数
try:
position = int(position)
except ValueError:
# 如果转换失败,使用默认值
logger.warning(f"条目 {uid} 的 position 字段值 '{position}' 无法转换为整数,使用默认值 0")
position = 0
# 处理触发配置,默认使用永久触发
try:
trigger_config = TriggerConfig()
# 设置永久触发
trigger_config.set_trigger(TriggerStrategy.CONSTANT, constant)
# 设置关键词触发
if not constant and key:
keyword_config = KeywordTriggerConfig(
key=key,
keysecondary=keysecondary,
selective=selective,
selectiveLogic=data.get("selectiveLogic", 0),
matchWholeWords=data.get("matchWholeWords", False),
caseSensitive=data.get("caseSensitive", False)
)
trigger_config.set_trigger(TriggerStrategy.KEYWORD, True, keyword_config)
# 设置RAG触发
if "rag_threshold" in data or "top_k" in data or "query_template" in data:
rag_config = RAGTriggerConfig(
threshold=data.get("rag_threshold", 0.75),
top_k=data.get("top_k", 5),
query_template=data.get("query_template", None)
)
trigger_config.set_trigger(TriggerStrategy.RAG, True, rag_config)
# 设置条件触发
if "variable_a" in data and "operator" in data and "variable_b" in data:
condition_config = ConditionTriggerConfig(
variable_a=data.get("variable_a", ""),
operator=data.get("operator", "="),
variable_b=data.get("variable_b", "")
)
trigger_config.set_trigger(TriggerStrategy.CONDITION, True, condition_config)
except Exception as e:
# 如果触发配置解析失败,使用默认的永久触发配置
logger.warning(f"条目 {uid} 的触发配置解析失败: {str(e)},使用默认的永久触发配置")
trigger_config = TriggerConfig()
trigger_config.set_trigger(TriggerStrategy.CONSTANT, True)
# 处理其他字段
probability = data.get("probability", 100)
useProbability = data.get("useProbability", False)
group = data.get("group", None)
use_regex = data.get("useRegex", False)
# 处理 extensions 字段(如果存在)
extensions = data.get("extensions", {})
if extensions:
probability = extensions.get("probability", probability)
useProbability = extensions.get("useProbability", useProbability)
group = extensions.get("group", group)
# 创建条目对象
entry = cls(
uid=uid,
key=key, # 保留key字段用于兼容
comment=comment,
content=content,
position=position,
order=order,
trigger_config=trigger_config,
group=group,
probability=probability,
useProbability=useProbability,
use_regex=use_regex
)
# 复制其他字段(如果存在)
for key, value in data.items():
if hasattr(entry, key) and key not in ["uid", "key", "comment", "content",
"constant", "position", "order", "probability",
"useProbability", "group", "use_regex",
"keysecondary", "selective", "selectiveLogic",
"matchWholeWords", "caseSensitive"]:
setattr(entry, key, value)
return entry
except Exception as e:
logger.error(f"从 SillyTavern 数据创建条目失败: {str(e)}")
raise ValueError(f"从 SillyTavern 数据创建条目失败: {str(e)}")
def to_sillytavern_dict(self) -> Dict[str, Any]:
"""
转换为 SillyTavern 格式的字典
Returns:
Dict[str, Any]: SillyTavern 格式的条目数据
"""
result = {
"uid": self.uid,
"key": self.key,
"comment": self.comment,
"content": self.content,
"position": self.position,
"order": self.order,
"probability": self.probability,
"useProbability": self.useProbability,
"group": self.group,
"useRegex": getattr(self, 'use_regex', False),
"preventRecursion": self.preventRecursion,
"excludeRecursion": self.excludeRecursion
}
# 根据触发策略设置对应的字段
try:
# 检查永久触发是否启用
constant_enabled, _ = self.trigger_config.get_trigger(TriggerStrategy.CONSTANT)
result["constant"] = constant_enabled
# 检查关键词触发是否启用
keyword_enabled, keyword_config = self.trigger_config.get_trigger(TriggerStrategy.KEYWORD)
if keyword_enabled and keyword_config:
result["selective"] = keyword_config.selective
result["keysecondary"] = keyword_config.keysecondary
result["selectiveLogic"] = keyword_config.selectiveLogic
result["matchWholeWords"] = keyword_config.matchWholeWords
result["caseSensitive"] = keyword_config.caseSensitive
# 检查RAG触发是否启用
rag_enabled, rag_config = self.trigger_config.get_trigger(TriggerStrategy.RAG)
if rag_enabled and rag_config:
result["rag_threshold"] = rag_config.threshold
result["top_k"] = rag_config.top_k
result["query_template"] = rag_config.query_template
# 检查条件触发是否启用
condition_enabled, condition_config = self.trigger_config.get_trigger(TriggerStrategy.CONDITION)
if condition_enabled and condition_config:
result["variable_a"] = condition_config.variable_a
result["operator"] = condition_config.operator
result["variable_b"] = condition_config.variable_b
except Exception as e:
# 如果触发配置导出失败,使用默认的永久触发配置
logger.warning(f"条目 {self.uid} 的触发配置导出失败: {str(e)},使用默认的永久触发配置")
result["constant"] = True
return result
def get_trigger_params(self) -> Dict[str, Any]:
"""
获取触发策略所需的参数
Returns:
Dict[str, Any]: 触发参数字典
"""
params = {}
try:
# 获取所有启用的触发策略
enabled_triggers = self.trigger_config.get_enabled_triggers()
# 处理关键词触发
if TriggerStrategy.KEYWORD in enabled_triggers:
_, keyword_config = self.trigger_config.get_trigger(TriggerStrategy.KEYWORD)
if keyword_config:
params["key"] = keyword_config.key
params["keysecondary"] = keyword_config.keysecondary
params["selective"] = keyword_config.selective
params["selectiveLogic"] = keyword_config.selectiveLogic
params["matchWholeWords"] = keyword_config.matchWholeWords
params["caseSensitive"] = keyword_config.caseSensitive
# 处理RAG触发
if TriggerStrategy.RAG in enabled_triggers:
_, rag_config = self.trigger_config.get_trigger(TriggerStrategy.RAG)
if rag_config:
params["threshold"] = rag_config.threshold
params["top_k"] = rag_config.top_k
params["query_template"] = rag_config.query_template
params["vectorized"] = self.vectorized
# 处理条件触发
if TriggerStrategy.CONDITION in enabled_triggers:
_, condition_config = self.trigger_config.get_trigger(TriggerStrategy.CONDITION)
if condition_config:
params["variable_a"] = condition_config.variable_a
params["operator"] = condition_config.operator
params["variable_b"] = condition_config.variable_b
except Exception as e:
# 如果获取触发参数失败,返回空字典,表示使用默认的永久触发
logger.warning(f"条目 {self.uid} 的触发参数获取失败: {str(e)},使用默认的永久触发")
return params

View File

@@ -102,7 +102,7 @@ class ChatHistory(BaseModel):
"""获取所有角色的所有聊天列表"""
data_dir = cls.get_data_path()
if not data_dir.exists():
return {"chats": []}
return {"chat": []}
chats = []
for role_dir in data_dir.iterdir():
@@ -123,7 +123,7 @@ class ChatHistory(BaseModel):
})
except Exception:
continue # 跳过损坏的聊天文件
return {"chats": chats}
return {"chat": chats}
@classmethod
async def get_chat(cls, role_name: str, chat_name: str) -> Dict[str, Any]:

View File

@@ -1,7 +1,23 @@
import logging
import sys
# 配置日志
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
handlers=[
logging.StreamHandler(sys.stdout)
]
)
# 确保所有模块的日志都能被捕获
for logger_name in ['uvicorn', 'uvicorn.access', 'fastapi']:
logging_logger = logging.getLogger(logger_name)
logging_logger.setLevel(logging.INFO)
# backend/app/main.py
from fastapi import FastAPI
from .api.route import router
app = FastAPI(title="LLM Workflow Engine")
# 注册路由

View File

@@ -5,7 +5,7 @@ services:
build: ./backend
command: uvicorn backend.main:app --host 0.0.0.0 --port 8000 --reload
ports:
- "3001:8000"
- "23337:8000"
volumes:
- ./backend:/app/backend
- ./data:/app/data
@@ -19,7 +19,7 @@ services:
context: ./frontend-react
dockerfile: Dockerfile
ports:
- "3000:5173"
- "23338:5173"
volumes:
- ./frontend-react:/app
- /app/node_modules

View File

@@ -1,268 +1,671 @@
import { create } from 'zustand';
// 辅助函数:处理 API 响应
const handleResponse = async (response) => {
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || `请求失败: ${response.status}`);
}
return response.json().catch(() => ({}));
};
// 辅助函数:处理文件下载
const handleFileDownload = async (response, filename) => {
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', filename);
document.body.appendChild(link);
link.click();
link.parentNode.removeChild(link);
window.URL.revokeObjectURL(url);
};
// 创建世界书 store
const useWorldBookStore = create((set, get) => ({
// 世界书列表
worldBooks: [],
// 当前选中的世界书(用于编辑)
selectedWorldBook: null,
// 全局激活的世界书列表(在槽位中显示)
globalWorldBooks: [],
// 是否显示编辑面板
showEditPanel: false,
// 当前编辑的条目
editingEntry: null,
// 加载状态
isLoading: false,
// 选中世界书的加载状态
isSelecting: false,
// 错误信息
error: null,
// 是否显示世界书下拉框
showWorldBookDropdown: false,
// 是否显示添加世界书下拉框
showAddWorldBookDropdown: false,
// 状态
worldBooks: [], // 世界书列表
globalWorldBooks: [], // 全局世界书列表
currentWorldBook: null, // 当前选中的世界书
currentEntries: [], // 当前世界书的条目列表
currentEntry: null, // 当前选中的条目
loading: false, // 加载状态
error: null, // 错误信息
success: false, // 操作成功状态
message: '', // 成功或错误消息
// 从后端获取世界书列表
// Actions
clearError: () => set({ error: null, message: '' }),
clearSuccess: () => set({ success: false, message: '' }),
setCurrentWorldBook: (worldBook) => set({
currentWorldBook: worldBook,
currentEntries: [],
currentEntry: null
}),
setCurrentEntry: (entry) => set({ currentEntry: entry }),
resetCurrentWorldBook: () => set({
currentWorldBook: null,
currentEntries: [],
currentEntry: null
}),
// 异步操作:切换世界书的全局状态
toggleGlobalWorldBook: async (name, isGlobal) => {
set({ loading: true, error: null, success: false });
try {
// 先获取当前世界书的信息
const currentBook = get().worldBooks.find(wb => wb.name === name);
if (!currentBook) {
throw new Error(`世界书 "${name}" 不存在`);
}
const formData = new FormData();
formData.append('description', currentBook.description);
formData.append('is_global', isGlobal);
const response = await fetch(`/api/worldbooks/${name}`, {
method: 'PUT',
body: formData
});
const data = await handleResponse(response);
set(state => {
const updatedWorldBooks = state.worldBooks.map(wb =>
wb.name === data.name ? data : wb
);
// 更新全局世界书列表
let updatedGlobalBooks = [...state.globalWorldBooks];
const globalIndex = updatedGlobalBooks.findIndex(wb => wb.name === data.name);
if (isGlobal) {
// 如果是世界书被标记为全局
if (globalIndex === -1) {
// 如果不在全局列表中,添加它
updatedGlobalBooks = [...updatedGlobalBooks, data];
} else {
// 如果已经在全局列表中,更新它
updatedGlobalBooks[globalIndex] = data;
}
} else {
// 如果世界书不再全局,从全局列表中移除
if (globalIndex !== -1) {
updatedGlobalBooks = updatedGlobalBooks.filter(wb => wb.name !== data.name);
}
}
return {
loading: false,
worldBooks: updatedWorldBooks,
globalWorldBooks: updatedGlobalBooks,
currentWorldBook: state.currentWorldBook?.name === data.name
? data
: state.currentWorldBook,
success: true,
message: isGlobal ? `已将 "${name}" 设置为全局世界书` : `已取消 "${name}" 的全局世界书状态`
};
});
return data;
} catch (error) {
set({
loading: false,
error: error.message,
success: false
});
throw error;
}
},
// 异步操作:获取所有世界书
fetchWorldBooks: async () => {
set({ isLoading: true, error: null });
set({ loading: true, error: null });
try {
const response = await fetch('/api/worldbooks');
if (!response.ok) {
throw new Error('获取世界书列表失败');
}
const data = await response.json();
const response = await fetch(`/api/worldbooks/`);
const data = await handleResponse(response);
// 筛选出全局世界书
const globalBooks = data.filter(book => book.is_global);
set({
worldBooks: data.worldbooks,
globalWorldBooks: data.worldbooks.filter(book => book.enabled),
isLoading: false
loading: false,
worldBooks: data,
globalWorldBooks: globalBooks,
error: null
});
return data;
} catch (error) {
set({ error: error.message, isLoading: false });
console.error('获取世界书列表失败:', error);
}
},
// 切换世界书选择下拉框显示
toggleWorldBookDropdown: () => set((state) => ({
showWorldBookDropdown: !state.showWorldBookDropdown
})),
// 切换添加世界书下拉框显示
toggleAddWorldBookDropdown: () => set((state) => ({
showAddWorldBookDropdown: !state.showAddWorldBookDropdown
})),
// 添加世界书到全局槽位
addGlobalWorldBook: async (uid) => {
try {
const response = await fetch(`/api/worldbooks/${uid}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
enabled: true
}),
});
if (!response.ok) {
throw new Error('添加全局世界书失败');
}
// 更新本地状态
set((state) => ({
worldBooks: state.worldBooks.map(b =>
b.uid === uid ? { ...b, enabled: true } : b
),
globalWorldBooks: [...state.globalWorldBooks, state.worldBooks.find(b => b.uid === uid)]
}));
} catch (error) {
console.error('添加全局世界书失败:', error);
throw error;
}
},
// 从全局槽位移除世界书
removeGlobalWorldBook: async (uid) => {
try {
const response = await fetch(`/api/worldbooks/${uid}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
enabled: false
}),
});
if (!response.ok) {
throw new Error('移除全局世界书失败');
}
// 更新本地状态
set((state) => ({
worldBooks: state.worldBooks.map(b =>
b.uid === uid ? { ...b, enabled: false } : b
),
globalWorldBooks: state.globalWorldBooks.filter(b => b.uid !== uid)
}));
} catch (error) {
console.error('移除全局世界书失败:', error);
throw error;
}
},
// 创建新世界书
createWorldBook: async (name, description) => {
try {
const response = await fetch('/api/worldbooks', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name,
description: description || '',
enabled: false // 新建的世界书默认不全局激活
}),
});
if (!response.ok) {
throw new Error('创建世界书失败');
}
// 重新获取世界书列表
await get().fetchWorldBooks();
return await response.json();
} catch (error) {
console.error('创建世界书失败:', error);
throw error;
}
},
// 删除世界书
deleteWorldBook: async (id) => {
try {
const response = await fetch(`/api/worldbooks/${id}`, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error('删除世界书失败');
}
// 更新状态
set((state) => ({
worldBooks: state.worldBooks.filter(book => book.uid !== id),
selectedWorldBook: state.selectedWorldBook?.uid === id ? null : state.selectedWorldBook,
globalWorldBooks: state.globalWorldBooks.filter(book => book.uid !== id)
}));
} catch (error) {
console.error('删除世界书失败:', error);
throw error;
}
},
// 选择世界书(用于编辑)
selectWorldBook: async (id) => {
set({ isSelecting: true, error: null });
try {
const response = await fetch(`/api/worldbooks/${id}`);
if (!response.ok) {
throw new Error('获取世界书详情失败');
}
const data = await response.json();
set({
selectedWorldBook: data,
isSelecting: false
loading: false,
error: error.message
});
} catch (error) {
set({ error: error.message, isSelecting: false });
console.error('获取世界书详情失败:', error);
throw error;
}
},
// 添加条目
addEntry: async (entry) => {
const state = get();
if (!state.selectedWorldBook) return;
// 异步操作:获取指定世界书
fetchWorldBook: async (name) => {
set({ loading: true, error: null });
try {
const response = await fetch(`/api/worldbooks/${state.selectedWorldBook.uid}/entries`, {
const response = await fetch(`/api/worldbooks/${name}`);
const data = await handleResponse(response);
set({
loading: false,
currentWorldBook: data,
error: null
});
return data;
} catch (error) {
set({
loading: false,
error: error.message
});
throw error;
}
},
// 异步操作:创建世界书
createWorldBook: async ({ name, description, is_global, file }) => {
set({ loading: true, error: null, success: false });
try {
const formData = new FormData();
formData.append('name', name);
formData.append('description', description || '');
if (is_global !== undefined) {
formData.append('is_global', is_global);
}
if (file) {
formData.append('file', file);
}
const response = await fetch(`/api/worldbooks/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(entry),
body: formData
});
if (!response.ok) {
throw new Error('添加条目失败');
}
const data = await handleResponse(response);
// 重新获取选中的世界书
await get().selectWorldBook(state.selectedWorldBook.uid);
set(state => {
const newWorldBooks = [...state.worldBooks, data];
const newGlobalBooks = data.is_global
? [...state.globalWorldBooks, data]
: state.globalWorldBooks;
return {
loading: false,
worldBooks: newWorldBooks,
globalWorldBooks: newGlobalBooks,
currentWorldBook: data,
success: true,
message: '世界书创建成功'
};
});
return data;
} catch (error) {
console.error('添加条目失败:', error);
set({
loading: false,
error: error.message,
success: false
});
throw error;
}
},
// 删除条目
deleteEntry: async (entryId) => {
const state = get();
if (!state.selectedWorldBook) return;
// 异步操作:更新世界书
updateWorldBook: async ({ name, description, is_global, file }) => {
set({ loading: true, error: null, success: false });
try {
const response = await fetch(`/api/worldbooks/${state.selectedWorldBook.uid}/entries/${entryId}`, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error('删除条目失败');
const formData = new FormData();
if (description !== undefined) {
formData.append('description', description);
}
if (is_global !== undefined) {
formData.append('is_global', is_global);
}
if (file) {
formData.append('file', file);
}
// 重新获取选中的世界书
await get().selectWorldBook(state.selectedWorldBook.uid);
} catch (error) {
console.error('删除条目失败:', error);
throw error;
}
},
// 更新条目
updateEntry: async (entryId, updatedEntry) => {
const state = get();
if (!state.selectedWorldBook) return;
try {
const response = await fetch(`/api/worldbooks/${state.selectedWorldBook.uid}/entries/${entryId}`, {
const response = await fetch(`/api/worldbooks/${name}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(updatedEntry),
body: formData
});
if (!response.ok) {
throw new Error('更新条目失败');
}
const data = await handleResponse(response);
// 重新获取选中的世界书
await get().selectWorldBook(state.selectedWorldBook.uid);
set(state => {
const updatedWorldBooks = state.worldBooks.map(wb =>
wb.name === data.name ? data : wb
);
// 更新全局世界书列表
let updatedGlobalBooks = [...state.globalWorldBooks];
const globalIndex = updatedGlobalBooks.findIndex(wb => wb.name === data.name);
if (data.is_global) {
// 如果是世界书被标记为全局
if (globalIndex === -1) {
// 如果不在全局列表中,添加它
updatedGlobalBooks = [...updatedGlobalBooks, data];
} else {
// 如果已经在全局列表中,更新它
updatedGlobalBooks[globalIndex] = data;
}
} else {
// 如果世界书不再全局,从全局列表中移除
if (globalIndex !== -1) {
updatedGlobalBooks = updatedGlobalBooks.filter(wb => wb.name !== data.name);
}
}
return {
loading: false,
worldBooks: updatedWorldBooks,
globalWorldBooks: updatedGlobalBooks,
currentWorldBook: state.currentWorldBook?.name === data.name
? data
: state.currentWorldBook,
success: true,
message: '世界书更新成功'
};
});
return data;
} catch (error) {
console.error('更新条目失败:', error);
set({
loading: false,
error: error.message,
success: false
});
throw error;
}
},
// 切换编辑面板显示
toggleEditPanel: (show, entry = null) => set({
showEditPanel: show !== undefined ? show : !get().showEditPanel,
editingEntry: entry
})
// 异步操作:删除世界书
deleteWorldBook: async (name) => {
set({ loading: true, error: null, success: false });
try {
const response = await fetch(`/api/worldbooks/${name}`, {
method: 'DELETE'
});
await handleResponse(response);
set(state => {
const filteredWorldBooks = state.worldBooks.filter(wb => wb.name !== name);
const filteredGlobalBooks = state.globalWorldBooks.filter(wb => wb.name !== name);
return {
loading: false,
worldBooks: filteredWorldBooks,
globalWorldBooks: filteredGlobalBooks,
currentWorldBook: state.currentWorldBook?.name === name
? null
: state.currentWorldBook,
currentEntries: state.currentWorldBook?.name === name
? []
: state.currentEntries,
currentEntry: state.currentWorldBook?.name === name
? null
: state.currentEntry,
success: true,
message: '世界书删除成功'
};
});
return name;
} catch (error) {
set({
loading: false,
error: error.message,
success: false
});
throw error;
}
},
// 异步操作:获取世界书的所有条目
fetchWorldBookEntries: async (name) => {
set({ loading: true, error: null });
try {
const response = await fetch(`/api/worldbooks/${name}/entries`);
const data = await handleResponse(response);
set(state => {
if (state.currentWorldBook?.name === name) {
return {
loading: false,
currentEntries: data,
error: null
};
}
return { loading: false, error: null };
});
return data;
} catch (error) {
set({
loading: false,
error: error.message
});
throw error;
}
},
// 异步操作:获取世界书的指定条目
fetchWorldBookEntry: async (name, uid) => {
set({ loading: true, error: null });
try {
const response = await fetch(`/api/worldbooks/${name}/entries/${uid}`);
const data = await handleResponse(response);
set(state => {
if (state.currentWorldBook?.name === name) {
return {
loading: false,
currentEntry: data,
error: null
};
}
return { loading: false, error: null };
});
return data;
} catch (error) {
set({
loading: false,
error: error.message
});
throw error;
}
},
// 异步操作:创建世界书条目
createWorldBookEntry: async (name, entryData) => {
set({ loading: true, error: null, success: false });
try {
// 处理触发配置数据
const processedEntryData = { ...entryData };
if (processedEntryData.trigger_config && processedEntryData.trigger_config.triggers) {
// 创建新的触发配置对象
const triggerConfig = {
triggers: {}
};
// 处理每个触发策略
for (const [strategy, triggerInfo] of Object.entries(processedEntryData.trigger_config.triggers)) {
if (Array.isArray(triggerInfo) && triggerInfo.length >= 2) {
triggerConfig.triggers[strategy] = [
triggerInfo[0], // 是否启用
triggerInfo[1] // 配置对象
];
} else {
// 如果格式不正确,设置为不启用
triggerConfig.triggers[strategy] = [false, null];
}
}
processedEntryData.trigger_config = triggerConfig;
}
const response = await fetch(`/api/worldbooks/${name}/entries`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(processedEntryData)
});
const data = await handleResponse(response);
set(state => {
if (state.currentWorldBook?.name === name) {
return {
loading: false,
currentEntries: [...state.currentEntries, data],
success: true,
message: '条目创建成功'
};
}
return {
loading: false,
success: true,
message: '条目创建成功'
};
});
return data;
} catch (error) {
set({
loading: false,
error: error.message,
success: false
});
throw error;
}
},
// 异步操作:更新世界书条目
updateWorldBookEntry: async (name, uid, entryData) => {
set({ loading: true, error: null, success: false });
try {
// 处理触发配置数据
const processedEntryData = { ...entryData };
if (processedEntryData.trigger_config && processedEntryData.trigger_config.triggers) {
// 创建新的触发配置对象
const triggerConfig = {
triggers: {}
};
// 处理每个触发策略
for (const [strategy, triggerInfo] of Object.entries(processedEntryData.trigger_config.triggers)) {
if (Array.isArray(triggerInfo) && triggerInfo.length >= 2) {
triggerConfig.triggers[strategy] = [
triggerInfo[0], // 是否启用
triggerInfo[1] // 配置对象
];
} else {
// 如果格式不正确,设置为不启用
triggerConfig.triggers[strategy] = [false, null];
}
}
processedEntryData.trigger_config = triggerConfig;
}
const response = await fetch(`/api/worldbooks/${name}/entries/${uid}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(processedEntryData)
});
const data = await handleResponse(response);
set(state => {
if (state.currentWorldBook?.name === name) {
const updatedEntries = state.currentEntries.map(entry =>
entry.uid === data.uid ? data : entry
);
return {
loading: false,
currentEntries: updatedEntries,
currentEntry: state.currentEntry?.uid === data.uid
? data
: state.currentEntry,
success: true,
message: '条目更新成功'
};
}
return {
loading: false,
success: true,
message: '条目更新成功'
};
});
return data;
} catch (error) {
set({
loading: false,
error: error.message,
success: false
});
throw error;
}
},
// 异步操作:删除世界书条目
deleteWorldBookEntry: async (name, uid) => {
set({ loading: true, error: null, success: false });
try {
const response = await fetch(`/api/worldbooks/${name}/entries/${uid}`, {
method: 'DELETE'
});
await handleResponse(response);
set(state => {
if (state.currentWorldBook?.name === name) {
const filteredEntries = state.currentEntries.filter(entry => entry.uid !== uid);
return {
loading: false,
currentEntries: filteredEntries,
currentEntry: state.currentEntry?.uid === uid
? null
: state.currentEntry,
success: true,
message: '条目删除成功'
};
}
return {
loading: false,
success: true,
message: '条目删除成功'
};
});
return { name, uid };
} catch (error) {
set({
loading: false,
error: error.message,
success: false
});
throw error;
}
},
// 异步操作:导入世界书
importWorldBook: async (name, file) => {
set({ loading: true, error: null, success: false });
try {
const formData = new FormData();
formData.append('file', file);
const response = await fetch(`/api/worldbooks/${name}/import`, {
method: 'POST',
body: formData
});
const data = await handleResponse(response);
set(state => {
const existingIndex = state.worldBooks.findIndex(wb => wb.name === data.name);
let updatedWorldBooks;
let updatedGlobalBooks = [...state.globalWorldBooks];
if (existingIndex !== -1) {
updatedWorldBooks = [...state.worldBooks];
updatedWorldBooks[existingIndex] = data;
// 更新全局世界书列表
const globalIndex = updatedGlobalBooks.findIndex(wb => wb.name === data.name);
if (data.is_global) {
if (globalIndex === -1) {
updatedGlobalBooks = [...updatedGlobalBooks, data];
} else {
updatedGlobalBooks[globalIndex] = data;
}
} else {
if (globalIndex !== -1) {
updatedGlobalBooks = updatedGlobalBooks.filter(wb => wb.name !== data.name);
}
}
} else {
updatedWorldBooks = [...state.worldBooks, data];
// 如果是世界书被标记为全局,添加到全局列表
if (data.is_global) {
updatedGlobalBooks = [...updatedGlobalBooks, data];
}
}
return {
loading: false,
worldBooks: updatedWorldBooks,
globalWorldBooks: updatedGlobalBooks,
currentWorldBook: state.currentWorldBook?.name === data.name
? data
: state.currentWorldBook,
success: true,
message: '世界书导入成功'
};
});
return data;
} catch (error) {
set({
loading: false,
error: error.message,
success: false
});
throw error;
}
},
// 异步操作:导出世界书
exportWorldBook: async (name) => {
set({ loading: true, error: null, success: false });
try {
const response = await fetch(`/api/worldbooks/${name}/export`);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || `请求失败: ${response.status}`);
}
// 处理文件下载
await handleFileDownload(response, `${name}.json`);
set({
loading: false,
success: true,
message: '世界书导出成功'
});
return { name };
} catch (error) {
set({
loading: false,
error: error.message,
success: false
});
throw error;
}
},
}));
export default useWorldBookStore;

View File

@@ -5,243 +5,683 @@ import useWorldBookStore from '../../../Store/Slices/LeftTabsSlices/WorldBookSli
const WorldBook = () => {
const {
worldBooks,
selectedWorldBook,
showEditPanel,
editingEntry,
isLoading,
isSelecting,
error,
showWorldBookDropdown,
globalWorldBooks,
currentWorldBook,
currentEntries,
currentEntry,
loading,
error,
success,
message,
fetchWorldBooks,
toggleWorldBookDropdown,
addGlobalWorldBook,
removeGlobalWorldBook,
fetchWorldBook,
createWorldBook,
deleteWorldBook,
selectWorldBook,
addEntry,
deleteEntry,
updateEntry,
toggleEditPanel,
fetchWorldBookEntries,
createWorldBookEntry,
updateWorldBookEntry,
deleteWorldBookEntry,
toggleGlobalWorldBook,
setCurrentWorldBook,
setCurrentEntry,
resetCurrentWorldBook,
clearError,
clearSuccess,
} = useWorldBookStore();
const [newEntry, setNewEntry] = useState({
name: '',
content: '',
enabled: true,
triggerStrategy: 'keyword',
insertPosition: 'after',
order: 0,
});
useEffect(() => {
fetchWorldBooks();
}, [fetchWorldBooks]);
const [newEntry, setNewEntry] = useState({
uid: 0,
key: [],
keysecondary: [],
content: '',
comment: '',
constant: false,
position: 0,
order: 100,
depth:4,
selective: true,
selectiveLogic: 0,
probability: 100,
useProbability: false,
role: 0,
caseSensitive: false,
matchWholeWords: false,
useGroupScoring: false,
group: '',
groupOverride: false,
groupWeight: 100,
excludeRecursion: true,
preventRecursion: true,
delayUntilRecursion: false,
disable: false,
ignoreBudget: false,
outletName: '',
automationId: '',
sticky: 0,
cooldown: 0,
delay: 0,
triggers: [],
displayIndex: 0,
vectorized: false,
// 新的触发配置结构
trigger_config: {
triggers: {
constant: [true, null],
keyword: [false, {
key: [],
keysecondary: [],
selective: true,
selectiveLogic: 0,
matchWholeWords: false,
caseSensitive: false
}],
rag: [false, {
threshold: 0.75,
top_k: 5,
query_template: null
}],
condition: [false, {
variable_a: '',
operator: '=',
variable_b: ''
}]
}
}
});
const [showEditPanel, setShowEditPanel] = useState(false);
const [showWorldBookDropdown, setShowWorldBookDropdown] = useState(false);
const [showGlobalDropdown, setShowGlobalDropdown] = useState(false);
useEffect(() => {
fetchWorldBooks();
}, [fetchWorldBooks]);
useEffect(() => {
if (success && message) {
alert(message);
clearSuccess();
}
}, [success, message, clearSuccess]);
useEffect(() => {
if (error) {
alert(error);
clearError();
}
}, [error, clearError]);
const handleCreateWorldBook = async () => {
const name = prompt('请输入世界书名称:');
const description = prompt('请输入世界书描述(可选):') || '';
if (name) {
await createWorldBook(name);
try {
await createWorldBook({ name, description });
// 创建成功后自动选择新创建的世界书
const newBook = worldBooks.find(wb => wb.name === name);
if (newBook) {
handleSelectWorldBook(newBook);
}
} catch (err) {
console.error('创建世界书失败:', err);
}
}
};
const handleAddEntry = async () => {
if (!selectedWorldBook) return;
await addEntry(newEntry);
setNewEntry({
name: '',
content: '',
enabled: true,
triggerStrategy: 'keyword',
insertPosition: 'after',
order: 0,
});
const handleSelectWorldBook = async (book) => {
setCurrentWorldBook(book);
setShowWorldBookDropdown(false);
try {
await fetchWorldBookEntries(book.name);
} catch (err) {
console.error('加载世界书条目失败:', err);
}
};
const handleToggleGlobal = async (name, isGlobal) => {
try {
await toggleGlobalWorldBook(name, isGlobal);
} catch (err) {
console.error('切换全局世界书状态失败:', err);
}
};
const handleAddEntry = async () => {
if (!currentWorldBook) return;
// 生成新的UID
const maxUid = currentEntries.reduce((max, entry) => Math.max(max, entry.uid), 0);
const newUid = maxUid + 1;
// 处理触发配置数据
const triggerConfig = {
triggers: {
constant: [newEntry.constant, null],
keyword: [!newEntry.constant && newEntry.key.length > 0, {
key: newEntry.key,
keysecondary: newEntry.keysecondary,
selective: newEntry.selective,
selectiveLogic: newEntry.selectiveLogic,
matchWholeWords: newEntry.matchWholeWords,
caseSensitive: newEntry.caseSensitive
}],
rag: [false, {
threshold: newEntry.rag_threshold || 0.75,
top_k: 5,
query_template: null
}],
condition: [false, {
variable_a: '',
operator: '=',
variable_b: ''
}]
}
};
const entryData = {
uid: newUid,
content: newEntry.content,
comment: newEntry.comment,
position: newEntry.position,
order: newEntry.order,
depth: newEntry.depth,
role: newEntry.role,
disable: newEntry.disable,
ignoreBudget: newEntry.ignoreBudget,
outletName: newEntry.outletName,
automationId: newEntry.automationId,
sticky: newEntry.sticky,
cooldown: newEntry.cooldown,
delay: newEntry.delay,
triggers: newEntry.triggers,
displayIndex: newEntry.displayIndex,
vectorized: newEntry.vectorized,
useGroupScoring: newEntry.useGroupScoring,
group: newEntry.group,
groupOverride: newEntry.groupOverride,
groupWeight: newEntry.groupWeight,
excludeRecursion: newEntry.excludeRecursion,
preventRecursion: newEntry.preventRecursion,
delayUntilRecursion: newEntry.delayUntilRecursion,
probability: newEntry.probability,
useProbability: newEntry.useProbability,
trigger_config: triggerConfig
};
try {
await createWorldBookEntry(currentWorldBook.name, entryData);
// 重置新条目表单
setNewEntry({
uid: 0,
key: [],
keysecondary: [],
content: '',
comment: '',
constant: false,
position: 0,
order: 100,
depth:4,
selective: true,
selectiveLogic: 0,
probability: 100,
useProbability: false,
role: 0,
caseSensitive: false,
matchWholeWords: false,
useGroupScoring: false,
group: '',
groupOverride: false,
groupWeight: 100,
excludeRecursion: true,
preventRecursion: true,
delayUntilRecursion: false,
disable: false,
ignoreBudget: false,
outletName: '',
automationId: '',
sticky: 0,
cooldown: 0,
delay: 0,
triggers: [],
displayIndex: 0,
vectorized: false,
trigger_config: {
triggers: {
constant: [true, null],
keyword: [false, {
key: [],
keysecondary: [],
selective: true,
selectiveLogic: 0,
matchWholeWords: false,
caseSensitive: false
}],
rag: [false, {
threshold: 0.75,
top_k: 5,
query_template: null
}],
condition: [false, {
variable_a: '',
operator: '=',
variable_b: ''
}]
}
}
});
} catch (err) {
console.error('添加条目失败:', err);
}
};
const handleEntryClick = (entry) => {
toggleEditPanel(true, entry);
setCurrentEntry(entry);
setShowEditPanel(true);
};
const handleEntryUpdate = async (field, value) => {
if (!editingEntry) return;
const updatedEntry = { ...editingEntry, [field]: value };
await updateEntry(editingEntry.uid, updatedEntry);
};
if (!currentEntry || !currentWorldBook) return;
const isGlobalBook = (bookUid) => {
return globalWorldBooks.some(gb => gb.uid === bookUid);
};
const handleToggleGlobalBook = (bookUid) => {
if (isGlobalBook(bookUid)) {
removeGlobalWorldBook(bookUid);
} else {
addGlobalWorldBook(bookUid);
const updatedEntry = { ...currentEntry, [field]: value };
try {
await updateWorldBookEntry(currentWorldBook.name, currentEntry.uid, updatedEntry);
setCurrentEntry(updatedEntry);
} catch (err) {
console.error('更新条目失败:', err);
}
};
const sortedWorldBooks = [...worldBooks].sort((a, b) => {
const aIsGlobal = isGlobalBook(a.uid);
const bIsGlobal = isGlobalBook(b.uid);
if (aIsGlobal && !bIsGlobal) return -1;
if (!aIsGlobal && bIsGlobal) return 1;
return 0;
});
const handleDeleteEntry = async () => {
if (!currentEntry || !currentWorldBook) return;
if (confirm('确定要删除此条目吗?')) {
try {
await deleteWorldBookEntry(currentWorldBook.name, currentEntry.uid);
setShowEditPanel(false);
setCurrentEntry(null);
} catch (err) {
console.error('删除条目失败:', err);
}
}
};
const handleDeleteWorldBook = async () => {
if (!currentWorldBook) return;
if (confirm(`确定要删除世界书 "${currentWorldBook.name}" 吗?`)) {
try {
await deleteWorldBook(currentWorldBook.name);
resetCurrentWorldBook();
} catch (err) {
console.error('删除世界书失败:', err);
}
}
};
const handleImportWorldBook = async () => {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json';
input.onchange = async (e) => {
const file = e.target.files[0];
if (!file) return;
const name = prompt('请输入世界书名称:', file.name.replace('.json', ''));
if (!name) return;
try {
await useWorldBookStore.getState().importWorldBook(name, file);
await fetchWorldBooks();
// 导入成功后选择新导入的世界书
const importedBook = worldBooks.find(wb => wb.name === name);
if (importedBook) {
handleSelectWorldBook(importedBook);
}
} catch (err) {
console.error('导入世界书失败:', err);
}
};
input.click();
};
const handleExportWorldBook = async () => {
if (!currentWorldBook) return;
try {
await useWorldBookStore.getState().exportWorldBook(currentWorldBook.name);
} catch (err) {
console.error('导出世界书失败:', err);
}
};
return (
<div className="worldbook-content">
{/* 全局世界书区域 */}
<div className="global-worldbooks-section">
<div className="global-worldbooks-slot">
<div className="global-books-header">
<span className="title-text">全局世界书</span>
{globalWorldBooks.length > 0 && (
<div className="active-books-list">
{globalWorldBooks.map(book => (
<span
key={book.uid}
className="active-book-item"
onClick={() => selectWorldBook(book.uid)}
>
{book.name}
<span
className="remove-btn"
onClick={(e) => {
e.stopPropagation();
removeGlobalWorldBook(book.uid);
}}
>
</span>
</span>
))}
</div>
)}
</div>
</div>
{/* 操作按钮组 */}
<div className="worldbook-actions">
<button className="action-btn" onClick={handleCreateWorldBook}>
+ 新建
</button>
<button className="action-btn">
📋 复制
</button>
<button className="action-btn">
📥 导入
</button>
<button className="action-btn">
📤 导出
</button>
</div>
</div>
{/* 世界书选择区域 */}
<div className="worldbook-selector">
<div className="dropdown" style={{ flex: 1 }}>
<button className="dropdown-btn" onClick={toggleWorldBookDropdown}>
{selectedWorldBook ? selectedWorldBook.name : '选择世界书'}
<span></span>
</button>
{showWorldBookDropdown && (
<div className="dropdown-menu">
{worldBooks.map(book => (
<div
key={book.uid}
className={`dropdown-item ${selectedWorldBook?.uid === book.uid ? 'active' : ''}`}
onClick={() => {
selectWorldBook(book.uid);
toggleWorldBookDropdown(false);
}}
>
{book.name}
{/* 全局世界书区域 */}
<div className="worldbook-selector-section">
{/* 全局世界书展示区域 */}
<div className="global-books-display">
<div className="global-books-header">
<span className="title-text">全局世界书</span>
</div>
{globalWorldBooks.length > 0 ? (
<div className="global-books-list">
{globalWorldBooks.map(book => (
<div key={book.name} className="global-book-item">
<span className="global-book-name">{book.name}</span>
<button
className="btn-icon"
onClick={(e) => {
e.stopPropagation();
handleToggleGlobal(book.name, false);
}}
title="取消全局"
>
</button>
</div>
))}
</div>
) : (
<div className="no-global-books">暂无全局世界书</div>
)}
</div>
{/* 世界书管理区域 */}
<div className="worldbook-management">
<div className="worldbook-header">
<span className="title-text">世界书管理</span>
</div>
{/* 操作按钮组 */}
<div className="worldbook-actions">
<button className="action-btn" onClick={handleCreateWorldBook}>
+ 新建
</button>
<button className="action-btn" onClick={handleImportWorldBook}>
📥 导入
</button>
{currentWorldBook && (
<button className="action-btn" onClick={handleExportWorldBook}>
📤 导出
</button>
)}
</div>
{/* 世界书选择区域 */}
<div className="worldbook-selector">
<div className="dropdown" style={{ flex: 1 }}>
<button className="dropdown-btn" onClick={() => setShowWorldBookDropdown(!showWorldBookDropdown)}>
{currentWorldBook ? currentWorldBook.name : '选择世界书'}
<span></span>
</button>
{showWorldBookDropdown && (
<div className="dropdown-menu">
{worldBooks.map(book => (
<div
key={book.name}
className={`dropdown-item ${currentWorldBook?.name === book.name ? 'active' : ''}`}
onClick={(e) => {
// 防止点击复选框时触发选择世界书
if (e.target.type !== 'checkbox') {
handleSelectWorldBook(book);
}
}}
>
<label className="checkbox-label">
<input
type="checkbox"
checked={book.is_global}
onChange={(e) => {
e.stopPropagation();
handleToggleGlobal(book.name, e.target.checked);
}}
/>
<span className="book-name">{book.name}</span>
{book.description && (
<span className="book-desc">{book.description}</span>
)}
</label>
</div>
))}
</div>
)}
</div>
{currentWorldBook && (
<button
className="btn btn-danger"
onClick={handleDeleteWorldBook}
>
删除
</button>
)}
</div>
{/* 条目列表区域 */}
{loading ? (
<div className="loading">加载中...</div>
) : error ? (
<div className="error">{error}</div>
) : currentWorldBook ? (
<div className="entries-container" style={{ maxHeight: 'calc(100vh - 400px)', overflowY: 'auto' }}>
{currentEntries.length > 0 ? (
currentEntries.map(entry => (
<div
key={entry.uid}
className={`entry-item ${currentEntry?.uid === entry.uid ? 'active' : ''}`}
onClick={() => handleEntryClick(entry)}
>
<div className="entry-header">
<span className="entry-name">
{entry.comment || `条目 #${entry.uid}`}
</span>
<span className={`entry-status ${!entry.disable ? 'enabled' : ''}`}>
{!entry.disable ? '启用' : '禁用'}
</span>
</div>
<div className="entry-meta">
<span>策略: {entry.trigger_strategy}</span>
<span>位置: {entry.position}</span>
<span>顺序: {entry.order}</span>
</div>
</div>
))
) : (
<div className="loading">暂无条目</div>
)}
<button className="btn btn-primary" onClick={handleAddEntry}>
+ 添加条目
</button>
</div>
) : (
<div className="loading">请选择一个世界书</div>
)}
</div>
{selectedWorldBook && (
<button
className="btn btn-danger"
onClick={() => deleteWorldBook(selectedWorldBook.uid)}
>
删除
</button>
)}
</div>
{/* 条目列表区域 */}
{isLoading ? (
<div className="loading">加载中...</div>
) : error ? (
<div className="error">{error}</div>
) : selectedWorldBook ? (
<div className="entries-container">
{isSelecting ? (
<div className="loading">加载条目中...</div>
) : selectedWorldBook.entries && selectedWorldBook.entries.length > 0 ? (
selectedWorldBook.entries.map(entry => (
<div
key={entry.uid}
className={`entry-item ${editingEntry?.uid === entry.uid ? 'active' : ''}`}
onClick={() => handleEntryClick(entry)}
>
<div className="entry-header">
<span className="entry-name">{entry.name}</span>
<span className={`entry-status ${entry.enabled ? 'enabled' : ''}`}>
{entry.enabled ? '启用' : '禁用'}
</span>
</div>
<div className="entry-meta">
<span>策略: {entry.triggerStrategy}</span>
<span>位置: {entry.insertPosition}</span>
<span>顺序: {entry.order}</span>
</div>
</div>
))
) : (
<div className="loading">暂无条目</div>
)}
<button className="btn btn-primary" onClick={handleAddEntry}>
+ 添加条目
</button>
</div>
) : (
<div className="loading">请选择一个世界书</div>
)}
{/* 编辑面板 */}
{showEditPanel && editingEntry && (
{showEditPanel && currentEntry && (
<div className={`edit-panel ${showEditPanel ? 'open' : ''}`}>
<div className="edit-panel-header">
<h2>编辑条目</h2>
<button className="close-btn" onClick={() => toggleEditPanel(false)}>
<button className="close-btn" onClick={() => setShowEditPanel(false)}>
</button>
</div>
<div className="form-group">
<label className="form-label">主关键词</label>
<input
type="text"
className="form-input"
value={currentEntry.trigger_config?.triggers?.keyword?.[1]?.key?.join(', ') || ''}
onChange={(e) => {
const updatedTriggerConfig = {
...currentEntry.trigger_config,
triggers: {
...currentEntry.trigger_config?.triggers,
keyword: [
currentEntry.trigger_config?.triggers?.keyword?.[0] || false,
{
...currentEntry.trigger_config?.triggers?.keyword?.[1],
key: e.target.value.split(',').map(k => k.trim())
}
]
}
};
handleEntryUpdate('trigger_config', updatedTriggerConfig);
}}
/>
</div>
<div className="form-group">
<label className="form-label">次要关键词</label>
<input
type="text"
className="form-input"
value={currentEntry.trigger_config?.triggers?.keyword?.[1]?.keysecondary?.join(', ') || ''}
onChange={(e) => {
const updatedTriggerConfig = {
...currentEntry.trigger_config,
triggers: {
...currentEntry.trigger_config?.triggers,
keyword: [
currentEntry.trigger_config?.triggers?.keyword?.[0] || false,
{
...currentEntry.trigger_config?.triggers?.keyword?.[1],
keysecondary: e.target.value.split(',').map(k => k.trim())
}
]
}
};
handleEntryUpdate('trigger_config', updatedTriggerConfig);
}}
/>
</div>
<div className="form-group">
<label className="form-label">
<input
type="checkbox"
checked={currentEntry.trigger_config?.triggers?.keyword?.[1]?.selective || false}
onChange={(e) => {
const updatedTriggerConfig = {
...currentEntry.trigger_config,
triggers: {
...currentEntry.trigger_config?.triggers,
keyword: [
currentEntry.trigger_config?.triggers?.keyword?.[0] || false,
{
...currentEntry.trigger_config?.triggers?.keyword?.[1],
selective: e.target.checked
}
]
}
};
handleEntryUpdate('trigger_config', updatedTriggerConfig);
}}
/>
选择性匹配
</label>
</div>
<div className="form-group">
<label className="form-label">
<input
type="checkbox"
checked={currentEntry.trigger_config?.triggers?.keyword?.[1]?.matchWholeWords || false}
onChange={(e) => {
const updatedTriggerConfig = {
...currentEntry.trigger_config,
triggers: {
...currentEntry.trigger_config?.triggers,
keyword: [
currentEntry.trigger_config?.triggers?.keyword?.[0] || false,
{
...currentEntry.trigger_config?.triggers?.keyword?.[1],
matchWholeWords: e.target.checked
}
]
}
};
handleEntryUpdate('trigger_config', updatedTriggerConfig);
}}
/>
全词匹配
</label>
</div>
<div className="form-group">
<label className="form-label">
<input
type="checkbox"
checked={currentEntry.trigger_config?.triggers?.keyword?.[1]?.caseSensitive || false}
onChange={(e) => {
const updatedTriggerConfig = {
...currentEntry.trigger_config,
triggers: {
...currentEntry.trigger_config?.triggers,
keyword: [
currentEntry.trigger_config?.triggers?.keyword?.[0] || false,
{
...currentEntry.trigger_config?.triggers?.keyword?.[1],
caseSensitive: e.target.checked
}
]
}
};
handleEntryUpdate('trigger_config', updatedTriggerConfig);
}}
/>
区分大小写
</label>
</div>
<div className="form-group">
<label className="form-label">名称</label>
<label className="form-label">
<input
type="checkbox"
checked={currentEntry.useProbability}
onChange={(e) => handleEntryUpdate('useProbability', e.target.checked)}
/>
使用概率判定
</label>
</div>
{currentEntry.useProbability && (
<div className="form-group">
<label className="form-label">触发概率 (%)</label>
<input
type="number"
className="form-input"
min="0"
max="100"
value={currentEntry.probability}
onChange={(e) => handleEntryUpdate('probability', parseInt(e.target.value))}
/>
</div>
)}
<div className="form-group">
<label className="form-label">分组</label>
<input
type="text"
className="form-input"
value={editingEntry.name}
onChange={(e) => handleEntryUpdate('name', e.target.value)}
value={currentEntry.group || ''}
onChange={(e) => handleEntryUpdate('group', e.target.value)}
/>
</div>
<div className="form-group">
<label className="form-label">内容</label>
<textarea
className="form-textarea"
value={editingEntry.content}
onChange={(e) => handleEntryUpdate('content', e.target.value)}
<label className="form-label">分组权重</label>
<input
type="number"
className="form-input"
value={currentEntry.groupWeight}
onChange={(e) => handleEntryUpdate('groupWeight', parseInt(e.target.value))}
/>
</div>
@@ -249,52 +689,16 @@ const WorldBook = () => {
<label className="form-label">
<input
type="checkbox"
checked={editingEntry.enabled}
onChange={(e) => handleEntryUpdate('enabled', e.target.checked)}
checked={currentEntry.groupOverride}
onChange={(e) => handleEntryUpdate('groupOverride', e.target.checked)}
/>
启用此条目
覆盖分组限制
</label>
</div>
<div className="form-group">
<label className="form-label">触发策略</label>
<select
className="form-select"
value={editingEntry.triggerStrategy}
onChange={(e) => handleEntryUpdate('triggerStrategy', e.target.value)}
>
<option value="persistent">持久</option>
<option value="keyword">关键词</option>
<option value="rag">RAG</option>
<option value="calculation">运算</option>
</select>
</div>
<div className="form-group">
<label className="form-label">插入位置</label>
<select
className="form-select"
value={editingEntry.insertPosition}
onChange={(e) => handleEntryUpdate('insertPosition', e.target.value)}
>
<option value="before">之前</option>
<option value="after">之后</option>
</select>
</div>
<div className="form-group">
<label className="form-label">顺序</label>
<input
type="number"
className="form-input"
value={editingEntry.order}
onChange={(e) => handleEntryUpdate('order', parseInt(e.target.value))}
/>
</div>
<button
className="btn btn-danger"
onClick={() => deleteEntry(editingEntry.uid)}
onClick={handleDeleteEntry}
>
删除条目
</button>