diff --git a/backend/api/routes/chatsRoute.py b/backend/api/routes/chatsRoute.py index 5b56200..98f8d6e 100644 --- a/backend/api/routes/chatsRoute.py +++ b/backend/api/routes/chatsRoute.py @@ -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"]) # ========== 聊天历史基础路由 ========== diff --git a/backend/api/routes/worldbooksRoute.py b/backend/api/routes/worldbooksRoute.py index a380e68..51fb451 100644 --- a/backend/api/routes/worldbooksRoute.py +++ b/backend/api/routes/worldbooksRoute.py @@ -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)}") diff --git a/backend/core/config.py b/backend/core/config.py index 4ea344c..6bfb580 100644 --- a/backend/core/config.py +++ b/backend/core/config.py @@ -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}") diff --git a/backend/core/models/WorldBook.py b/backend/core/models/WorldBook.py index 0808880..0700d3d 100644 --- a/backend/core/models/WorldBook.py +++ b/backend/core/models/WorldBook.py @@ -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}") diff --git a/backend/core/models/WorldItem.py b/backend/core/models/WorldItem.py index 94d7775..0643eb6 100644 --- a/backend/core/models/WorldItem.py +++ b/backend/core/models/WorldItem.py @@ -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 \ No newline at end of file diff --git a/backend/core/models/chat_history.py b/backend/core/models/chat_history.py index dfa0b5b..57881e0 100644 --- a/backend/core/models/chat_history.py +++ b/backend/core/models/chat_history.py @@ -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]: diff --git a/backend/main.py b/backend/main.py index 884966c..bb49321 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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") # 注册路由 diff --git a/docker-compose.yml b/docker-compose.yml index 8106adf..8971e32 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/frontend-react/src/Store/Slices/LeftTabsSlices/WorldBookSlice.jsx b/frontend-react/src/Store/Slices/LeftTabsSlices/WorldBookSlice.jsx index 836409b..09985c8 100644 --- a/frontend-react/src/Store/Slices/LeftTabsSlices/WorldBookSlice.jsx +++ b/frontend-react/src/Store/Slices/LeftTabsSlices/WorldBookSlice.jsx @@ -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; diff --git a/frontend-react/src/components/SideBarLeft/tab/WorldBook.jsx b/frontend-react/src/components/SideBarLeft/tab/WorldBook.jsx index cec241d..1c6f4d2 100644 --- a/frontend-react/src/components/SideBarLeft/tab/WorldBook.jsx +++ b/frontend-react/src/components/SideBarLeft/tab/WorldBook.jsx @@ -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 (
- {/* 全局世界书区域 */} -
-
-
- 全局世界书 - {globalWorldBooks.length > 0 && ( -
- {globalWorldBooks.map(book => ( - selectWorldBook(book.uid)} - > - {book.name} - { - e.stopPropagation(); - removeGlobalWorldBook(book.uid); - }} - > - ✕ - - - ))} -
- )} -
-
- - {/* 操作按钮组 */} -
- - - - -
-
- - - {/* 世界书选择区域 */} -
-
- - {showWorldBookDropdown && ( -
- {worldBooks.map(book => ( -
{ - selectWorldBook(book.uid); - toggleWorldBookDropdown(false); - }} - > - {book.name} + {/* 全局世界书区域 */} +
+ {/* 全局世界书展示区域 */} +
+
+ 全局世界书 +
+ {globalWorldBooks.length > 0 ? ( +
+ {globalWorldBooks.map(book => ( +
+ {book.name} +
))}
+ ) : ( +
暂无全局世界书
+ )} +
+ + {/* 世界书管理区域 */} +
+
+ 世界书管理 +
+ + {/* 操作按钮组 */} +
+ + + {currentWorldBook && ( + + )} +
+ + {/* 世界书选择区域 */} +
+
+ + {showWorldBookDropdown && ( +
+ {worldBooks.map(book => ( +
{ + // 防止点击复选框时触发选择世界书 + if (e.target.type !== 'checkbox') { + handleSelectWorldBook(book); + } + }} + > + +
+ ))} +
+ )} +
+ {currentWorldBook && ( + + )} +
+ + {/* 条目列表区域 */} + {loading ? ( +
加载中...
+ ) : error ? ( +
{error}
+ ) : currentWorldBook ? ( +
+ + {currentEntries.length > 0 ? ( + currentEntries.map(entry => ( +
handleEntryClick(entry)} + > +
+ + {entry.comment || `条目 #${entry.uid}`} + + + {!entry.disable ? '启用' : '禁用'} + +
+
+ 策略: {entry.trigger_strategy} + 位置: {entry.position} + 顺序: {entry.order} +
+
+ )) + ) : ( +
暂无条目
+ )} + +
+ ) : ( +
请选择一个世界书
)}
- {selectedWorldBook && ( - - )}
- {/* 条目列表区域 */} - {isLoading ? ( -
加载中...
- ) : error ? ( -
{error}
- ) : selectedWorldBook ? ( -
- {isSelecting ? ( -
加载条目中...
- ) : selectedWorldBook.entries && selectedWorldBook.entries.length > 0 ? ( - selectedWorldBook.entries.map(entry => ( -
handleEntryClick(entry)} - > -
- {entry.name} - - {entry.enabled ? '启用' : '禁用'} - -
-
- 策略: {entry.triggerStrategy} - 位置: {entry.insertPosition} - 顺序: {entry.order} -
-
- )) - ) : ( -
暂无条目
- )} - -
- ) : ( -
请选择一个世界书
- )} - {/* 编辑面板 */} - {showEditPanel && editingEntry && ( + {showEditPanel && currentEntry && (

编辑条目

-
+
+ + { + 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); + }} + /> +
+ +
+ + { + 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); + }} + /> +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
- + +
+ + {currentEntry.useProbability && ( +
+ + handleEntryUpdate('probability', parseInt(e.target.value))} + /> +
+ )} + +
+ handleEntryUpdate('name', e.target.value)} + value={currentEntry.group || ''} + onChange={(e) => handleEntryUpdate('group', e.target.value)} />
- -