世界书部分基本完成
This commit is contained in:
@@ -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"])
|
||||
|
||||
|
||||
# ========== 聊天历史基础路由 ==========
|
||||
|
||||
@@ -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)}")
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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
|
||||
@@ -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]:
|
||||
|
||||
@@ -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")
|
||||
|
||||
# 注册路由
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user