Compare commits
10 Commits
33188a345e
...
feature/ll
| Author | SHA1 | Date | |
|---|---|---|---|
| dd17206e1f | |||
| 4f9cf4b725 | |||
| e8dedb5ec4 | |||
| 7a62139683 | |||
| 01ca2bd0f9 | |||
| 1fc0c43689 | |||
| 1abfaeda9d | |||
| 0ae53c4b81 | |||
| f90ad8dc13 | |||
| 6375f9759c |
@@ -1,20 +1,16 @@
|
||||
from fastapi import APIRouter
|
||||
from ..core.items import ChatRequest
|
||||
from ..tools.get_all_role_and_chat import get_all_role_and_chat
|
||||
from ..core.models.chat_history import ChatHistory # 修改导入语句
|
||||
from .routes import presetsRoute, chatsRoute, worldbooksRoute
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# 1. 从本地读取所有的data内容
|
||||
# 注册子路由
|
||||
router.include_router(presetsRoute.router)
|
||||
router.include_router(chatsRoute.router)
|
||||
router.include_router(worldbooksRoute.router)
|
||||
|
||||
|
||||
# 保留原有的其他路由
|
||||
@router.get("/tool_bar/get_all_role_and_chat")
|
||||
def get_all_role_and_chat_endpoint():
|
||||
# 正确调用函数并返回结果
|
||||
from ..tools.get_all_role_and_chat import get_all_role_and_chat
|
||||
return get_all_role_and_chat()
|
||||
|
||||
# 2. 根据rolename和chatname读取特定聊天记录
|
||||
@router.get("/chat_box/get_chat_history")
|
||||
async def get_chat_history_endpoint(role_name: str, chat_name: str):
|
||||
# 实例化工具类
|
||||
reader = ChatHistory.load_from_file(role_name, chat_name)
|
||||
|
||||
return reader.to_chatbox_format()
|
||||
|
||||
97
backend/api/routes/chatsRoute.py
Normal file
97
backend/api/routes/chatsRoute.py
Normal file
@@ -0,0 +1,97 @@
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from backend.core.models.chat_history import ChatHistory, Message
|
||||
|
||||
router = APIRouter(prefix="/chat", tags=["chat"])
|
||||
|
||||
|
||||
# ========== 聊天历史基础路由 ==========
|
||||
|
||||
@router.get("", response_model=dict)
|
||||
async def list_all_chats():
|
||||
"""获取所有角色的所有聊天列表"""
|
||||
return await ChatHistory.list_all_chats()
|
||||
|
||||
|
||||
@router.get("/{role_name}/{chat_name}")
|
||||
async def get_chat(role_name: str, chat_name: str):
|
||||
"""获取指定聊天的完整内容"""
|
||||
try:
|
||||
return await ChatHistory.get_chat(role_name, chat_name)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail="Chat not found")
|
||||
|
||||
|
||||
@router.post("/{role_name}", status_code=status.HTTP_201_CREATED)
|
||||
async def create_chat(role_name: str, chat_name: str, metadata: dict = None):
|
||||
"""创建新聊天"""
|
||||
try:
|
||||
return await ChatHistory.create_chat(role_name, chat_name, metadata)
|
||||
except FileExistsError:
|
||||
raise HTTPException(status_code=400, detail="Chat already exists")
|
||||
|
||||
|
||||
@router.put("/{role_name}/{chat_name}")
|
||||
async def update_chat(role_name: str, chat_name: str, update_data: dict):
|
||||
"""更新聊天元数据"""
|
||||
try:
|
||||
return await ChatHistory.update_chat(role_name, chat_name, update_data)
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Chat not found")
|
||||
|
||||
|
||||
@router.delete("/{role_name}/{chat_name}")
|
||||
async def delete_chat(role_name: str, chat_name: str):
|
||||
"""删除指定聊天"""
|
||||
try:
|
||||
return await ChatHistory.delete_chat(role_name, chat_name)
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Chat not found")
|
||||
|
||||
|
||||
# ========== 聊天消息路由 ==========
|
||||
|
||||
@router.get("/{role_name}/{chat_name}/messages")
|
||||
async def list_messages(role_name: str, chat_name: str):
|
||||
"""获取聊天的所有消息"""
|
||||
try:
|
||||
return await ChatHistory.list_messages(role_name, chat_name)
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Chat not found")
|
||||
|
||||
|
||||
@router.get("/{role_name}/{chat_name}/messages/{floor}")
|
||||
async def get_message(role_name: str, chat_name: str, floor: int):
|
||||
"""获取指定楼层的消息"""
|
||||
try:
|
||||
return await ChatHistory.get_message(role_name, chat_name, floor)
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Chat not found")
|
||||
|
||||
|
||||
@router.post("/{role_name}/{chat_name}/messages", status_code=status.HTTP_201_CREATED)
|
||||
async def add_message(role_name: str, chat_name: str, message_data: dict):
|
||||
"""向聊天添加新消息"""
|
||||
try:
|
||||
return await ChatHistory.add_message(role_name, chat_name, message_data)
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Chat not found")
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.put("/{role_name}/{chat_name}/messages/{floor}")
|
||||
async def update_message(role_name: str, chat_name: str, floor: int, update_data: dict):
|
||||
"""更新指定楼层的消息"""
|
||||
try:
|
||||
return await ChatHistory.update_message(role_name, chat_name, floor, update_data)
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Chat not found")
|
||||
|
||||
|
||||
@router.delete("/{role_name}/{chat_name}/messages/{floor}")
|
||||
async def delete_message(role_name: str, chat_name: str, floor: int):
|
||||
"""删除指定楼层的消息"""
|
||||
try:
|
||||
return await ChatHistory.delete_message(role_name, chat_name, floor)
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Chat not found")
|
||||
98
backend/api/routes/presetsRoute.py
Normal file
98
backend/api/routes/presetsRoute.py
Normal file
@@ -0,0 +1,98 @@
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from backend.core.models.PromptList import AIDesignSpec
|
||||
from backend.core.models.PromptComponent import PromptComponent
|
||||
|
||||
router = APIRouter(prefix="/presets", tags=["presets"])
|
||||
|
||||
|
||||
# ========== 预设基础路由 ==========
|
||||
|
||||
@router.get("", response_model=dict)
|
||||
async def list_presets():
|
||||
"""获取所有预设列表及其基本信息"""
|
||||
return await AIDesignSpec.list_all_presets()
|
||||
|
||||
|
||||
@router.get("/{preset_name}")
|
||||
async def get_preset(preset_name: str):
|
||||
"""获取指定预设的完整内容"""
|
||||
try:
|
||||
return await AIDesignSpec.get_preset(preset_name)
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Preset not found")
|
||||
|
||||
|
||||
@router.post("", status_code=status.HTTP_201_CREATED)
|
||||
async def create_preset(preset_name: str, preset_data: dict):
|
||||
"""创建新预设"""
|
||||
try:
|
||||
return await AIDesignSpec.create_preset(preset_name, preset_data)
|
||||
except FileExistsError:
|
||||
raise HTTPException(status_code=400, detail="Preset already exists")
|
||||
|
||||
|
||||
@router.put("/{preset_name}")
|
||||
async def update_preset(preset_name: str, update_data: dict):
|
||||
"""更新预设配置"""
|
||||
try:
|
||||
return await AIDesignSpec.update_preset(preset_name, update_data)
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Preset not found")
|
||||
|
||||
|
||||
@router.delete("/{preset_name}")
|
||||
async def delete_preset(preset_name: str):
|
||||
"""删除指定预设"""
|
||||
try:
|
||||
return await AIDesignSpec.delete_preset(preset_name)
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Preset not found")
|
||||
|
||||
|
||||
# ========== 预设组件路由 ==========
|
||||
|
||||
@router.get("/{preset_name}/components")
|
||||
async def list_preset_components(preset_name: str):
|
||||
"""获取预设中的所有组件"""
|
||||
try:
|
||||
return await AIDesignSpec.list_components(preset_name)
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Preset not found")
|
||||
|
||||
|
||||
@router.get("/{preset_name}/components/{component_id}")
|
||||
async def get_preset_component(preset_name: str, component_id: str):
|
||||
"""获取指定组件的详情"""
|
||||
try:
|
||||
return await AIDesignSpec.get_component(preset_name, component_id)
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Preset not found")
|
||||
|
||||
|
||||
@router.post("/{preset_name}/components", status_code=status.HTTP_201_CREATED)
|
||||
async def add_preset_component(preset_name: str, component_data: dict):
|
||||
"""向预设添加新组件"""
|
||||
try:
|
||||
return await AIDesignSpec.add_component_to_preset(preset_name, component_data)
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Preset not found")
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.put("/{preset_name}/components/{component_id}")
|
||||
async def update_preset_component(preset_name: str, component_id: str, update_data: dict):
|
||||
"""更新指定组件"""
|
||||
try:
|
||||
return await AIDesignSpec.update_component_in_preset(preset_name, component_id, update_data)
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Preset not found")
|
||||
|
||||
|
||||
@router.delete("/{preset_name}/components/{component_id}")
|
||||
async def delete_preset_component(preset_name: str, component_id: str):
|
||||
"""从预设中删除指定组件"""
|
||||
try:
|
||||
return await AIDesignSpec.delete_component_from_preset(preset_name, component_id)
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Preset not found")
|
||||
565
backend/api/routes/worldbooksRoute.py
Normal file
565
backend/api/routes/worldbooksRoute.py
Normal file
@@ -0,0 +1,565 @@
|
||||
# 标准库导入
|
||||
import os
|
||||
import shutil
|
||||
import logging
|
||||
from pathlib import Path
|
||||
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 (
|
||||
WorldItem,
|
||||
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=List[Dict[str, Any]])
|
||||
async def list_worldbooks():
|
||||
"""
|
||||
获取所有世界书的列表
|
||||
Returns:
|
||||
List[Dict[str, Any]]: 世界书列表
|
||||
"""
|
||||
try:
|
||||
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:
|
||||
logger.error(f"获取世界书列表失败: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"获取世界书列表失败: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/{name}", response_model=Dict[str, Any])
|
||||
async def get_worldbook(name: str):
|
||||
"""
|
||||
获取指定名称的世界书
|
||||
|
||||
Args:
|
||||
name: 世界书名称
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 世界书数据
|
||||
"""
|
||||
try:
|
||||
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:
|
||||
logger.error(f"获取世界书 {name} 失败: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"获取世界书失败: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/", response_model=Dict[str, Any])
|
||||
async def create_worldbook(
|
||||
name: str = Form(...),
|
||||
file: Optional[UploadFile] = File(None)
|
||||
):
|
||||
"""
|
||||
创建新世界书
|
||||
|
||||
Args:
|
||||
name: 世界书名称
|
||||
description: 世界书描述
|
||||
file: 可选的上传文件(SillyTavern 格式)
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 创建的世界书数据
|
||||
"""
|
||||
try:
|
||||
# 如果上传了文件,从文件导入
|
||||
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.save()
|
||||
logger.info(f"从文件创建世界书: {name}")
|
||||
finally:
|
||||
# 删除临时文件
|
||||
if os.path.exists(temp_path):
|
||||
os.remove(temp_path)
|
||||
else:
|
||||
# 创建空世界书
|
||||
world_book = WorldBook.create_empty(name)
|
||||
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.put("/{name}", response_model=Dict[str, Any])
|
||||
async def update_worldbook(
|
||||
name: str,
|
||||
file: Optional[UploadFile] = File(None)
|
||||
):
|
||||
"""
|
||||
更新世界书
|
||||
|
||||
Args:
|
||||
name: 世界书名称
|
||||
description: 世界书描述(可选)
|
||||
file: 可选的上传文件(SillyTavern 格式)
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 更新后的世界书数据
|
||||
"""
|
||||
try:
|
||||
# 检查世界书是否存在
|
||||
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)
|
||||
|
||||
# 保存世界书
|
||||
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.to_dict()
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"获取世界书 {name} 的条目 {uid} 失败: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"获取世界书条目失败: {str(e)}")
|
||||
|
||||
|
||||
@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:
|
||||
# 检查世界书是否存在
|
||||
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 = WorldItem.Entry(**entry_data)
|
||||
|
||||
# 添加条目
|
||||
world_book.add_entry(entry)
|
||||
|
||||
# 保存世界书
|
||||
world_book.save()
|
||||
|
||||
logger.info(f"在世界书 {name} 中创建条目: UID={entry.uid}")
|
||||
return entry.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.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:
|
||||
# 检查世界书是否存在
|
||||
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
|
||||
|
||||
valid_fields = WorldItem.Entry.model_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.to_dict()
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"更新世界书 {name} 的条目 {uid} 失败: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"更新世界书条目失败: {str(e)}")
|
||||
|
||||
|
||||
@router.delete("/{name}/entries/{uid}")
|
||||
async def delete_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)
|
||||
|
||||
# 删除条目
|
||||
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:
|
||||
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,11 +3,13 @@ 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")
|
||||
@@ -27,22 +29,51 @@ class Settings:
|
||||
BASE_PATH = PROJECT_ROOT
|
||||
|
||||
# 数据目录:固定为根目录下的 data 文件夹
|
||||
# 即使 .env 里写了 DATA_PATH=/data,这里也会强制指向项目根目录下的 data
|
||||
DATA_PATH = BASE_PATH / "data"
|
||||
|
||||
# 其他文件路径:基于 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"
|
||||
|
||||
# --- 业务数据目录 ---
|
||||
|
||||
# 世界书目录
|
||||
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)
|
||||
|
||||
|
||||
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'}")
|
||||
|
||||
|
||||
print(f"世界书目录: {settings.WORLDBOOKS_PATH}")
|
||||
print(f"预设目录: {settings.PRESETS_PATH}")
|
||||
print(f"聊天目录: {settings.CHAT_PATH}")
|
||||
|
||||
65
backend/core/models/PromptComponent.py
Normal file
65
backend/core/models/PromptComponent.py
Normal file
@@ -0,0 +1,65 @@
|
||||
from pydantic import BaseModel, Field, validator
|
||||
from typing import Dict, Any
|
||||
|
||||
|
||||
class PromptComponent(BaseModel):
|
||||
"""预设组件类,代表一个独立的提示词模块"""
|
||||
|
||||
identifier: str = Field(..., description="唯一标识符,用于引用和定位组件")
|
||||
name: str = Field(..., description="组件显示名称")
|
||||
content: str = Field("", description="组件内容文本")
|
||||
# 0:System,1:User,2:Assistant
|
||||
role: int = Field(0, description="角色身份(0:System,1:User,2:Assistant)")
|
||||
system_prompt: bool = Field(False, description="是否强制作为系统提示词处理")
|
||||
marker: bool = Field(False, description="是否为动态插入点占位符")
|
||||
|
||||
@validator('role')
|
||||
def validate_role(cls, v):
|
||||
"""验证角色值是否在有效范围内"""
|
||||
if not isinstance(v, int) or v not in [0, 1, 2]:
|
||||
raise ValueError("角色值必须是0(System)、1(User)或2(Assistant)")
|
||||
return v
|
||||
|
||||
def update(self, **kwargs) -> None:
|
||||
"""
|
||||
更新组件属性
|
||||
|
||||
参数:
|
||||
**kwargs: 要更新的字段和值
|
||||
|
||||
异常:
|
||||
ValueError: 当尝试更新identifier时抛出
|
||||
"""
|
||||
if 'identifier' in kwargs:
|
||||
raise ValueError("组件标识符不可修改")
|
||||
|
||||
for key, value in kwargs.items():
|
||||
if hasattr(self, key):
|
||||
setattr(self, key, value)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""
|
||||
将组件转换为字典
|
||||
|
||||
返回:
|
||||
Dict[str, Any]: 组件的字典表示
|
||||
"""
|
||||
return self.dict()
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'PromptComponent':
|
||||
"""
|
||||
从字典创建组件实例,自动处理role字段的类型转换
|
||||
|
||||
参数:
|
||||
data: 包含组件数据的字典
|
||||
|
||||
返回:
|
||||
PromptComponent: 组件实例
|
||||
"""
|
||||
# 处理role字段,将字符串转换为整数
|
||||
if 'role' in data and isinstance(data['role'], str):
|
||||
role_map = {'system': 0, 'user': 1, 'assistant': 2}
|
||||
data['role'] = role_map.get(data['role'].lower(), 0)
|
||||
|
||||
return cls(**data)
|
||||
591
backend/core/models/PromptList.py
Normal file
591
backend/core/models/PromptList.py
Normal file
@@ -0,0 +1,591 @@
|
||||
from pydantic import BaseModel, Field, validator
|
||||
from typing import List, Dict, Any, Optional
|
||||
from pathlib import Path
|
||||
import json
|
||||
from .PromptComponent import PromptComponent
|
||||
|
||||
|
||||
class AIDesignSpec(BaseModel):
|
||||
"""AI设计规范类,包含模型生成的核心参数和动态结构配置"""
|
||||
|
||||
# [Base] 基础核心参数
|
||||
temperature: float = Field(1.0, description="生成温度,控制随机性(0-2)")
|
||||
frequency_penalty: float = Field(0.0, description="频率惩罚,降低重复token概率")
|
||||
presence_penalty: float = Field(0.0, description="存在惩罚,鼓励谈论新话题")
|
||||
top_p: float = Field(1.0, description="核采样,控制词汇选择范围")
|
||||
top_k: int = Field(0, description="随机采样范围,从概率最高的K个词中选择")
|
||||
top_a: float = Field(0.0, description="基于平方概率分布的采样")
|
||||
min_p: float = Field(0.0, description="最小概率阈值")
|
||||
repetition_penalty: float = Field(1.0, description="重复惩罚系数(1.0-1.2)")
|
||||
max_context: int = Field(2048, description="上下文窗口大小(Token上限)")
|
||||
max_tokens: int = Field(250, description="单次回复的最大长度")
|
||||
max_context_unlocked: bool = Field(False, description="是否允许超出限制的上下文")
|
||||
names_behavior: int = Field(0, description="名字处理行为(0=默认,1=始终包含,2=仅角色)")
|
||||
send_if_empty: str = Field("", description="用户发送空消息时自动填充的内容")
|
||||
impersonation_prompt: str = Field("", description="模仿模式下使用的提示词")
|
||||
new_chat_prompt: str = Field("", description="开启新聊天时自动发送的系统提示")
|
||||
new_group_chat_prompt: str = Field("", description="开启新群组聊天时的提示")
|
||||
new_example_chat_prompt: str = Field("", description="新示例聊天的提示")
|
||||
continue_nudge_prompt: str = Field("", description="续写功能触发的提示词")
|
||||
bias_preset_selected: str = Field("", description="选用的偏见预设")
|
||||
wi_format: str = Field("{0}", description="世界书条目的格式化字符串")
|
||||
scenario_format: str = Field("{{scenario}}", description="场景描述的格式化字符串")
|
||||
personality_format: str = Field("", description="角色性格的格式化字符串")
|
||||
group_nudge_prompt: str = Field("", description="群组聊天中提示AI仅以特定角色回复的提示词")
|
||||
stream: bool = Field(True, description="是否使用流式输出")
|
||||
assistant_prefill: str = Field("", description="强制AI回复的开头内容")
|
||||
assistant_impersonation: str = Field("", description="模仿模式下强制AI回复的开头内容")
|
||||
use_sysprompt: bool = Field(True, description="是否强制将提示词注入系统层")
|
||||
squash_system_messages: bool = Field(False, description="是否压缩系统消息")
|
||||
media_inlining: bool = Field(False, description="是否内联媒体描述")
|
||||
continue_prefill: bool = Field(True, description="续写时是否预填充内容")
|
||||
continue_postfix: str = Field(" ", description="续写时添加的后缀")
|
||||
seed: int = Field(-1, description="随机种子(-1为随机)")
|
||||
n: int = Field(1, description="生成回复的数量")
|
||||
|
||||
# [Dynamic] 动态结构
|
||||
prompts: List[PromptComponent] = Field(
|
||||
default_factory=list,
|
||||
description="组件库,定义所有可用的积木块"
|
||||
)
|
||||
prompt_order: List[str] = Field(
|
||||
default_factory=list,
|
||||
description="组装说明书,定义构建最终提示词的顺序"
|
||||
)
|
||||
|
||||
@validator('prompts')
|
||||
def validate_prompts_unique_identifier(cls, v):
|
||||
"""验证组件标识符唯一性"""
|
||||
identifiers = [comp.identifier for comp in v]
|
||||
if len(identifiers) != len(set(identifiers)):
|
||||
raise ValueError("组件标识符必须唯一")
|
||||
return v
|
||||
|
||||
@validator('prompt_order')
|
||||
def validate_prompt_order_exists(cls, v, values):
|
||||
"""验证prompt_order中的组件ID是否存在于prompts中"""
|
||||
if 'prompts' in values:
|
||||
prompt_ids = {comp.identifier for comp in values['prompts']}
|
||||
invalid_ids = set(v) - prompt_ids
|
||||
if invalid_ids:
|
||||
raise ValueError(f"prompt_order中包含不存在的组件ID: {invalid_ids}")
|
||||
return v
|
||||
|
||||
@classmethod
|
||||
def get_preset_dir(cls) -> Path:
|
||||
"""获取预设目录路径"""
|
||||
try:
|
||||
from backend.core.config import settings
|
||||
preset_dir = settings.DATA_PATH / "preset"
|
||||
# 如果路径不存在,尝试使用相对路径
|
||||
if not preset_dir.exists():
|
||||
# 尝试从当前工作目录构建路径
|
||||
cwd_preset_dir = Path.cwd() / "data" / "preset"
|
||||
if cwd_preset_dir.exists():
|
||||
return cwd_preset_dir
|
||||
# 尝试从脚本所在目录构建路径
|
||||
script_dir = Path(__file__).resolve().parent.parent.parent
|
||||
script_preset_dir = script_dir / "data" / "preset"
|
||||
if script_preset_dir.exists():
|
||||
return script_preset_dir
|
||||
# 如果都不存在,返回默认路径
|
||||
return Path("data/preset")
|
||||
return preset_dir
|
||||
except ImportError:
|
||||
# 如果无法导入settings,尝试使用相对路径
|
||||
cwd_preset_dir = Path.cwd() / "data" / "preset"
|
||||
if cwd_preset_dir.exists():
|
||||
return cwd_preset_dir
|
||||
# 尝试从脚本所在目录构建路径
|
||||
script_dir = Path(__file__).resolve().parent.parent.parent
|
||||
script_preset_dir = script_dir / "data" / "preset"
|
||||
if script_preset_dir.exists():
|
||||
return script_preset_dir
|
||||
# 如果都不存在,返回默认路径
|
||||
return Path("data/preset")
|
||||
|
||||
@classmethod
|
||||
async def list_all_presets(cls) -> Dict[str, List[Dict]]:
|
||||
"""获取所有预设列表及其基本信息"""
|
||||
preset_dir = cls.get_preset_dir()
|
||||
if not preset_dir.exists():
|
||||
return {"presets": []}
|
||||
|
||||
presets = []
|
||||
for preset_file in preset_dir.glob("*.json"):
|
||||
try:
|
||||
with open(preset_file, 'r', encoding='utf-8') as f:
|
||||
preset_data = json.load(f)
|
||||
presets.append({
|
||||
"name": preset_file.stem,
|
||||
"description": preset_data.get("description", ""),
|
||||
"component_count": len(preset_data.get("prompts", [])),
|
||||
"temperature": preset_data.get("temperature", 1.0)
|
||||
})
|
||||
except Exception:
|
||||
continue # 跳过损坏的预设文件
|
||||
return {"presets": presets}
|
||||
|
||||
@classmethod
|
||||
async def get_preset(cls, preset_name: str) -> Dict[str, Any]:
|
||||
"""获取指定预设的完整内容"""
|
||||
preset_path = cls.get_preset_dir() / f"{preset_name}.json"
|
||||
if not preset_path.exists():
|
||||
raise FileNotFoundError(f"Preset not found: {preset_name}")
|
||||
|
||||
try:
|
||||
with open(preset_path, 'r', encoding='utf-8') as f:
|
||||
preset_data = json.load(f)
|
||||
|
||||
# 处理prompt_order,简化为单角色配置
|
||||
if 'prompt_order' in preset_data and isinstance(preset_data['prompt_order'], list) and len(
|
||||
preset_data['prompt_order']) > 0:
|
||||
# 检查第一个元素是否为字典(多角色配置)
|
||||
first_item = preset_data['prompt_order'][0]
|
||||
if isinstance(first_item, dict) and 'order' in first_item:
|
||||
# 提取第一个角色的order配置
|
||||
first_role_order = first_item
|
||||
if isinstance(first_role_order['order'], list):
|
||||
# 简化为只包含enabled为True的identifier列表
|
||||
simplified_order = [
|
||||
item.get('identifier')
|
||||
for item in first_role_order['order']
|
||||
if item.get('enabled', True)
|
||||
]
|
||||
preset_data['prompt_order'] = simplified_order
|
||||
|
||||
# 转换为AIDesignSpec对象进行验证
|
||||
ai_design_spec = cls.from_dict(preset_data)
|
||||
|
||||
# 构建返回数据,确保格式与前端期望的一致
|
||||
result = {
|
||||
# 基础参数
|
||||
"temperature": ai_design_spec.temperature,
|
||||
"frequency_penalty": ai_design_spec.frequency_penalty,
|
||||
"presence_penalty": ai_design_spec.presence_penalty,
|
||||
"top_p": ai_design_spec.top_p,
|
||||
"top_k": ai_design_spec.top_k,
|
||||
"max_context": ai_design_spec.max_context,
|
||||
"max_tokens": ai_design_spec.max_tokens,
|
||||
"max_context_unlocked": ai_design_spec.max_context_unlocked,
|
||||
"stream_openai": ai_design_spec.stream,
|
||||
"seed": ai_design_spec.seed,
|
||||
"n": ai_design_spec.n,
|
||||
|
||||
# 兼容旧格式
|
||||
"openai_max_context": ai_design_spec.max_context,
|
||||
"openai_max_tokens": ai_design_spec.max_tokens,
|
||||
|
||||
# 其他参数
|
||||
"top_a": ai_design_spec.top_a,
|
||||
"min_p": ai_design_spec.min_p,
|
||||
"repetition_penalty": ai_design_spec.repetition_penalty,
|
||||
"names_behavior": ai_design_spec.names_behavior,
|
||||
"send_if_empty": ai_design_spec.send_if_empty,
|
||||
"impersonation_prompt": ai_design_spec.impersonation_prompt,
|
||||
"new_chat_prompt": ai_design_spec.new_chat_prompt,
|
||||
"new_group_chat_prompt": ai_design_spec.new_group_chat_prompt,
|
||||
"new_example_chat_prompt": ai_design_spec.new_example_chat_prompt,
|
||||
"continue_nudge_prompt": ai_design_spec.continue_nudge_prompt,
|
||||
"bias_preset_selected": ai_design_spec.bias_preset_selected,
|
||||
"wi_format": ai_design_spec.wi_format,
|
||||
"scenario_format": ai_design_spec.scenario_format,
|
||||
"personality_format": ai_design_spec.personality_format,
|
||||
"group_nudge_prompt": ai_design_spec.group_nudge_prompt,
|
||||
"assistant_prefill": ai_design_spec.assistant_prefill,
|
||||
"assistant_impersonation": ai_design_spec.assistant_impersonation,
|
||||
"use_sysprompt": ai_design_spec.use_sysprompt,
|
||||
"squash_system_messages": ai_design_spec.squash_system_messages,
|
||||
"media_inlining": ai_design_spec.media_inlining,
|
||||
"continue_prefill": ai_design_spec.continue_prefill,
|
||||
"continue_postfix": ai_design_spec.continue_postfix,
|
||||
|
||||
# 处理组件
|
||||
"prompts": []
|
||||
}
|
||||
|
||||
# 处理组件列表
|
||||
if ai_design_spec.prompts:
|
||||
# 获取当前角色的prompt_order(简化后的字符串列表)
|
||||
current_order = ai_design_spec.prompt_order if ai_design_spec.prompt_order else []
|
||||
|
||||
# 构建组件列表
|
||||
for prompt in ai_design_spec.prompts:
|
||||
# 检查组件是否在order中
|
||||
is_in_order = prompt.identifier in current_order
|
||||
|
||||
# 构建组件对象
|
||||
component = {
|
||||
"identifier": prompt.identifier,
|
||||
"name": prompt.name,
|
||||
"content": prompt.content if hasattr(prompt, 'content') else "",
|
||||
"role": prompt.role if hasattr(prompt, 'role') else (0 if prompt.system_prompt else 1),
|
||||
"system_prompt": prompt.system_prompt,
|
||||
"marker": prompt.marker,
|
||||
"enabled": is_in_order if current_order else True
|
||||
}
|
||||
|
||||
result["prompts"].append(component)
|
||||
|
||||
# 按照order排序组件
|
||||
if current_order:
|
||||
result["prompts"].sort(
|
||||
key=lambda x: current_order.index(x["identifier"]) if x[
|
||||
"identifier"] in current_order else len(
|
||||
current_order))
|
||||
|
||||
# 添加prompt_order
|
||||
result["prompt_order"] = ai_design_spec.prompt_order if ai_design_spec.prompt_order else []
|
||||
|
||||
return result
|
||||
except Exception as e:
|
||||
raise Exception(f"Failed to load preset: {str(e)}")
|
||||
|
||||
@classmethod
|
||||
async def create_preset(cls, preset_name: str, preset_data: Dict) -> Dict[str, str]:
|
||||
"""创建新预设"""
|
||||
preset_dir = cls.get_preset_dir()
|
||||
preset_dir.mkdir(parents=True, exist_ok=True)
|
||||
preset_path = preset_dir / f"{preset_name}.json"
|
||||
|
||||
if preset_path.exists():
|
||||
raise FileExistsError(f"Preset already exists: {preset_name}")
|
||||
|
||||
try:
|
||||
# 验证并转换为AIDesignSpec对象
|
||||
ai_design_spec = cls.from_dict(preset_data)
|
||||
|
||||
# 保存到文件
|
||||
with open(preset_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(ai_design_spec.dict(), f, ensure_ascii=False, indent=2)
|
||||
|
||||
return {"message": "Preset created successfully", "name": preset_name}
|
||||
except Exception as e:
|
||||
raise Exception(f"Failed to create preset: {str(e)}")
|
||||
|
||||
@classmethod
|
||||
async def update_preset(cls, preset_name: str, update_data: Dict) -> Dict[str, str]:
|
||||
"""更新预设配置"""
|
||||
preset_path = cls.get_preset_dir() / f"{preset_name}.json"
|
||||
if not preset_path.exists():
|
||||
raise FileNotFoundError(f"Preset not found: {preset_name}")
|
||||
|
||||
try:
|
||||
# 加载现有预设
|
||||
with open(preset_path, 'r', encoding='utf-8') as f:
|
||||
preset_data = json.load(f)
|
||||
|
||||
# 更新字段
|
||||
for key, value in update_data.items():
|
||||
preset_data[key] = value
|
||||
|
||||
# 验证并转换为AIDesignSpec对象
|
||||
ai_design_spec = cls.from_dict(preset_data)
|
||||
|
||||
# 保存更新
|
||||
with open(preset_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(ai_design_spec.dict(), f, ensure_ascii=False, indent=2)
|
||||
|
||||
return {"message": "Preset updated successfully"}
|
||||
except Exception as e:
|
||||
raise Exception(f"Failed to update preset: {str(e)}")
|
||||
|
||||
@classmethod
|
||||
async def delete_preset(cls, preset_name: str) -> Dict[str, str]:
|
||||
"""删除指定预设"""
|
||||
preset_path = cls.get_preset_dir() / f"{preset_name}.json"
|
||||
if not preset_path.exists():
|
||||
raise FileNotFoundError(f"Preset not found: {preset_name}")
|
||||
|
||||
try:
|
||||
preset_path.unlink()
|
||||
return {"message": "Preset deleted successfully"}
|
||||
except Exception as e:
|
||||
raise Exception(f"Failed to delete preset: {str(e)}")
|
||||
|
||||
@classmethod
|
||||
async def list_components(cls, preset_name: str) -> Dict[str, List[Dict]]:
|
||||
"""获取预设中的所有组件"""
|
||||
preset_path = cls.get_preset_dir() / f"{preset_name}.json"
|
||||
if not preset_path.exists():
|
||||
raise FileNotFoundError(f"Preset not found: {preset_name}")
|
||||
|
||||
try:
|
||||
with open(preset_path, 'r', encoding='utf-8') as f:
|
||||
preset_data = json.load(f)
|
||||
|
||||
# 获取组件列表
|
||||
components = preset_data.get("prompts", [])
|
||||
return {"components": components}
|
||||
except Exception as e:
|
||||
raise Exception(f"Failed to load components: {str(e)}")
|
||||
|
||||
@classmethod
|
||||
async def get_component(cls, preset_name: str, component_id: str) -> Dict[str, Any]:
|
||||
"""获取指定组件的详情"""
|
||||
preset_path = cls.get_preset_dir() / f"{preset_name}.json"
|
||||
if not preset_path.exists():
|
||||
raise FileNotFoundError(f"Preset not found: {preset_name}")
|
||||
|
||||
try:
|
||||
with open(preset_path, 'r', encoding='utf-8') as f:
|
||||
preset_data = json.load(f)
|
||||
|
||||
# 查找组件
|
||||
components = preset_data.get("prompts", [])
|
||||
component = next((c for c in components if c.get("identifier") == component_id), None)
|
||||
|
||||
if not component:
|
||||
raise FileNotFoundError(f"Component not found: {component_id}")
|
||||
|
||||
return component
|
||||
except FileNotFoundError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise Exception(f"Failed to load component: {str(e)}")
|
||||
|
||||
@classmethod
|
||||
async def add_component_to_preset(cls, preset_name: str, component_data: Dict) -> Dict[str, str]:
|
||||
"""向预设添加新组件"""
|
||||
preset_path = cls.get_preset_dir() / f"{preset_name}.json"
|
||||
if not preset_path.exists():
|
||||
raise FileNotFoundError(f"Preset not found: {preset_name}")
|
||||
|
||||
try:
|
||||
# 加载预设数据
|
||||
with open(preset_path, 'r', encoding='utf-8') as f:
|
||||
preset_data = json.load(f)
|
||||
|
||||
# 验证组件数据
|
||||
component = PromptComponent(**component_data)
|
||||
|
||||
# 检查组件ID是否已存在
|
||||
components = preset_data.get("prompts", [])
|
||||
if any(c.get("identifier") == component.identifier for c in components):
|
||||
raise ValueError(f"Component identifier already exists: {component.identifier}")
|
||||
|
||||
# 添加组件
|
||||
components.append(component.dict())
|
||||
preset_data["prompts"] = components
|
||||
|
||||
# 保存更新
|
||||
with open(preset_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(preset_data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
return {"message": "Component added successfully", "identifier": component.identifier}
|
||||
except (FileNotFoundError, ValueError):
|
||||
raise
|
||||
except Exception as e:
|
||||
raise Exception(f"Failed to add component: {str(e)}")
|
||||
|
||||
@classmethod
|
||||
async def update_component_in_preset(cls, preset_name: str, component_id: str, update_data: Dict) -> Dict[str, str]:
|
||||
"""更新指定组件"""
|
||||
preset_path = cls.get_preset_dir() / f"{preset_name}.json"
|
||||
if not preset_path.exists():
|
||||
raise FileNotFoundError(f"Preset not found: {preset_name}")
|
||||
|
||||
try:
|
||||
# 加载预设数据
|
||||
with open(preset_path, 'r', encoding='utf-8') as f:
|
||||
preset_data = json.load(f)
|
||||
|
||||
# 查找并更新组件
|
||||
components = preset_data.get("prompts", [])
|
||||
component_index = next((i for i, c in enumerate(components) if c.get("identifier") == component_id), None)
|
||||
|
||||
if component_index is None:
|
||||
raise FileNotFoundError(f"Component not found: {component_id}")
|
||||
|
||||
# 更新组件字段
|
||||
for key, value in update_data.items():
|
||||
components[component_index][key] = value
|
||||
|
||||
# 保存更新
|
||||
with open(preset_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(preset_data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
return {"message": "Component updated successfully"}
|
||||
except FileNotFoundError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise Exception(f"Failed to update component: {str(e)}")
|
||||
|
||||
@classmethod
|
||||
async def delete_component_from_preset(cls, preset_name: str, component_id: str) -> Dict[str, str]:
|
||||
"""从预设中删除指定组件"""
|
||||
preset_path = cls.get_preset_dir() / f"{preset_name}.json"
|
||||
if not preset_path.exists():
|
||||
raise FileNotFoundError(f"Preset not found: {preset_name}")
|
||||
|
||||
try:
|
||||
# 加载预设数据
|
||||
with open(preset_path, 'r', encoding='utf-8') as f:
|
||||
preset_data = json.load(f)
|
||||
|
||||
# 查找并删除组件
|
||||
components = preset_data.get("prompts", [])
|
||||
original_length = len(components)
|
||||
components = [c for c in components if c.get("identifier") != component_id]
|
||||
|
||||
if len(components) == original_length:
|
||||
raise FileNotFoundError(f"Component not found: {component_id}")
|
||||
|
||||
# 更新预设数据
|
||||
preset_data["prompts"] = components
|
||||
|
||||
# 保存更新
|
||||
with open(preset_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(preset_data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
return {"message": "Component deleted successfully"}
|
||||
except FileNotFoundError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise Exception(f"Failed to delete component: {str(e)}")
|
||||
|
||||
# ========== 组件管理方法 ==========
|
||||
|
||||
def add_component(self, component: PromptComponent) -> None:
|
||||
"""
|
||||
添加新组件
|
||||
|
||||
参数:
|
||||
component: 要添加的组件
|
||||
|
||||
异常:
|
||||
ValueError: 当组件标识符已存在时抛出
|
||||
"""
|
||||
if any(c.identifier == component.identifier for c in self.prompts):
|
||||
raise ValueError(f"组件标识符 {component.identifier} 已存在")
|
||||
self.prompts.append(component)
|
||||
|
||||
def remove_component(self, identifier: str) -> bool:
|
||||
"""
|
||||
移除指定组件
|
||||
|
||||
参数:
|
||||
identifier: 组件标识符
|
||||
|
||||
返回:
|
||||
bool: 是否成功移除
|
||||
"""
|
||||
original_length = len(self.prompts)
|
||||
self.prompts = [c for c in self.prompts if c.identifier != identifier]
|
||||
|
||||
# 同时从prompt_order中移除
|
||||
self.prompt_order = [id for id in self.prompt_order if id != identifier]
|
||||
|
||||
return len(self.prompts) < original_length
|
||||
|
||||
def get_component(self, identifier: str) -> Optional[PromptComponent]:
|
||||
"""
|
||||
获取指定组件
|
||||
|
||||
参数:
|
||||
identifier: 组件标识符
|
||||
|
||||
返回:
|
||||
Optional[PromptComponent]: 找到的组件,未找到返回None
|
||||
"""
|
||||
for component in self.prompts:
|
||||
if component.identifier == identifier:
|
||||
return component
|
||||
return None
|
||||
|
||||
def update_component(self, identifier: str, **kwargs) -> bool:
|
||||
"""
|
||||
更新指定组件
|
||||
|
||||
参数:
|
||||
identifier: 组件标识符
|
||||
**kwargs: 要更新的字段
|
||||
|
||||
返回:
|
||||
bool: 是否成功更新
|
||||
"""
|
||||
component = self.get_component(identifier)
|
||||
if component is None:
|
||||
return False
|
||||
|
||||
component.update(**kwargs)
|
||||
return True
|
||||
|
||||
def list_components(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
列出所有组件
|
||||
|
||||
返回:
|
||||
List[Dict[str, Any]]: 组件字典列表
|
||||
"""
|
||||
return [component.to_dict() for component in self.prompts]
|
||||
|
||||
def reorder_components(self, new_order: List[str]) -> None:
|
||||
"""
|
||||
重新排序组件
|
||||
|
||||
参数:
|
||||
new_order: 新的组件标识符顺序
|
||||
|
||||
异常:
|
||||
ValueError: 当包含不存在的组件ID时抛出
|
||||
"""
|
||||
# 验证所有ID都存在
|
||||
existing_ids = {c.identifier for c in self.prompts}
|
||||
invalid_ids = set(new_order) - existing_ids
|
||||
|
||||
if invalid_ids:
|
||||
raise ValueError(f"包含不存在的组件ID: {invalid_ids}")
|
||||
|
||||
self.prompt_order = new_order
|
||||
|
||||
def get_ordered_components(self) -> List[PromptComponent]:
|
||||
"""
|
||||
获取按prompt_order排序的组件列表
|
||||
|
||||
返回:
|
||||
List[PromptComponent]: 排序后的组件列表
|
||||
"""
|
||||
component_map = {c.identifier: c for c in self.prompts}
|
||||
ordered_components = []
|
||||
|
||||
for identifier in self.prompt_order:
|
||||
if identifier in component_map:
|
||||
ordered_components.append(component_map[identifier])
|
||||
|
||||
# 添加未在prompt_order中的组件
|
||||
ordered_components.extend([
|
||||
c for c in self.prompts
|
||||
if c.identifier not in self.prompt_order
|
||||
])
|
||||
|
||||
return ordered_components
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""
|
||||
将设计规范转换为字典
|
||||
|
||||
返回:
|
||||
Dict[str, Any]: 设计规范的字典表示
|
||||
"""
|
||||
return self.dict()
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'AIDesignSpec':
|
||||
"""
|
||||
从字典创建设计规范实例
|
||||
|
||||
参数:
|
||||
data: 包含设计规范数据的字典
|
||||
|
||||
返回:
|
||||
AIDesignSpec: 设计规范实例
|
||||
"""
|
||||
# 处理prompts字段
|
||||
if 'prompts' in data:
|
||||
data['prompts'] = [
|
||||
PromptComponent.from_dict(comp) if isinstance(comp, dict) else comp
|
||||
for comp in data['prompts']
|
||||
]
|
||||
|
||||
return cls(**data)
|
||||
438
backend/core/models/WorldBook.py
Normal file
438
backend/core/models/WorldBook.py
Normal file
@@ -0,0 +1,438 @@
|
||||
import json
|
||||
import os
|
||||
import logging
|
||||
from typing import Dict, List, Optional, Any
|
||||
from pathlib import Path
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from .WorldItem import WorldItem, TriggerStrategy
|
||||
from backend.core.config import settings
|
||||
|
||||
# 配置日志
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WorldBook(BaseModel):
|
||||
"""
|
||||
世界书集合模型
|
||||
管理多个世界书条目,支持导入导出 SillyTavern 格式
|
||||
"""
|
||||
# 世界书基本信息
|
||||
name: str = Field(..., description="世界书名称")
|
||||
|
||||
# 条目集合
|
||||
entries: Dict[str, WorldItem.Entry] = Field(
|
||||
default_factory=dict,
|
||||
description="世界书条目字典对象 (Key-Value Map)"
|
||||
)
|
||||
|
||||
@field_validator('entries')
|
||||
@classmethod
|
||||
def validate_entries_unique_uid(cls, 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: WorldItem.Entry) -> None:
|
||||
"""
|
||||
添加世界书条目
|
||||
|
||||
Args:
|
||||
entry: 世界书条目对象
|
||||
|
||||
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:
|
||||
"""
|
||||
移除世界书条目
|
||||
|
||||
Args:
|
||||
uid: 条目 UID
|
||||
|
||||
Returns:
|
||||
bool: 是否成功移除
|
||||
"""
|
||||
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: int) -> Optional[WorldItem.Entry]:
|
||||
"""
|
||||
获取指定 UID 的世界书条目
|
||||
|
||||
Args:
|
||||
uid: 条目 UID
|
||||
|
||||
Returns:
|
||||
Optional[WorldItem.Entry]: 找到的条目,未找到返回 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: int, **kwargs) -> bool:
|
||||
"""
|
||||
更新世界书条目
|
||||
|
||||
Args:
|
||||
uid: 条目 UID
|
||||
**kwargs: 要更新的字段
|
||||
|
||||
Returns:
|
||||
bool: 是否成功更新
|
||||
"""
|
||||
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: int) -> List[WorldItem.Entry]:
|
||||
"""
|
||||
根据位置筛选条目
|
||||
|
||||
Args:
|
||||
position: 位置值
|
||||
|
||||
Returns:
|
||||
List[WorldItem.Entry]: 筛选后的条目列表
|
||||
"""
|
||||
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]:
|
||||
"""
|
||||
获取世界书概要信息
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 概要信息字典
|
||||
"""
|
||||
summary = {
|
||||
"name": self.name,
|
||||
"entry_count": len(self.entries),
|
||||
"trigger_strategies": {
|
||||
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]:
|
||||
"""
|
||||
生成世界书摘要信息,用于列表显示
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 包含基本信息的字典
|
||||
"""
|
||||
summary = {
|
||||
"name": self.name,
|
||||
"entry_count": len(self.entries),
|
||||
}
|
||||
logger.debug(f"生成世界书 {self.name} 的摘要信息")
|
||||
return summary
|
||||
|
||||
def to_dict(self) -> Dict:
|
||||
"""
|
||||
将 WorldBook 转换为字典
|
||||
|
||||
Returns:
|
||||
Dict: 世界书数据字典
|
||||
"""
|
||||
result = {
|
||||
'name': self.name,
|
||||
'entries': {uid: entry.model_dump() for uid, entry in self.entries.items()}
|
||||
}
|
||||
logger.debug(f"将世界书 {self.name} 转换为字典")
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def load(cls, name: str) -> 'WorldBook':
|
||||
"""
|
||||
从文件加载世界书(只有 entries 字段的格式)
|
||||
|
||||
Args:
|
||||
name: 世界书名称
|
||||
|
||||
Returns:
|
||||
WorldBook: 世界书对象
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: 文件不存在
|
||||
ValueError: 格式不符合标准
|
||||
json.JSONDecodeError: JSON 解析错误
|
||||
"""
|
||||
file_path = cls.get_file_path(name)
|
||||
if not os.path.exists(file_path):
|
||||
error_msg = f"世界书文件未找到: {file_path}"
|
||||
logger.error(error_msg)
|
||||
raise FileNotFoundError(error_msg)
|
||||
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
raw_data = json.load(f)
|
||||
|
||||
# 世界书名称始终使用文件名(不包括后缀名)
|
||||
world_name = name
|
||||
|
||||
# 直接使用 entries 字段
|
||||
entries_dict = raw_data.get("entries", {})
|
||||
if not isinstance(entries_dict, dict):
|
||||
error_msg = "无效的世界书格式:'entries' 字段必须是一个字典。"
|
||||
logger.error(error_msg)
|
||||
raise ValueError(error_msg)
|
||||
|
||||
# 创建世界书对象
|
||||
world_book = cls(
|
||||
name=world_name,
|
||||
)
|
||||
|
||||
# 转换标准格式的条目
|
||||
for uid, entry_data in entries_dict.items():
|
||||
try:
|
||||
# 先使用 WorldItem 解析数据
|
||||
world_item = WorldItem.from_sillytavern_data(entry_data)
|
||||
# 然后转换为 Entry
|
||||
world_entry = world_item.to_entry()
|
||||
world_book.add_entry(world_entry)
|
||||
except Exception as e:
|
||||
logger.warning(f"跳过条目 {uid},解析失败: {e}")
|
||||
|
||||
logger.info(
|
||||
f"从文件加载世界书: 文件={file_path}, 名称={world_name}, 条目数={len(world_book.entries)}")
|
||||
|
||||
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:
|
||||
"""
|
||||
保存世界书到文件(只有 entries 字段的格式)
|
||||
如果文件不存在,会创建新文件;如果文件存在,会更新现有文件
|
||||
|
||||
Raises:
|
||||
IOError: 文件写入失败
|
||||
"""
|
||||
file_path = self.get_file_path(self.name)
|
||||
|
||||
# 确保目录存在
|
||||
os.makedirs(Path(file_path).parent, exist_ok=True)
|
||||
|
||||
try:
|
||||
# 转换为标准格式
|
||||
entries_dict = {}
|
||||
for uid, entry in self.entries.items():
|
||||
entries_dict[uid] = entry.to_sillytavern_dict()
|
||||
|
||||
output_data = {
|
||||
"entries": entries_dict
|
||||
}
|
||||
|
||||
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]]:
|
||||
"""
|
||||
提取所有条目的触发关键词和内容,用于快速构建向量数据库或索引
|
||||
|
||||
Returns:
|
||||
List[Dict[str, Any]]: 包含 trigger (key) 和 content 的列表
|
||||
"""
|
||||
result = []
|
||||
for entry in self.entries.values():
|
||||
entry_dict = entry.to_dict()
|
||||
# 添加额外的触发相关信息
|
||||
enabled_triggers = entry.trigger_config.get_enabled_triggers()
|
||||
keyword_enabled, keyword_config = entry.trigger_config.get_trigger(TriggerStrategy.KEYWORD)
|
||||
constant_enabled, _ = entry.trigger_config.get_trigger(TriggerStrategy.CONSTANT)
|
||||
|
||||
entry_dict.update({
|
||||
"triggers": keyword_config.key if keyword_enabled and keyword_config else [],
|
||||
"constant": constant_enabled,
|
||||
"trigger_strategies": [strategy.value for strategy in enabled_triggers]
|
||||
})
|
||||
result.append(entry_dict)
|
||||
|
||||
logger.debug(f"列出世界书 {self.name} 的触发词和内容: 条目数={len(result)}")
|
||||
return result
|
||||
|
||||
def get_all_entries(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
获取所有条目的核心信息(包括已禁用的条目)
|
||||
|
||||
Returns:
|
||||
List[Dict[str, Any]]: 包含核心信息的条目列表
|
||||
"""
|
||||
result = [entry.to_dict() for entry in self.entries.values()]
|
||||
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:
|
||||
# 创建空白世界书
|
||||
world_book = WorldBook.create_empty("test_worldbook")
|
||||
|
||||
# 加载世界书
|
||||
world_book = WorldBook.load("test_worldbook")
|
||||
|
||||
# 打印概要
|
||||
summary = world_book.get_summary()
|
||||
print(f"世界书名称: {summary['name']}")
|
||||
print(f"条目数量: {summary['entry_count']}")
|
||||
print(f"触发策略分布: {summary['trigger_strategies']}")
|
||||
|
||||
# 列出所有条目的触发词和内容预览
|
||||
print("\n--- 条目预览 ---")
|
||||
for item in world_book.list_triggers_and_content():
|
||||
triggers = item['triggers'] if item['triggers'] else ['(无关键词 - 常驻)']
|
||||
content_preview = item['content'][:50].replace('\n', ' ') + "..."
|
||||
print(f"[{item['position']}] TRIGGERS: {triggers} -> CONTENT: {content_preview}")
|
||||
|
||||
# 保存世界书
|
||||
world_book.save()
|
||||
print(f"\n✅ 世界书已保存")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 错误: {e}")
|
||||
826
backend/core/models/WorldItem.py
Normal file
826
backend/core/models/WorldItem.py
Normal file
@@ -0,0 +1,826 @@
|
||||
import logging
|
||||
from enum import Enum
|
||||
from typing import List, Optional, Dict, Any, Union
|
||||
from pydantic import BaseModel, Field, field_validator, ConfigDict
|
||||
|
||||
# 配置日志
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WorldInfoPosition(Enum):
|
||||
"""
|
||||
SillyTavern 世界书条目插入位置枚举
|
||||
|
||||
注意:枚举值的顺序(0-4)并不完全代表物理顺序!
|
||||
以下是按照 Prompt 从上到下的真实物理顺序排列的:
|
||||
"""
|
||||
|
||||
# --- 1. 顶部区域 ---
|
||||
# (System Prompt 在这里,不可插入)
|
||||
|
||||
# --- 2. 核心指令区 (Position 4 实际上在这里) ---
|
||||
SYSTEM_PROMPT = 4
|
||||
"""
|
||||
物理位置:紧跟在系统提示词之后,角色定义之前。
|
||||
语境:最高优先级的规则。
|
||||
用途:作者注释、核心系统规则。AI 在读人设前就会先读到这个。
|
||||
"""
|
||||
|
||||
# --- 3. 角色人设区 (Position 0 实际上在这里) ---
|
||||
# (Character Definition 在这里)
|
||||
|
||||
CHAR_AFTER = 0
|
||||
"""
|
||||
物理位置:紧跟在角色定义之后。
|
||||
语境:角色固有属性。
|
||||
用途:性格、外貌、长期设定。
|
||||
"""
|
||||
|
||||
# --- 4. 示例对话区 ---
|
||||
EXAMPLE_BEFORE = 2
|
||||
"""
|
||||
物理位置:在示例对话块之前。
|
||||
"""
|
||||
|
||||
EXAMPLE_AFTER = 3
|
||||
"""
|
||||
物理位置:在示例对话块之后。
|
||||
"""
|
||||
|
||||
# --- 5. 底部区域 ---
|
||||
# (Chat History 在这里)
|
||||
# (User Input 在这里 - 最新输入)
|
||||
|
||||
# --- 6. 动态深度区 (Depth / d0-d99) ---
|
||||
# 这是你强调的"第 6 个插入区"
|
||||
# 它不是一个固定的物理点,而是一个动态区域
|
||||
|
||||
DEPTH_HISTORY = 4
|
||||
"""
|
||||
物理位置:
|
||||
- d0: 在 [用户最新输入] 之前,[AI 回复] 之前。
|
||||
- d0~d99: 在 [Chat History] 内部,倒数第 N 条消息之前。
|
||||
|
||||
语境:
|
||||
- d0: 即时状态("现在正在发生")。
|
||||
- d1+: 历史背景("当时就在那里")。
|
||||
|
||||
用途:
|
||||
这是最灵活的插入区,利用 Depth 字段来精确控制条目在对话流中的位置。
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def get_description(cls, position: int) -> str:
|
||||
"""
|
||||
获取位置描述
|
||||
|
||||
Args:
|
||||
position: 位置值
|
||||
|
||||
Returns:
|
||||
str: 位置描述
|
||||
"""
|
||||
position_map = {
|
||||
0: "角色定义之后",
|
||||
1: "角色定义之后 (最常用)",
|
||||
2: "示例对话之前",
|
||||
3: "示例对话之后",
|
||||
4: "系统提示 / 作者注释 (底部) 或 历史记录深度插入"
|
||||
}
|
||||
return position_map.get(position, "未知位置")
|
||||
|
||||
@classmethod
|
||||
def is_depth_position(cls, position: int) -> bool:
|
||||
"""
|
||||
判断是否为深度插入位置
|
||||
|
||||
Args:
|
||||
position: 位置值
|
||||
|
||||
Returns:
|
||||
bool: 是否为深度插入位置
|
||||
"""
|
||||
return position == cls.DEPTH_HISTORY.value
|
||||
|
||||
|
||||
class TriggerStrategy(str, Enum):
|
||||
"""
|
||||
触发策略枚举
|
||||
"""
|
||||
CONSTANT = "constant" # 永久触发
|
||||
KEYWORD = "keyword" # 关键词匹配触发
|
||||
RAG = "rag" # 向量检索触发
|
||||
CONDITION = "condition" # 逻辑条件触发
|
||||
|
||||
|
||||
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 KeywordTriggerConfig(BaseModel):
|
||||
"""
|
||||
关键词触发配置
|
||||
"""
|
||||
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 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="触发配置字典,键为触发策略,值为[是否启用, 对应配置]"
|
||||
)
|
||||
|
||||
model_config = ConfigDict(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 WorldItem(BaseModel):
|
||||
"""
|
||||
世界书条目完整模型
|
||||
包含所有 SillyTavern 世界书条目属性,用于导入导出
|
||||
"""
|
||||
|
||||
class Entry(BaseModel):
|
||||
"""
|
||||
世界书条目模型
|
||||
精简版,只包含必要字段,用于实际使用
|
||||
"""
|
||||
# 基础定义
|
||||
uid: int = Field(..., description="唯一标识符")
|
||||
content: str = Field(..., description="注入到 Prompt 的实际文本内容")
|
||||
comment: str = Field("", description="条目名、备注")
|
||||
|
||||
# 注入与排序
|
||||
position: int = Field(0,
|
||||
description="插入位置 (0=角色定义之前, 1=角色定义之后, 2=示例对话之前, 3=示例对话之后, 4=系统提示/作者注释)")
|
||||
order: int = Field(100, description="注入顺序权重,数字越小优先级越高")
|
||||
depth: int = Field(4, description="扫描深度,0为最深/最高,4为标准")
|
||||
|
||||
# 触发配置
|
||||
trigger_config: Optional[TriggerConfig] = Field(
|
||||
default_factory=TriggerConfig,
|
||||
description="触发配置,为空表示无需触发配置"
|
||||
)
|
||||
# 角色匹配
|
||||
role: int = Field(0, description="角色匹配 (0=Both, 1=User, 2=Assistant)")
|
||||
|
||||
# 条目启用状态
|
||||
enabled: bool = Field(True, description="条目是否启用(启用才会被插入到LLM)")
|
||||
|
||||
@field_validator('position')
|
||||
@classmethod
|
||||
def validate_position(cls, v):
|
||||
"""验证 position 值是否在有效范围内"""
|
||||
if v not in [0, 1, 2, 3, 4]:
|
||||
logger.warning(f"无效的 position 值: {v},将使用默认值 1")
|
||||
return 1
|
||||
return v
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""
|
||||
转换为字典
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 字典数据
|
||||
"""
|
||||
return self.dict()
|
||||
|
||||
def get_trigger_params(self) -> Dict[str, Any]:
|
||||
"""
|
||||
获取触发策略所需的参数
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 触发参数字典
|
||||
"""
|
||||
params = {}
|
||||
|
||||
try:
|
||||
# 获取所有启用的触发策略
|
||||
enabled_triggers = self.trigger_config.get_enabled_triggers()
|
||||
|
||||
# 处理 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"] = True
|
||||
|
||||
# 处理关键词触发
|
||||
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
|
||||
|
||||
# 处理条件触发
|
||||
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
|
||||
|
||||
# 基础定义
|
||||
uid: int = Field(..., description="唯一标识符")
|
||||
content: str = Field(..., description="注入到 Prompt 的实际文本内容")
|
||||
comment: str = Field("", description="条目名、备注")
|
||||
|
||||
# 注入与排序
|
||||
position: int = Field(0,
|
||||
description="插入位置 (0=角色定义之前, 1=角色定义之后, 2=示例对话之前, 3=示例对话之后, 4=系统提示/作者注释)")
|
||||
order: int = Field(100, description="注入顺序权重,数字越小优先级越高")
|
||||
depth: int = Field(4, description="扫描深度,0为最深/最高,4为标准")
|
||||
|
||||
# 触发配置
|
||||
trigger_config: TriggerConfig = Field(
|
||||
default_factory=TriggerConfig,
|
||||
description="触发配置"
|
||||
)
|
||||
|
||||
# 角色匹配
|
||||
role: int = Field(0, description="角色匹配 (0=Both, 1=User, 2=Assistant)")
|
||||
|
||||
# 条目启用状态
|
||||
enabled: bool = Field(True, description="条目是否启用(启用才会被插入到LLM)")
|
||||
|
||||
# 触发相关属性
|
||||
vectorized: bool = Field(False, description="是否使用向量检索(RAG触发)")
|
||||
selective: bool = Field(True, description="是否开启选择性匹配(关键词触发)")
|
||||
selectiveLogic: int = Field(0, description="逻辑模式 (0=OR, 1=AND)")
|
||||
constant: bool = Field(False, description="是否永久触发")
|
||||
|
||||
# 关键词相关
|
||||
key: List[str] = Field(default_factory=list, description="主关键词数组")
|
||||
keysecondary: List[str] = Field(default_factory=list, description="次要关键词数组")
|
||||
matchWholeWords: Optional[bool] = Field(None, description="是否全词匹配")
|
||||
caseSensitive: Optional[bool] = Field(None, description="是否区分大小写")
|
||||
|
||||
# RAG相关
|
||||
rag_threshold: Optional[float] = Field(None, description="RAG 相似度阈值")
|
||||
top_k: Optional[int] = Field(None, description="返回的匹配条目数")
|
||||
query_template: Optional[str] = Field(None, description="检索用的查询模板")
|
||||
|
||||
# 条目控制
|
||||
addMemo: bool = Field(True, description="是否添加备忘")
|
||||
disable: bool = Field(False, description="是否禁用")
|
||||
ignoreBudget: bool = Field(False, description="是否忽略预算")
|
||||
excludeRecursion: bool = Field(True, description="是否排除递归")
|
||||
preventRecursion: bool = Field(True, description="是否阻止递归")
|
||||
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="是否匹配作者笔记")
|
||||
delayUntilRecursion: bool = Field(False, description="是否延迟递归")
|
||||
|
||||
# 概率相关
|
||||
probability: int = Field(100, description="触发概率 (0-100)")
|
||||
useProbability: bool = Field(True, description="是否使用概率")
|
||||
|
||||
# 分组相关
|
||||
group: str = Field("", description="分组名称")
|
||||
groupOverride: bool = Field(False, description="是否覆盖分组")
|
||||
groupWeight: int = Field(100, description="分组权重")
|
||||
useGroupScoring: bool = Field(False, description="是否使用分组评分")
|
||||
|
||||
# 其他属性
|
||||
scanDepth: Optional[int] = Field(None, description="扫描深度")
|
||||
automationId: str = Field("", description="自动化ID")
|
||||
sticky: int = Field(0, description="粘性")
|
||||
cooldown: int = Field(0, description="冷却时间(秒)")
|
||||
delay: int = Field(0, description="延迟时间(秒)")
|
||||
displayIndex: int = Field(0, description="显示索引")
|
||||
|
||||
# 角色过滤器
|
||||
characterFilter: Dict[str, Any] = Field(
|
||||
default_factory=lambda: {"isExclude": False, "names": [], "tags": []},
|
||||
description="角色过滤器"
|
||||
)
|
||||
|
||||
# 验证器
|
||||
@field_validator('position')
|
||||
@classmethod
|
||||
def validate_position(cls, v):
|
||||
"""验证 position 值是否在有效范围内"""
|
||||
if v not in [0, 1, 2, 3, 4]:
|
||||
logger.warning(f"无效的 position 值: {v},将使用默认值 1")
|
||||
return 1
|
||||
return v
|
||||
|
||||
@field_validator('role')
|
||||
@classmethod
|
||||
def validate_role(cls, v):
|
||||
"""验证 role 值是否在有效范围内"""
|
||||
if v not in [0, 1, 2]:
|
||||
logger.warning(f"无效的 role 值: {v},将使用默认值 2")
|
||||
return 2
|
||||
return v
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'WorldItem':
|
||||
"""
|
||||
从字典创建 WorldItem 对象
|
||||
|
||||
Args:
|
||||
data: 字典数据
|
||||
|
||||
Returns:
|
||||
WorldItem: WorldItem 对象
|
||||
"""
|
||||
return cls(**data)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""
|
||||
转换为字典
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 字典数据
|
||||
"""
|
||||
return self.dict()
|
||||
|
||||
def to_entry(self) -> Entry:
|
||||
"""
|
||||
转换为 Entry 对象
|
||||
|
||||
Returns:
|
||||
Entry: Entry 对象
|
||||
"""
|
||||
# 转换为 SillyTavern 格式的字典
|
||||
sillytavern_dict = self.to_sillytavern_dict()
|
||||
# 创建 Entry 对象
|
||||
return self.Entry(**sillytavern_dict)
|
||||
|
||||
def to_sillytavern_dict(self) -> Dict[str, Any]:
|
||||
"""
|
||||
转换为 SillyTavern 格式的字典
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: SillyTavern 格式的条目数据
|
||||
"""
|
||||
result = {
|
||||
"uid": self.uid,
|
||||
"content": self.content,
|
||||
"comment": self.comment,
|
||||
"position": self.position,
|
||||
"order": self.order,
|
||||
"depth": self.depth,
|
||||
"role": self.role,
|
||||
"enabled": self.enabled,
|
||||
"vectorized": self.vectorized,
|
||||
"selective": self.selective,
|
||||
"selectiveLogic": self.selectiveLogic,
|
||||
"constant": self.constant,
|
||||
"key": self.key,
|
||||
"keysecondary": self.keysecondary,
|
||||
"matchWholeWords": self.matchWholeWords,
|
||||
"caseSensitive": self.caseSensitive,
|
||||
"addMemo": self.addMemo,
|
||||
"disable": self.disable,
|
||||
"ignoreBudget": self.ignoreBudget,
|
||||
"excludeRecursion": self.excludeRecursion,
|
||||
"preventRecursion": self.preventRecursion,
|
||||
"matchPersonaDescription": self.matchPersonaDescription,
|
||||
"matchCharacterDescription": self.matchCharacterDescription,
|
||||
"matchCharacterPersonality": self.matchCharacterPersonality,
|
||||
"matchCharacterDepthPrompt": self.matchCharacterDepthPrompt,
|
||||
"matchScenario": self.matchScenario,
|
||||
"matchCreatorNotes": self.matchCreatorNotes,
|
||||
"delayUntilRecursion": self.delayUntilRecursion,
|
||||
"probability": self.probability,
|
||||
"useProbability": self.useProbability,
|
||||
"group": self.group,
|
||||
"groupOverride": self.groupOverride,
|
||||
"groupWeight": self.groupWeight,
|
||||
"scanDepth": self.scanDepth,
|
||||
"automationId": self.automationId,
|
||||
"sticky": self.sticky,
|
||||
"cooldown": self.cooldown,
|
||||
"delay": self.delay,
|
||||
"displayIndex": self.displayIndex,
|
||||
"characterFilter": self.characterFilter
|
||||
}
|
||||
|
||||
# 添加 RAG 相关字段
|
||||
if self.vectorized:
|
||||
result["rag_threshold"] = self.rag_threshold
|
||||
result["top_k"] = self.top_k
|
||||
result["query_template"] = self.query_template
|
||||
|
||||
# 添加条件触发相关字段
|
||||
if TriggerStrategy.CONDITION in self.trigger_config.get_enabled_triggers():
|
||||
condition_config = self.trigger_config.get_trigger(TriggerStrategy.CONDITION)[1]
|
||||
if condition_config:
|
||||
result["variable_a"] = condition_config.variable_a
|
||||
result["operator"] = condition_config.operator
|
||||
result["variable_b"] = condition_config.variable_b
|
||||
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def from_sillytavern_data(cls, data: Dict[str, Any]) -> 'WorldItem':
|
||||
"""
|
||||
从 SillyTavern 格式的数据创建 WorldItem 对象
|
||||
|
||||
Args:
|
||||
data: SillyTavern 格式的条目数据
|
||||
|
||||
Returns:
|
||||
WorldItem: WorldItem 对象
|
||||
"""
|
||||
|
||||
constant = data.get("constant", False)
|
||||
if isinstance(constant, str):
|
||||
constant = constant.lower() in ('true', '1', 'yes')
|
||||
|
||||
enabled = data.get("enabled", True)
|
||||
if isinstance(enabled, str):
|
||||
enabled = enabled.lower() in ('true', '1', 'yes')
|
||||
|
||||
try:
|
||||
# 提取必要字段
|
||||
uid = int(data.get("uid", data.get("id", 0)))
|
||||
content = data.get("content", "")
|
||||
comment = data.get("comment", "")
|
||||
position = data.get("position", 0)
|
||||
order = data.get("order", 100)
|
||||
depth = data.get("depth", 4)
|
||||
role = data.get("role", 0)
|
||||
enabled = data.get("enabled", True)
|
||||
|
||||
# 处理 position 字段,确保为整数类型
|
||||
if isinstance(position, str):
|
||||
try:
|
||||
position = int(position)
|
||||
except ValueError:
|
||||
logger.warning(f"条目 {uid} 的 position 字段值 '{position}' 无法转换为整数,使用默认值 0")
|
||||
position = 0
|
||||
|
||||
# 初始化触发配置
|
||||
trigger_config = TriggerConfig()
|
||||
|
||||
# 读取触发相关字段,并进行类型转换
|
||||
vectorized = data.get("vectorized", False)
|
||||
if isinstance(vectorized, str):
|
||||
vectorized = vectorized.lower() in ('true', '1', 'yes')
|
||||
|
||||
selective = data.get("selective", True)
|
||||
if isinstance(selective, str):
|
||||
selective = selective.lower() in ('true', '1', 'yes')
|
||||
|
||||
constant = data.get("constant", False)
|
||||
if isinstance(constant, str):
|
||||
constant = constant.lower() in ('true', '1', 'yes')
|
||||
|
||||
# 初始化变量,确保它们始终有值
|
||||
key = []
|
||||
keysecondary = []
|
||||
selectiveLogic = 0
|
||||
matchWholeWords = False
|
||||
caseSensitive = False
|
||||
|
||||
# 判断触发策略并设置对应的触发配置
|
||||
# 优先级:vectorized > constant > selective
|
||||
if vectorized:
|
||||
# RAG 触发
|
||||
rag_config = RAGTriggerConfig(
|
||||
threshold=float(data.get("rag_threshold", 0.75)),
|
||||
top_k=int(data.get("top_k", 5)),
|
||||
query_template=data.get("query_template", None)
|
||||
)
|
||||
trigger_config.set_trigger(TriggerStrategy.RAG, True, rag_config)
|
||||
elif constant:
|
||||
# 永久触发
|
||||
trigger_config.set_trigger(TriggerStrategy.CONSTANT, True)
|
||||
elif selective:
|
||||
# 关键词触发
|
||||
key = data.get("key", [])
|
||||
keysecondary = data.get("keysecondary", data.get("secondary_keys", []))
|
||||
selectiveLogic = int(data.get("selectiveLogic", 0))
|
||||
|
||||
# 处理 matchWholeWords 字段
|
||||
matchWholeWords = data.get("matchWholeWords", False)
|
||||
if matchWholeWords is None:
|
||||
matchWholeWords = False
|
||||
elif isinstance(matchWholeWords, str):
|
||||
matchWholeWords = matchWholeWords.lower() in ('true', '1', 'yes')
|
||||
|
||||
# 处理 caseSensitive 字段
|
||||
caseSensitive = data.get("caseSensitive", False)
|
||||
if caseSensitive is None:
|
||||
caseSensitive = False
|
||||
elif isinstance(caseSensitive, str):
|
||||
caseSensitive = caseSensitive.lower() in ('true', '1', 'yes')
|
||||
|
||||
keyword_config = KeywordTriggerConfig(
|
||||
key=key,
|
||||
keysecondary=keysecondary,
|
||||
selective=selective,
|
||||
selectiveLogic=selectiveLogic,
|
||||
matchWholeWords=matchWholeWords,
|
||||
caseSensitive=caseSensitive
|
||||
)
|
||||
trigger_config.set_trigger(TriggerStrategy.KEYWORD, True, keyword_config)
|
||||
else:
|
||||
# 默认使用永久触发
|
||||
trigger_config.set_trigger(TriggerStrategy.CONSTANT, True)
|
||||
|
||||
# 检查是否有条件触发(虽然 JSON 中没有对应字段,但需要保留兼容性)
|
||||
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)
|
||||
|
||||
# 创建 WorldItem 对象
|
||||
return cls(
|
||||
uid=uid,
|
||||
content=content,
|
||||
comment=comment,
|
||||
position=position,
|
||||
order=order,
|
||||
depth=depth,
|
||||
trigger_config=trigger_config,
|
||||
role=role,
|
||||
enabled=enabled,
|
||||
vectorized=vectorized,
|
||||
selective=selective,
|
||||
selectiveLogic=selectiveLogic,
|
||||
constant=constant,
|
||||
key=key,
|
||||
keysecondary=keysecondary,
|
||||
matchWholeWords=matchWholeWords,
|
||||
caseSensitive=caseSensitive,
|
||||
rag_threshold=float(data.get("rag_threshold", None)) if vectorized else None,
|
||||
top_k=int(data.get("top_k", None)) if vectorized else None,
|
||||
query_template=data.get("query_template", None),
|
||||
addMemo=data.get("addMemo", True),
|
||||
disable=data.get("disable", False),
|
||||
ignoreBudget=data.get("ignoreBudget", False),
|
||||
excludeRecursion=data.get("excludeRecursion", True),
|
||||
preventRecursion=data.get("preventRecursion", True),
|
||||
matchPersonaDescription=data.get("matchPersonaDescription", False),
|
||||
matchCharacterDescription=data.get("matchCharacterDescription", False),
|
||||
matchCharacterPersonality=data.get("matchCharacterPersonality", False),
|
||||
matchCharacterDepthPrompt=data.get("matchCharacterDepthPrompt", False),
|
||||
matchScenario=data.get("matchScenario", False),
|
||||
matchCreatorNotes=data.get("matchCreatorNotes", False),
|
||||
delayUntilRecursion=data.get("delayUntilRecursion", False),
|
||||
probability=data.get("probability", 100),
|
||||
useProbability=data.get("useProbability", True),
|
||||
group=data.get("group", ""),
|
||||
groupOverride=data.get("groupOverride", False),
|
||||
groupWeight=data.get("groupWeight", 100),
|
||||
useGroupScoring=data.get("useGroupScoring", False),
|
||||
scanDepth=data.get("scanDepth", None),
|
||||
automationId=data.get("automationId", ""),
|
||||
sticky=data.get("sticky", 0),
|
||||
cooldown=data.get("cooldown", 0),
|
||||
delay=data.get("delay", 0),
|
||||
displayIndex=data.get("displayIndex", 0),
|
||||
characterFilter=data.get("characterFilter", {"isExclude": False, "names": [], "tags": []})
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"从 SillyTavern 数据创建 WorldItem 失败: {str(e)}")
|
||||
raise ValueError(f"从 SillyTavern 数据创建 WorldItem 失败: {str(e)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
"""
|
||||
测试入口:用于调试 WorldItem 的解析和转换功能
|
||||
可以像断点调试一样查看内部执行过程
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# 测试用例1:基本条目
|
||||
test_data_1 = {
|
||||
"uid": 0,
|
||||
"content": "测试内容",
|
||||
"comment": "测试条目",
|
||||
"position": 0,
|
||||
"order": 100,
|
||||
"depth": 4,
|
||||
"role": 0,
|
||||
"vectorized": False,
|
||||
"selective": True,
|
||||
"selectiveLogic": 0,
|
||||
"constant": False,
|
||||
"key": ["测试关键词"],
|
||||
"keysecondary": [],
|
||||
"matchWholeWords": False,
|
||||
"caseSensitive": False,
|
||||
"addMemo": True,
|
||||
"disable": False,
|
||||
"ignoreBudget": False,
|
||||
"excludeRecursion": True,
|
||||
"preventRecursion": True
|
||||
}
|
||||
|
||||
# 测试用例2:RAG触发
|
||||
test_data_2 = {
|
||||
"uid": 1,
|
||||
"content": "RAG测试内容",
|
||||
"comment": "RAG测试条目",
|
||||
"position": 4,
|
||||
"order": 50,
|
||||
"depth": 0,
|
||||
"role": 0,
|
||||
"vectorized": True,
|
||||
"rag_threshold": 0.8,
|
||||
"top_k": 10,
|
||||
"query_template": "测试模板"
|
||||
}
|
||||
|
||||
# 测试用例3:条件触发
|
||||
test_data_3 = {
|
||||
"uid": 2,
|
||||
"content": "条件触发测试",
|
||||
"comment": "条件触发条目",
|
||||
"position": 1,
|
||||
"order": 75,
|
||||
"depth": 2,
|
||||
"role": 0,
|
||||
"variable_a": "好感度",
|
||||
"operator": ">",
|
||||
"variable_b": "50"
|
||||
}
|
||||
|
||||
print("=" * 60)
|
||||
print("开始测试 WorldItem 解析功能")
|
||||
print("=" * 60)
|
||||
|
||||
try:
|
||||
# 测试1:解析基本条目
|
||||
print("\n【测试1】解析基本条目...")
|
||||
item1 = WorldItem.from_sillytavern_data(test_data_1)
|
||||
print(f"✓ 解析成功: {item1.comment}")
|
||||
print(f" - UID: {item1.uid}")
|
||||
print(f" - Position: {item1.position}")
|
||||
print(f" - 触发策略和配置:")
|
||||
for strategy in item1.trigger_config.get_enabled_triggers():
|
||||
enabled, config = item1.trigger_config.get_trigger(strategy)
|
||||
print(f" * {strategy.value}: 启用={enabled}, 配置={config}")
|
||||
|
||||
# 测试2:解析RAG触发条目
|
||||
print("\n【测试2】解析RAG触发条目...")
|
||||
item2 = WorldItem.from_sillytavern_data(test_data_2)
|
||||
print(f"✓ 解析成功: {item2.comment}")
|
||||
print(f" - UID: {item2.uid}")
|
||||
print(f" - Position: {item2.position}")
|
||||
print(f" - 触发策略和配置:")
|
||||
for strategy in item2.trigger_config.get_enabled_triggers():
|
||||
enabled, config = item2.trigger_config.get_trigger(strategy)
|
||||
print(f" * {strategy.value}: 启用={enabled}, 配置={config}")
|
||||
if item2.rag_threshold:
|
||||
print(f" - RAG阈值: {item2.rag_threshold}")
|
||||
|
||||
# 测试3:解析条件触发条目
|
||||
print("\n【测试3】解析条件触发条目...")
|
||||
item3 = WorldItem.from_sillytavern_data(test_data_3)
|
||||
print(f"✓ 解析成功: {item3.comment}")
|
||||
print(f" - UID: {item3.uid}")
|
||||
print(f" - Position: {item3.position}")
|
||||
print(f" - 触发策略和配置:")
|
||||
for strategy in item3.trigger_config.get_enabled_triggers():
|
||||
enabled, config = item3.trigger_config.get_trigger(strategy)
|
||||
print(f" * {strategy.value}: 启用={enabled}, 配置={config}")
|
||||
|
||||
# 测试4:从文件读取实际数据
|
||||
print("\n【测试4】从实际JSON文件读取...")
|
||||
# 从当前文件位置向上查找项目根目录
|
||||
current_file = Path(__file__).resolve()
|
||||
project_root = current_file
|
||||
while project_root.name != "llm_workflow_engine" and project_root.parent != project_root:
|
||||
project_root = project_root.parent
|
||||
|
||||
# 构建正确的文件路径
|
||||
json_path = project_root / "data" / "worldbooks" / "卡立创-v5.json"
|
||||
print(f"查找文件路径: {json_path}")
|
||||
|
||||
if json_path.exists():
|
||||
with open(json_path, 'r', encoding='utf-8') as f:
|
||||
worldbook_data = json.load(f)
|
||||
entries = worldbook_data.get('entries', {})
|
||||
print(f"找到 {len(entries)} 个条目")
|
||||
|
||||
# 只测试前3个条目
|
||||
for uid, entry_data in list(entries.items())[:3]:
|
||||
try:
|
||||
item = WorldItem.from_sillytavern_data(entry_data)
|
||||
print(f"\n✓ 条目 {uid} 解析成功:")
|
||||
print(f" - 备注: {item.comment}")
|
||||
print(f" - UID: {item.uid}")
|
||||
print(f" - Position: {item.position}")
|
||||
print(f" - 触发策略和配置:")
|
||||
for strategy in item.trigger_config.get_enabled_triggers():
|
||||
enabled, config = item.trigger_config.get_trigger(strategy)
|
||||
print(f" * {strategy.value}: 启用={enabled}, 配置={config}")
|
||||
except Exception as e:
|
||||
print(f"\n✗ 条目 {uid} 解析失败: {str(e)}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
else:
|
||||
print(f"⚠ 文件不存在: {json_path}")
|
||||
print(f"请确认文件路径是否正确")
|
||||
# 列出可能的文件位置
|
||||
possible_paths = [
|
||||
project_root / "data" / "worldbooks",
|
||||
project_root / "backend" / "data" / "worldbooks",
|
||||
current_file.parent.parent.parent / "data" / "worldbooks"
|
||||
]
|
||||
print("\n可能的文件位置:")
|
||||
for path in possible_paths:
|
||||
if path.exists():
|
||||
print(f" ✓ {path}")
|
||||
for file in path.glob("*.json"):
|
||||
print(f" - {file.name}")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("所有测试完成!")
|
||||
print("=" * 60)
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n✗ 测试失败: {str(e)}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
@@ -88,7 +88,175 @@ class ChatHistory(BaseModel):
|
||||
class Config:
|
||||
arbitrary_types_allowed = True
|
||||
|
||||
@classmethod # 类方法装饰器,表示这是一个类方法,可以通过类名直接调用
|
||||
@classmethod
|
||||
def get_data_path(cls) -> Path:
|
||||
"""获取数据目录路径"""
|
||||
try:
|
||||
from backend.core.config import settings
|
||||
return settings.DATA_PATH / "chat"
|
||||
except ImportError:
|
||||
return Path("data")
|
||||
|
||||
@classmethod
|
||||
async def list_all_chats(cls) -> Dict[str, List[Dict]]:
|
||||
"""获取所有角色的所有聊天列表"""
|
||||
data_dir = cls.get_data_path()
|
||||
if not data_dir.exists():
|
||||
return {"chat": []}
|
||||
|
||||
chats = []
|
||||
for role_dir in data_dir.iterdir():
|
||||
if role_dir.is_dir():
|
||||
for chat_file in role_dir.glob("*.jsonl"):
|
||||
try:
|
||||
with open(chat_file, 'r', encoding='utf-8') as f:
|
||||
# 读取第一行获取元数据
|
||||
first_line = f.readline()
|
||||
metadata = json.loads(first_line)
|
||||
chats.append({
|
||||
"role_name": role_dir.name,
|
||||
"chat_name": chat_file.stem,
|
||||
"user_name": metadata.get("user_name", "User"),
|
||||
"character_name": metadata.get("character_name", "Assistant"),
|
||||
"last_modified": metadata.get("last_modified", ""),
|
||||
"message_count": sum(1 for _ in f) # 统计剩余行数(消息数)
|
||||
})
|
||||
except Exception:
|
||||
continue # 跳过损坏的聊天文件
|
||||
return {"chat": chats}
|
||||
|
||||
@classmethod
|
||||
async def get_chat(cls, role_name: str, chat_name: str) -> Dict[str, Any]:
|
||||
"""获取指定聊天的完整内容"""
|
||||
chat_history = cls.load_from_file(role_name, chat_name)
|
||||
return {
|
||||
"metadata": chat_history.chat_metadata.dict(),
|
||||
"messages": chat_history.to_chatbox_format()
|
||||
}
|
||||
|
||||
@classmethod
|
||||
async def create_chat(cls, role_name: str, chat_name: str, metadata: Optional[Dict] = None) -> Dict[str, str]:
|
||||
"""创建新聊天"""
|
||||
base_path = cls.get_data_path()
|
||||
role_dir = base_path / role_name
|
||||
role_dir.mkdir(parents=True, exist_ok=True)
|
||||
chat_path = role_dir / f"{chat_name}.jsonl"
|
||||
|
||||
if chat_path.exists():
|
||||
raise FileExistsError(f"Chat already exists: {chat_path}")
|
||||
|
||||
# 创建聊天历史对象
|
||||
chat_history = cls(
|
||||
chat_metadata=ChatMetadata(**(metadata or {})),
|
||||
messages=[]
|
||||
)
|
||||
|
||||
# 保存到文件
|
||||
chat_history.save_to_file(role_name, chat_name, base_path)
|
||||
return {"message": "Chat created successfully"}
|
||||
|
||||
@classmethod
|
||||
async def update_chat(cls, role_name: str, chat_name: str, update_data: Dict) -> Dict[str, str]:
|
||||
"""更新聊天元数据"""
|
||||
chat_history = cls.load_from_file(role_name, chat_name)
|
||||
|
||||
# 更新元数据
|
||||
if "metadata" in update_data:
|
||||
for key, value in update_data["metadata"].items():
|
||||
if hasattr(chat_history.chat_metadata, key):
|
||||
setattr(chat_history.chat_metadata, key, value)
|
||||
|
||||
# 保存更改
|
||||
base_path = cls.get_data_path()
|
||||
chat_history.save_to_file(role_name, chat_name, base_path)
|
||||
return {"message": "Chat metadata updated successfully"}
|
||||
|
||||
@classmethod
|
||||
async def delete_chat(cls, role_name: str, chat_name: str) -> Dict[str, str]:
|
||||
"""删除指定聊天"""
|
||||
base_path = cls.get_data_path()
|
||||
chat_path = base_path / role_name / f"{chat_name}.jsonl"
|
||||
|
||||
if not chat_path.exists():
|
||||
raise FileNotFoundError(f"Chat not found: {chat_path}")
|
||||
|
||||
chat_path.unlink()
|
||||
return {"message": "Chat deleted successfully"}
|
||||
|
||||
@classmethod
|
||||
async def list_messages(cls, role_name: str, chat_name: str) -> Dict[str, List[Dict]]:
|
||||
"""获取聊天的所有消息"""
|
||||
chat_history = cls.load_from_file(role_name, chat_name)
|
||||
return {"messages": chat_history.to_chatbox_format()}
|
||||
|
||||
@classmethod
|
||||
async def get_message(cls, role_name: str, chat_name: str, floor: int) -> Dict[str, Any]:
|
||||
"""获取指定楼层的消息"""
|
||||
chat_history = cls.load_from_file(role_name, chat_name)
|
||||
message = next((msg for msg in chat_history.messages if msg.floor == floor), None)
|
||||
|
||||
if not message:
|
||||
raise FileNotFoundError(f"Message not found: floor {floor}")
|
||||
|
||||
return message.dict()
|
||||
|
||||
@classmethod
|
||||
async def add_message(cls, role_name: str, chat_name: str, message_data: Dict) -> Dict[str, Any]:
|
||||
"""向聊天添加新消息"""
|
||||
chat_history = cls.load_from_file(role_name, chat_name)
|
||||
|
||||
# 创建消息对象
|
||||
message = Message(**message_data)
|
||||
|
||||
# 检查楼层是否已存在
|
||||
if any(msg.floor == message.floor for msg in chat_history.messages):
|
||||
raise ValueError(f"Message floor already exists: {message.floor}")
|
||||
|
||||
# 添加消息
|
||||
chat_history.messages.append(message)
|
||||
|
||||
# 保存更改
|
||||
base_path = cls.get_data_path()
|
||||
chat_history.save_to_file(role_name, chat_name, base_path)
|
||||
return {"message": "Message added successfully", "floor": message.floor}
|
||||
|
||||
@classmethod
|
||||
async def update_message(cls, role_name: str, chat_name: str, floor: int, update_data: Dict) -> Dict[str, str]:
|
||||
"""更新指定楼层的消息"""
|
||||
chat_history = cls.load_from_file(role_name, chat_name)
|
||||
message = next((msg for msg in chat_history.messages if msg.floor == floor), None)
|
||||
|
||||
if not message:
|
||||
raise FileNotFoundError(f"Message not found: floor {floor}")
|
||||
|
||||
# 更新消息字段
|
||||
for key, value in update_data.items():
|
||||
if hasattr(message, key):
|
||||
setattr(message, key, value)
|
||||
|
||||
# 保存更改
|
||||
base_path = cls.get_data_path()
|
||||
chat_history.save_to_file(role_name, chat_name, base_path)
|
||||
return {"message": "Message updated successfully"}
|
||||
|
||||
@classmethod
|
||||
async def delete_message(cls, role_name: str, chat_name: str, floor: int) -> Dict[str, str]:
|
||||
"""删除指定楼层的消息"""
|
||||
chat_history = cls.load_from_file(role_name, chat_name)
|
||||
|
||||
# 查找并删除消息
|
||||
original_length = len(chat_history.messages)
|
||||
chat_history.messages = [msg for msg in chat_history.messages if msg.floor != floor]
|
||||
|
||||
if len(chat_history.messages) == original_length:
|
||||
raise FileNotFoundError(f"Message not found: floor {floor}")
|
||||
|
||||
# 保存更改
|
||||
base_path = cls.get_data_path()
|
||||
chat_history.save_to_file(role_name, chat_name, base_path)
|
||||
return {"message": "Message deleted successfully"}
|
||||
|
||||
@classmethod
|
||||
def load_from_file(cls, role_name: str, chat_name: str, base_path: Path = None) -> 'ChatHistory':
|
||||
"""
|
||||
从JSONL文件加载聊天历史
|
||||
@@ -105,10 +273,9 @@ class ChatHistory(BaseModel):
|
||||
FileNotFoundError: 当文件不存在时抛出
|
||||
json.JSONDecodeError: 当JSON解析失败时抛出
|
||||
"""
|
||||
# 设置默认基础路径 - 如果未提供base_path,则从配置中获取默认路径
|
||||
# 设置默认基础路径
|
||||
if base_path is None:
|
||||
from backend.core.config import settings # 延迟导入配置模块
|
||||
base_path = settings.DATA_PATH / "chat" # 构建默认路径
|
||||
base_path = cls.get_data_path()
|
||||
|
||||
# 构建文件路径
|
||||
file_path = base_path / role_name / f"{chat_name}.jsonl"
|
||||
@@ -247,3 +414,30 @@ class ChatHistory(BaseModel):
|
||||
|
||||
return messages_list
|
||||
|
||||
def save_to_file(self, role_name: str, chat_name: str, base_path: Path = None) -> None:
|
||||
"""
|
||||
将聊天历史保存到JSONL文件
|
||||
|
||||
参数:
|
||||
role_name: 角色名称(文件夹名)
|
||||
chat_name: 聊天名称(文件名,不含扩展名)
|
||||
base_path: 基础路径,默认为data/chat
|
||||
"""
|
||||
# 设置默认基础路径
|
||||
if base_path is None:
|
||||
base_path = self.get_data_path()
|
||||
|
||||
# 构建文件路径
|
||||
file_path = base_path / role_name / f"{chat_name}.jsonl"
|
||||
|
||||
# 确保目录存在
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 写入文件
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
# 写入元数据
|
||||
f.write(json.dumps(self.chat_metadata.dict(), ensure_ascii=False) + '\n')
|
||||
|
||||
# 写入消息
|
||||
for message in self.messages:
|
||||
f.write(json.dumps(message.dict(), ensure_ascii=False) + '\n')
|
||||
|
||||
@@ -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")
|
||||
|
||||
# 注册路由
|
||||
|
||||
231
backend/nodes/PresetAssemblyNode.py
Normal file
231
backend/nodes/PresetAssemblyNode.py
Normal file
@@ -0,0 +1,231 @@
|
||||
from typing import List, Dict, Any, Optional
|
||||
from pydantic import BaseModel, Field
|
||||
from backend.core.models.PromptList import AIDesignSpec
|
||||
from backend.core.models.PromptComponent import PromptComponent
|
||||
from enum import Enum
|
||||
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class SpecialIdentifier(str, Enum):
|
||||
"""
|
||||
特殊组件标识符枚举
|
||||
定义所有提示词组件的类型及其在最终 Prompt 中的默认物理流向
|
||||
顺序大致遵循:系统层 -> 角色层 -> 动态层 -> 历史层 -> 尾部指令
|
||||
"""
|
||||
|
||||
WORLD_INFO_BEFORE = "worldInfoBefore"
|
||||
"""前置世界书:通常用于全局设定(如物理法则),紧接在 Main Prompt 之后,拥有最高优先级"""
|
||||
|
||||
PERSONA_DESCRIPTION = "personaDescription"
|
||||
"""用户设定:告诉 AI {{user}} 是谁,通常放在场景之后,完成“谁在对谁说话”的闭环"""
|
||||
|
||||
ENHANCE_DEFINITIONS = "enhanceDefinitions"
|
||||
"""增强定义:通常是 "If you have more knowledge...",用于补充 AI 的知识库,这里用rag获取"""
|
||||
|
||||
WORLD_INFO_AFTER = "worldInfoAfter"
|
||||
"""后置世界书:通常用于特定场景规则,位于中间层底部,用于覆盖或补充前面的全局设定"""
|
||||
|
||||
CHAT_HISTORY = "chatHistory"
|
||||
"""聊天历史:包含用户与 AI 的过往对话,占据提示词的下半部分"""
|
||||
|
||||
JAILBREAK = "jailbreak"
|
||||
"""后置指令/注释,也即d0层:通常位于聊天记录之后、AI 生成之前,用于最后时刻的强调(如“不要重复”)"""
|
||||
|
||||
class PresetAssemblyNode(BaseModel):
|
||||
"""预设组装节点类,负责根据组装指令动态组装提示词内容"""
|
||||
|
||||
# 输入数据
|
||||
design_spec: AIDesignSpec = Field(
|
||||
...,
|
||||
description="AI设计规范,包含组件库和组装顺序"
|
||||
)
|
||||
target_character_id: int = Field(
|
||||
...,
|
||||
description="目标角色ID,用于选择对应的组装指令"
|
||||
)
|
||||
|
||||
# 内部状态(不参与序列化)
|
||||
_component_map: Dict[str, PromptComponent] = Field(
|
||||
default_factory=dict,
|
||||
description="组件标识符到组件对象的映射"
|
||||
)
|
||||
|
||||
def __init__(self, **data):
|
||||
"""初始化方法,构建组件映射"""
|
||||
super().__init__(**data)
|
||||
# 构建组件映射字典,提高查找效率
|
||||
self._component_map = {
|
||||
comp.identifier: comp
|
||||
for comp in self.design_spec.prompts
|
||||
}
|
||||
|
||||
def _process_special_component(self, component: PromptComponent) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
处理特殊组件(marker为True的组件)
|
||||
|
||||
参数:
|
||||
component: 要处理的组件
|
||||
|
||||
返回:
|
||||
Optional[Dict[str, Any]]: 处理后的消息,如果组件无法处理则返回None
|
||||
"""
|
||||
try:
|
||||
# 尝试将标识符转换为枚举
|
||||
special_id = SpecialIdentifier(component.identifier)
|
||||
|
||||
# 根据不同标识符执行不同处理逻辑
|
||||
if special_id == SpecialIdentifier.CHAT_HISTORY:
|
||||
return self._handle_chat_history(component)
|
||||
elif special_id == SpecialIdentifier.WORLD_INFO_BEFORE:
|
||||
return self._handle_world_info_before(component)
|
||||
elif special_id == SpecialIdentifier.WORLD_INFO_AFTER:
|
||||
return self._handle_world_info_after(component)
|
||||
elif special_id == SpecialIdentifier.CHAR_DESCRIPTION:
|
||||
return self._handle_char_description(component)
|
||||
else:
|
||||
# 未知特殊组件,使用默认处理
|
||||
return self._process_regular_component(component)
|
||||
except ValueError:
|
||||
# 不是特殊标识符,使用默认处理
|
||||
return self._process_regular_component(component)
|
||||
|
||||
def _process_special_component(self, component: PromptComponent) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
处理特殊组件(marker为True的组件)
|
||||
|
||||
参数:
|
||||
component: 要处理的组件
|
||||
|
||||
返回:
|
||||
Optional[Dict[str, Any]]: 处理后的消息,如果组件无法处理则返回None
|
||||
"""
|
||||
try:
|
||||
# 尝试将标识符转换为枚举
|
||||
special_id = SpecialIdentifier(component.identifier)
|
||||
|
||||
# 根据不同标识符执行不同处理逻辑
|
||||
if special_id == SpecialIdentifier.CHAT_HISTORY:
|
||||
return self._handle_chat_history(component)
|
||||
elif special_id == SpecialIdentifier.WORLD_INFO_BEFORE:
|
||||
return self._handle_world_info_before(component)
|
||||
elif special_id == SpecialIdentifier.WORLD_INFO_AFTER:
|
||||
return self._handle_world_info_after(component)
|
||||
elif special_id == SpecialIdentifier.DIALOGUE_EXAMPLES:
|
||||
return self._handle_dialogue_examples(component)
|
||||
elif special_id == SpecialIdentifier.CHAR_DESCRIPTION:
|
||||
return self._handle_char_description(component)
|
||||
elif special_id == SpecialIdentifier.CHAR_PERSONALITY:
|
||||
return self._handle_char_personality(component)
|
||||
elif special_id == SpecialIdentifier.SCENARIO:
|
||||
return self._handle_scenario(component)
|
||||
elif special_id == SpecialIdentifier.PERSONA_DESCRIPTION:
|
||||
return self._handle_persona_description(component)
|
||||
else:
|
||||
# 未知特殊组件,使用默认处理
|
||||
return self._process_regular_component(component)
|
||||
except ValueError:
|
||||
# 不是特殊标识符,使用默认处理
|
||||
return self._process_regular_component(component)
|
||||
|
||||
def _process_regular_component(self, component: PromptComponent) -> Dict[str, Any]:
|
||||
"""
|
||||
处理普通组件(marker为False的组件)
|
||||
|
||||
参数:
|
||||
component: 要处理的组件
|
||||
|
||||
返回:
|
||||
Dict[str, Any]: 处理后的消息
|
||||
"""
|
||||
# 角色映射表
|
||||
role_map = {0: "system", 1: "user", 2: "assistant"}
|
||||
|
||||
# 构建消息
|
||||
message = {
|
||||
"role": role_map.get(component.role, "system"),
|
||||
"content": component.content
|
||||
}
|
||||
|
||||
# 添加系统提示词标记
|
||||
if component.system_prompt:
|
||||
message["system_prompt"] = True
|
||||
|
||||
return message
|
||||
|
||||
# 以下为特殊组件处理方法
|
||||
|
||||
def _handle_chat_history(self, component: PromptComponent) -> Dict[str, Any]:
|
||||
"""
|
||||
处理聊天历史组件
|
||||
|
||||
参数:
|
||||
component: 聊天历史组件
|
||||
|
||||
返回:
|
||||
Dict[str, Any]: 处理后的消息
|
||||
"""
|
||||
# 这里应该从外部获取实际的聊天历史
|
||||
# 示例实现,实际需要根据业务逻辑调整
|
||||
return {
|
||||
"role": "system",
|
||||
"content": "聊天历史内容...",
|
||||
"marker": True,
|
||||
"type": "chat_history"
|
||||
}
|
||||
|
||||
def _handle_world_info_before(self, component: PromptComponent) -> Dict[str, Any]:
|
||||
"""
|
||||
处理前置世界信息组件
|
||||
|
||||
参数:
|
||||
component: 世界信息组件
|
||||
|
||||
返回:
|
||||
Dict[str, Any]: 处理后的消息
|
||||
"""
|
||||
# 这里应该从外部获取实际的世界信息
|
||||
return {
|
||||
"role": "system",
|
||||
"content": "前置世界信息...",
|
||||
"marker": True,
|
||||
"type": "world_info_before"
|
||||
}
|
||||
|
||||
def _handle_world_info_after(self, component: PromptComponent) -> Dict[str, Any]:
|
||||
"""
|
||||
处理后置世界信息组件
|
||||
|
||||
参数:
|
||||
component: 世界信息组件
|
||||
|
||||
返回:
|
||||
Dict[str, Any]: 处理后的消息
|
||||
"""
|
||||
# 这里应该从外部获取实际的世界信息
|
||||
return {
|
||||
"role": "system",
|
||||
"content": "后置世界信息...",
|
||||
"marker": True,
|
||||
"type": "world_info_after"
|
||||
}
|
||||
|
||||
|
||||
def _handle_char_description(self, component: PromptComponent) -> Dict[str, Any]:
|
||||
"""
|
||||
处理角色描述组件
|
||||
|
||||
参数:
|
||||
component: 角色描述组件
|
||||
|
||||
返回:
|
||||
Dict[str, Any]: 处理后的消息
|
||||
"""
|
||||
# 这里应该从外部获取实际的角色描述
|
||||
return {
|
||||
"role": "system",
|
||||
"content": "角色描述内容...",
|
||||
"marker": True,
|
||||
"type": "char_description"
|
||||
}
|
||||
|
||||
@@ -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,9 +1,9 @@
|
||||
// frontend-react/src/App.jsx
|
||||
import React from 'react';
|
||||
import Toolbar from './components/ToolBar/ToolBar';
|
||||
import ChatBox from './components/ChatBox/ChatBox';
|
||||
import DicePanel from './components/DicePanel/DicePanel';
|
||||
import ImageDisplay from './components/ImageDisplay/ImageDisplay';
|
||||
import PresetPanel from './components/PresetPanel/PresetPanel';
|
||||
import SideBarLeft from './components/SideBarLeft/SideBarLeft';
|
||||
import SideBarRight from './components/SideBarRight/SideBarRight';
|
||||
import './index.css';
|
||||
|
||||
function App() {
|
||||
@@ -14,9 +14,7 @@ function App() {
|
||||
{/* 主内容容器 */}
|
||||
<div className="main-container">
|
||||
{/* 左侧栏 - 预设面板 */}
|
||||
<div className="sidebar-left">
|
||||
<PresetPanel />
|
||||
</div>
|
||||
<SideBarLeft />
|
||||
|
||||
{/* 中间栏:聊天框 */}
|
||||
<div className="chat-area">
|
||||
@@ -24,14 +22,7 @@ function App() {
|
||||
</div>
|
||||
|
||||
{/* 右侧栏 */}
|
||||
<div className="sidebar-right">
|
||||
<div className="right-top">
|
||||
<ImageDisplay /> {/* 图片展示放在顶部 */}
|
||||
</div>
|
||||
<div className="right-bottom">
|
||||
<DicePanel /> {/* 骰子面板放在底部 */}
|
||||
</div>
|
||||
</div>
|
||||
<SideBarRight />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -51,10 +51,10 @@ const useChatBoxStore = create(
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
// 设置生成状态
|
||||
setIsGenerating: (status) => set({ isGenerating: status }),
|
||||
|
||||
// 发送消息
|
||||
sendMessage: async (content) => {
|
||||
const { messages, userName, characterName, currentRole, currentChat } = get();
|
||||
|
||||
@@ -69,15 +69,15 @@ const useChatBoxStore = create(
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/chat_box/send_message', {
|
||||
const response = await fetch(`/api/chats/${encodeURIComponent(currentRole)}/${encodeURIComponent(currentChat)}/messages`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
role_name: currentRole,
|
||||
chat_name: currentChat,
|
||||
message: content
|
||||
floor: messages.length + 1,
|
||||
mes: content,
|
||||
is_user: true
|
||||
})
|
||||
});
|
||||
|
||||
@@ -105,20 +105,21 @@ const useChatBoxStore = create(
|
||||
|
||||
// 终止生成
|
||||
stopGeneration: () => set({ isGenerating: false }),
|
||||
|
||||
// 加载聊天历史
|
||||
fetchChatHistory: async (roleName, chatName) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const response = await fetch(`/api/chat_box/get_chat_history?role_name=${encodeURIComponent(roleName)}&chat_name=${encodeURIComponent(chatName)}`);
|
||||
const response = await fetch(`/api/chats/${encodeURIComponent(roleName)}/${encodeURIComponent(chatName)}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch chat history');
|
||||
}
|
||||
const data = await response.json();
|
||||
// 修改数据处理逻辑,适配API返回的数据结构
|
||||
|
||||
set({
|
||||
messages: data || [], // 直接使用返回的数组
|
||||
userName: 'User', // 固定用户名
|
||||
characterName: roleName || 'Assistant', // 使用角色名作为角色名称
|
||||
messages: data.messages || [],
|
||||
userName: data.metadata?.user_name || 'User',
|
||||
characterName: data.metadata?.character_name || roleName || 'Assistant',
|
||||
isLoading: false
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -129,7 +130,6 @@ const useChatBoxStore = create(
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
// 清空聊天历史
|
||||
clearChatHistory: () => set({
|
||||
messages: [],
|
||||
@@ -139,11 +139,119 @@ const useChatBoxStore = create(
|
||||
}),
|
||||
|
||||
// 更新特定消息的内容
|
||||
updateMessage: (id, content) => set((state) => ({
|
||||
updateMessage: async (floor, content) => {
|
||||
const { currentRole, currentChat } = get();
|
||||
try {
|
||||
const response = await fetch(`/api/chats/${encodeURIComponent(currentRole)}/${encodeURIComponent(currentChat)}/messages/${floor}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ mes: content })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update message');
|
||||
}
|
||||
|
||||
set((state) => ({
|
||||
messages: state.messages.map((msg) =>
|
||||
msg.id === id ? { ...msg, content } : msg
|
||||
msg.floor === floor ? { ...msg, mes: content } : msg
|
||||
)
|
||||
})),
|
||||
}));
|
||||
} catch (error) {
|
||||
set({ error: error.message });
|
||||
}
|
||||
},
|
||||
|
||||
// 删除特定消息
|
||||
deleteMessage: async (floor) => {
|
||||
const { currentRole, currentChat } = get();
|
||||
try {
|
||||
const response = await fetch(`/api/chats/${encodeURIComponent(currentRole)}/${encodeURIComponent(currentChat)}/messages/${floor}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete message');
|
||||
}
|
||||
|
||||
set((state) => ({
|
||||
messages: state.messages.filter((msg) => msg.floor !== floor)
|
||||
}));
|
||||
} catch (error) {
|
||||
set({ error: error.message });
|
||||
}
|
||||
},
|
||||
|
||||
// 创建新聊天
|
||||
createChat: async (roleName, chatName, metadata = {}) => {
|
||||
try {
|
||||
const response = await fetch(`/api/chats/${encodeURIComponent(roleName)}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
chat_name: chatName,
|
||||
metadata: {
|
||||
user_name: 'User',
|
||||
character_name: roleName,
|
||||
...metadata
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to create chat');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
set({ error: error.message });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// 更新聊天元数据
|
||||
updateChatMetadata: async (roleName, chatName, metadata) => {
|
||||
try {
|
||||
const response = await fetch(`/api/chats/${encodeURIComponent(roleName)}/${encodeURIComponent(chatName)}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ metadata })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update chat metadata');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
set({ error: error.message });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// 删除聊天
|
||||
deleteChat: async (roleName, chatName) => {
|
||||
try {
|
||||
const response = await fetch(`/api/chats/${encodeURIComponent(roleName)}/${encodeURIComponent(chatName)}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete chat');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
set({ error: error.message });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}))
|
||||
);
|
||||
|
||||
@@ -164,7 +272,4 @@ useChatBoxStore.subscribe(
|
||||
{ equalityFn: (a, b) => a.role === b.role && a.chat === b.chat }
|
||||
);
|
||||
|
||||
|
||||
|
||||
|
||||
export default useChatBoxStore;
|
||||
|
||||
356
frontend-react/src/Store/Slices/LeftTabsSlices/PresetSlice.jsx
Normal file
356
frontend-react/src/Store/Slices/LeftTabsSlices/PresetSlice.jsx
Normal file
@@ -0,0 +1,356 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
const usePresetStore = create((set, get) => ({
|
||||
// 预设选择
|
||||
selectedPreset: '',
|
||||
|
||||
// 核心参数
|
||||
parameters: {
|
||||
temperature: 1.0,
|
||||
frequency_penalty: 0.0,
|
||||
presence_penalty: 0.0,
|
||||
top_p: 1.0,
|
||||
top_k: 0,
|
||||
max_context: 1000000,
|
||||
max_tokens: 30000,
|
||||
max_context_unlocked: false,
|
||||
stream_openai: true,
|
||||
seed: -1,
|
||||
n: 1
|
||||
},
|
||||
|
||||
// 可用的预设列表 - 初始为空,将从后端加载
|
||||
presets: [],
|
||||
|
||||
// 是否正在加载预设列表
|
||||
isLoadingPresets: false,
|
||||
|
||||
// 参数设置折叠状态
|
||||
isParametersExpanded: true,
|
||||
|
||||
// 预设组件列表
|
||||
promptComponents: [
|
||||
{
|
||||
identifier: "dialogueExamples",
|
||||
name: "Chat Examples",
|
||||
system_prompt: true,
|
||||
marker: true,
|
||||
enabled: true,
|
||||
role: 0
|
||||
},
|
||||
{
|
||||
identifier: "chatHistory",
|
||||
name: "Chat History",
|
||||
system_prompt: true,
|
||||
marker: true,
|
||||
enabled: true,
|
||||
role: 0
|
||||
},
|
||||
{
|
||||
identifier: "worldInfoAfter",
|
||||
name: "World Info (after)",
|
||||
system_prompt: true,
|
||||
marker: true,
|
||||
enabled: true,
|
||||
role: 0
|
||||
},
|
||||
{
|
||||
identifier: "worldInfoBefore",
|
||||
name: "World Info (before)",
|
||||
system_prompt: true,
|
||||
marker: true,
|
||||
enabled: true,
|
||||
role: 0
|
||||
},
|
||||
{
|
||||
identifier: "charDescription",
|
||||
name: "Char Description",
|
||||
system_prompt: true,
|
||||
marker: true,
|
||||
enabled: true,
|
||||
role: 0
|
||||
},
|
||||
{
|
||||
identifier: "charPersonality",
|
||||
name: "Char Personality",
|
||||
system_prompt: true,
|
||||
marker: true,
|
||||
enabled: true,
|
||||
role: 0
|
||||
},
|
||||
{
|
||||
identifier: "scenario",
|
||||
name: "Scenario",
|
||||
system_prompt: true,
|
||||
marker: true,
|
||||
enabled: true,
|
||||
role: 0
|
||||
},
|
||||
{
|
||||
identifier: "personaDescription",
|
||||
name: "Persona Description",
|
||||
system_prompt: true,
|
||||
marker: true,
|
||||
enabled: true,
|
||||
role: 0
|
||||
}
|
||||
],
|
||||
|
||||
// 从后端加载预设列表
|
||||
fetchPresets: async () => {
|
||||
set({ isLoadingPresets: true });
|
||||
try {
|
||||
const response = await fetch('/api/presets');
|
||||
const data = await response.json();
|
||||
|
||||
// 转换为预设对象数组
|
||||
const presetList = data.presets.map(preset => ({
|
||||
id: preset.name,
|
||||
name: preset.name,
|
||||
description: preset.description,
|
||||
component_count: preset.component_count,
|
||||
temperature: preset.temperature
|
||||
}));
|
||||
|
||||
set({ presets: presetList, isLoadingPresets: false });
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch presets:', error);
|
||||
set({ isLoadingPresets: false });
|
||||
}
|
||||
},
|
||||
|
||||
// 设置选中的预设
|
||||
setSelectedPreset: async (presetId) => {
|
||||
try {
|
||||
// 从后端获取预设的完整内容
|
||||
const response = await fetch(`/api/presets/${presetId}`);
|
||||
const presetData = await response.json();
|
||||
|
||||
// 记录原始数据用于调试
|
||||
console.log('从后端获取的预设数据:', presetData);
|
||||
|
||||
// 提取参数并更新状态,确保所有参数都有默认值
|
||||
const parameters = {
|
||||
temperature: presetData.temperature !== undefined ? presetData.temperature : 1.0,
|
||||
frequency_penalty: presetData.frequency_penalty !== undefined ? presetData.frequency_penalty : 0.0,
|
||||
presence_penalty: presetData.presence_penalty !== undefined ? presetData.presence_penalty : 0.0,
|
||||
top_p: presetData.top_p !== undefined ? presetData.top_p : 1.0,
|
||||
top_k: presetData.top_k !== undefined ? presetData.top_k : 0,
|
||||
max_context: presetData.openai_max_context !== undefined ? presetData.openai_max_context :
|
||||
(presetData.max_context !== undefined ? presetData.max_context : 1000000),
|
||||
max_tokens: presetData.openai_max_tokens !== undefined ? presetData.openai_max_tokens :
|
||||
(presetData.max_tokens !== undefined ? presetData.max_tokens : 30000),
|
||||
max_context_unlocked: presetData.max_context_unlocked !== undefined ? presetData.max_context_unlocked : false,
|
||||
stream_openai: presetData.stream_openai !== undefined ? presetData.stream_openai : true,
|
||||
seed: presetData.seed !== undefined ? presetData.seed : -1,
|
||||
n: presetData.n !== undefined ? presetData.n : 1
|
||||
};
|
||||
|
||||
// 记录映射后的参数用于调试
|
||||
console.log('映射后的参数:', parameters);
|
||||
|
||||
// 处理预设组件
|
||||
let components = [];
|
||||
if (presetData.prompts && Array.isArray(presetData.prompts)) {
|
||||
// 获取当前角色的prompt_order,添加更严格的检查
|
||||
const currentOrder = (presetData.prompt_order &&
|
||||
Array.isArray(presetData.prompt_order) &&
|
||||
presetData.prompt_order.length > 0 &&
|
||||
presetData.prompt_order[0] &&
|
||||
presetData.prompt_order[0].order &&
|
||||
Array.isArray(presetData.prompt_order[0].order))
|
||||
? presetData.prompt_order[0].order
|
||||
: [];
|
||||
|
||||
// 根据prompt_order排序组件
|
||||
components = presetData.prompts.map(prompt => {
|
||||
const orderItem = currentOrder.find(item => item && item.identifier === prompt.identifier);
|
||||
return {
|
||||
...prompt,
|
||||
enabled: orderItem ? orderItem.enabled : true,
|
||||
role: prompt.role !== undefined ? prompt.role : (prompt.system_prompt ? 0 : 1)
|
||||
};
|
||||
});
|
||||
|
||||
// 如果有prompt_order,按照它排序
|
||||
if (currentOrder.length > 0) {
|
||||
components.sort((a, b) => {
|
||||
const indexA = currentOrder.findIndex(item => item && item.identifier === a.identifier);
|
||||
const indexB = currentOrder.findIndex(item => item && item.identifier === b.identifier);
|
||||
return indexA - indexB;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 更新状态,确保参数容器展开
|
||||
set({
|
||||
selectedPreset: presetId,
|
||||
parameters,
|
||||
promptComponents: components,
|
||||
isParametersExpanded: true // 确保参数容器展开
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to load preset:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// 更新参数
|
||||
updateParameter: ({ name, value }) => set((state) => ({
|
||||
parameters: { ...state.parameters, [name]: value }
|
||||
})),
|
||||
|
||||
// 添加预设
|
||||
addPreset: (preset) => set((state) => ({
|
||||
presets: [...state.presets, preset]
|
||||
})),
|
||||
|
||||
// 保存当前设置为预设
|
||||
saveCurrentAsPreset: async ({ name }) => {
|
||||
const state = get();
|
||||
try {
|
||||
// 构建预设数据
|
||||
const presetData = {
|
||||
...state.parameters,
|
||||
prompts: state.promptComponents.map(component => ({
|
||||
identifier: component.identifier,
|
||||
name: component.name,
|
||||
content: component.content || '',
|
||||
role: component.role,
|
||||
system_prompt: component.system_prompt,
|
||||
marker: component.marker
|
||||
})),
|
||||
prompt_order: [{
|
||||
order: state.promptComponents.map(component => ({
|
||||
identifier: component.identifier,
|
||||
enabled: component.enabled !== false
|
||||
}))
|
||||
}]
|
||||
};
|
||||
|
||||
// 发送到后端
|
||||
const response = await fetch('/api/presets', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
preset_name: name,
|
||||
...presetData
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to save preset');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
// 添加到本地预设列表
|
||||
const newPreset = {
|
||||
id: name,
|
||||
name,
|
||||
description: '',
|
||||
component_count: state.promptComponents.length,
|
||||
temperature: state.parameters.temperature
|
||||
};
|
||||
|
||||
set((state) => ({
|
||||
presets: [...state.presets, newPreset],
|
||||
selectedPreset: name
|
||||
}));
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Failed to save preset:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// 编辑预设名称
|
||||
editPresetName: async (presetId, newName) => {
|
||||
try {
|
||||
const response = await fetch(`/api/presets/${presetId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: newName
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update preset name');
|
||||
}
|
||||
|
||||
// 更新本地状态
|
||||
set((state) => ({
|
||||
presets: state.presets.map(preset =>
|
||||
preset.id === presetId ? { ...preset, name: newName } : preset
|
||||
)
|
||||
}));
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Failed to update preset name:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// 切换参数设置折叠状态
|
||||
toggleParametersExpanded: () => set((state) => ({
|
||||
isParametersExpanded: !state.isParametersExpanded
|
||||
})),
|
||||
|
||||
// 设置预设组件列表
|
||||
setPromptComponents: (components) => set({ promptComponents: components }),
|
||||
|
||||
// 更新组件
|
||||
updateComponent: (index, updatedComponent) => set((state) => {
|
||||
const newComponents = [...state.promptComponents];
|
||||
newComponents[index] = { ...newComponents[index], ...updatedComponent };
|
||||
return { promptComponents: newComponents };
|
||||
}),
|
||||
|
||||
// 切换组件启用状态
|
||||
toggleComponentEnabled: (index) => set((state) => {
|
||||
const newComponents = [...state.promptComponents];
|
||||
newComponents[index] = {
|
||||
...newComponents[index],
|
||||
enabled: !newComponents[index].enabled
|
||||
};
|
||||
return { promptComponents: newComponents };
|
||||
}),
|
||||
|
||||
// 添加新组件
|
||||
addComponent: (component) => set((state) => ({
|
||||
promptComponents: [...state.promptComponents, component]
|
||||
})),
|
||||
|
||||
// 删除组件
|
||||
removeComponent: (index) => set((state) => {
|
||||
const newComponents = [...state.promptComponents];
|
||||
newComponents.splice(index, 1);
|
||||
return { promptComponents: newComponents };
|
||||
}),
|
||||
|
||||
// 移动组件位置
|
||||
moveComponent: (fromIndex, toIndex) => set((state) => {
|
||||
const newComponents = [...state.promptComponents];
|
||||
const [movedComponent] = newComponents.splice(fromIndex, 1);
|
||||
newComponents.splice(toIndex, 0, movedComponent);
|
||||
return { promptComponents: newComponents };
|
||||
}),
|
||||
|
||||
// 获取当前预设的prompt_order
|
||||
getPromptOrder: () => {
|
||||
const { promptComponents } = get();
|
||||
return promptComponents.map(component => ({
|
||||
identifier: component.identifier,
|
||||
enabled: component.enabled !== false
|
||||
}));
|
||||
}
|
||||
}));
|
||||
|
||||
export default usePresetStore;
|
||||
@@ -0,0 +1,16 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
const useSideBarLeftStore = create((set) => ({
|
||||
activeTab: 'gallery',
|
||||
|
||||
tabs: [
|
||||
{ id: 'gallery', label: '🖼️ 画廊' },
|
||||
{ id: 'api', label: '🔌 API' },
|
||||
{ id: 'presets', label: '📋 预设' },
|
||||
{ id: 'worldbook', label: '🌍 世界书' }
|
||||
],
|
||||
|
||||
setActiveTab: (tab) => set({ activeTab: tab })
|
||||
}));
|
||||
|
||||
export default useSideBarLeftStore;
|
||||
@@ -0,0 +1,696 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
// LocalStorage 键名
|
||||
const GLOBAL_WORLDBOOKS_KEY = 'global_worldbooks';
|
||||
|
||||
// 辅助函数:从 LocalStorage 加载全局世界书
|
||||
const loadGlobalWorldBooks = () => {
|
||||
try {
|
||||
const stored = localStorage.getItem(GLOBAL_WORLDBOOKS_KEY);
|
||||
return stored ? JSON.parse(stored) : [];
|
||||
} catch (error) {
|
||||
console.error('加载全局世界书失败:', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
// 辅助函数:保存全局世界书到 LocalStorage
|
||||
const saveGlobalWorldBooks = (globalBooks) => {
|
||||
try {
|
||||
localStorage.setItem(GLOBAL_WORLDBOOKS_KEY, JSON.stringify(globalBooks));
|
||||
} catch (error) {
|
||||
console.error('保存全局世界书失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 辅助函数:处理 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: [], // 世界书列表
|
||||
globalWorldBooks: loadGlobalWorldBooks(), // 从 LocalStorage 初始化全局世界书列表
|
||||
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 formData = new FormData();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// 保存到 LocalStorage
|
||||
saveGlobalWorldBooks(updatedGlobalBooks);
|
||||
|
||||
return {
|
||||
loading: false,
|
||||
worldBooks: updatedWorldBooks,
|
||||
globalWorldBooks: updatedGlobalBooks,
|
||||
currentWorldBook: state.currentWorldBook?.name === data.name
|
||||
? data
|
||||
: state.currentWorldBook,
|
||||
success: true,
|
||||
};
|
||||
});
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
set({
|
||||
loading: false,
|
||||
error: error.message,
|
||||
success: false
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// 异步操作:获取所有世界书
|
||||
fetchWorldBooks: async () => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const response = await fetch(`/api/worldbooks/`);
|
||||
const data = await handleResponse(response);
|
||||
|
||||
// 从 LocalStorage 获取全局世界书列表
|
||||
const globalBooks = loadGlobalWorldBooks();
|
||||
|
||||
set({
|
||||
loading: false,
|
||||
worldBooks: data,
|
||||
globalWorldBooks: globalBooks,
|
||||
error: null
|
||||
});
|
||||
return data;
|
||||
} catch (error) {
|
||||
set({
|
||||
loading: false,
|
||||
error: error.message
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// 异步操作:获取指定世界书
|
||||
fetchWorldBook: async (name) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
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, is_global, file }) => {
|
||||
set({ loading: true, error: null, success: false });
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('name', name);
|
||||
if (is_global !== undefined) {
|
||||
formData.append('is_global', is_global);
|
||||
}
|
||||
if (file) {
|
||||
formData.append('file', file);
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/worldbooks/`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const data = await handleResponse(response);
|
||||
|
||||
set(state => {
|
||||
const newWorldBooks = [...state.worldBooks, data];
|
||||
let newGlobalBooks = [...state.globalWorldBooks];
|
||||
|
||||
// 如果是世界书被标记为全局,添加到全局列表
|
||||
if (data.is_global) {
|
||||
newGlobalBooks = [...newGlobalBooks, data];
|
||||
saveGlobalWorldBooks(newGlobalBooks);
|
||||
}
|
||||
|
||||
return {
|
||||
loading: false,
|
||||
worldBooks: newWorldBooks,
|
||||
globalWorldBooks: newGlobalBooks,
|
||||
currentWorldBook: data,
|
||||
success: true,
|
||||
message: '世界书创建成功'
|
||||
};
|
||||
});
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
set({
|
||||
loading: false,
|
||||
error: error.message,
|
||||
success: false
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// 异步操作:更新世界书
|
||||
updateWorldBook: async ({ name, is_global, file }) => {
|
||||
set({ loading: true, error: null, success: false });
|
||||
try {
|
||||
const formData = new FormData();
|
||||
if (is_global !== undefined) {
|
||||
formData.append('is_global', is_global);
|
||||
}
|
||||
if (file) {
|
||||
formData.append('file', file);
|
||||
}
|
||||
|
||||
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 (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);
|
||||
}
|
||||
}
|
||||
|
||||
// 保存到 LocalStorage
|
||||
saveGlobalWorldBooks(updatedGlobalBooks);
|
||||
|
||||
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;
|
||||
}
|
||||
},
|
||||
|
||||
// 异步操作:删除世界书
|
||||
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);
|
||||
|
||||
// 保存到 LocalStorage
|
||||
saveGlobalWorldBooks(filteredGlobalBooks);
|
||||
|
||||
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];
|
||||
}
|
||||
}
|
||||
|
||||
// 保存到 LocalStorage
|
||||
saveGlobalWorldBooks(updatedGlobalBooks);
|
||||
|
||||
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;
|
||||
@@ -0,0 +1,34 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
const useSideBarRightStore = create((set) => ({
|
||||
selectedTabs: ['dice', 'macros'],
|
||||
|
||||
allTabs: [
|
||||
{ id: 'dice', label: '🎲 骰子与工具', component: null },
|
||||
{ id: 'debug', label: '🔍 上下文调试', component: null },
|
||||
{ id: 'macros', label: '🔧 快捷宏', component: null },
|
||||
{ id: 'table', label: '📊 动态表格', component: null }
|
||||
],
|
||||
|
||||
handleTabClick: (tabId) => set((state) => {
|
||||
if (state.selectedTabs.includes(tabId)) {
|
||||
// 如果已选中,则取消选中
|
||||
return { selectedTabs: state.selectedTabs.filter(id => id !== tabId) };
|
||||
} else if (state.selectedTabs.length < 2) {
|
||||
// 如果未选中且少于2个,则添加
|
||||
return { selectedTabs: [...state.selectedTabs, tabId] };
|
||||
} else {
|
||||
// 如果已有2个,则替换最早选中的
|
||||
return { selectedTabs: [...state.selectedTabs.slice(1), tabId] };
|
||||
}
|
||||
}),
|
||||
|
||||
// 设置特定标签的组件
|
||||
setTabComponent: (tabId, component) => set((state) => ({
|
||||
allTabs: state.allTabs.map(tab =>
|
||||
tab.id === tabId ? { ...tab, component } : tab
|
||||
)
|
||||
}))
|
||||
}));
|
||||
|
||||
export default useSideBarRightStore;
|
||||
@@ -3,7 +3,7 @@ import { create } from 'zustand';
|
||||
// 异步获取角色数据
|
||||
const fetchRoleData = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/tool_bar/get_all_role_and_chat', {
|
||||
const response = await fetch('/api/chats', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Cache-Control': 'no-cache',
|
||||
@@ -12,7 +12,19 @@ const fetchRoleData = async () => {
|
||||
}
|
||||
});
|
||||
const data = await response.json();
|
||||
return data;
|
||||
|
||||
// 转换数据格式以适应前端需求
|
||||
const roleData = {};
|
||||
if (data.chats && Array.isArray(data.chats)) {
|
||||
data.chats.forEach(chat => {
|
||||
if (!roleData[chat.role_name]) {
|
||||
roleData[chat.role_name] = [];
|
||||
}
|
||||
roleData[chat.role_name].push(chat.chat_name);
|
||||
});
|
||||
}
|
||||
|
||||
return roleData;
|
||||
} catch (error) {
|
||||
console.error('获取角色数据失败:', error);
|
||||
throw error;
|
||||
@@ -66,13 +78,31 @@ const useRoleSelectorStore = create((set, get) => ({
|
||||
// 同时更新角色和聊天
|
||||
setSelectedRoleAndChat: (role, chat) => set({ selectedRole: role, selectedChat: chat }),
|
||||
|
||||
handleRenameRole: (oldName, newName) => {
|
||||
// 处理角色重命名
|
||||
handleRenameRole: async (oldName, newName) => {
|
||||
const { roleData, selectedRole } = get();
|
||||
if (newName && newName !== oldName) {
|
||||
try {
|
||||
// 获取该角色下的所有聊天
|
||||
const chats = roleData[oldName] || [];
|
||||
|
||||
// 为每个聊天更新元数据中的角色名称
|
||||
for (const chatName of chats) {
|
||||
await fetch(`/api/chats/${encodeURIComponent(oldName)}/${encodeURIComponent(chatName)}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
metadata: { character_name: newName }
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
// 更新本地状态
|
||||
const newRoleData = { ...roleData };
|
||||
const chats = newRoleData[oldName];
|
||||
delete newRoleData[oldName];
|
||||
newRoleData[newName] = chats;
|
||||
delete newRoleData[oldName];
|
||||
|
||||
if (selectedRole === oldName) {
|
||||
set({
|
||||
@@ -86,14 +116,32 @@ const useRoleSelectorStore = create((set, get) => ({
|
||||
editingRole: null
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('重命名角色失败:', error);
|
||||
set({ editingRole: null });
|
||||
}
|
||||
} else {
|
||||
set({ editingRole: null });
|
||||
}
|
||||
},
|
||||
|
||||
handleRenameChat: (oldName, newName) => {
|
||||
// 处理聊天重命名
|
||||
handleRenameChat: async (oldName, newName) => {
|
||||
const { roleData, selectedRole, selectedChat } = get();
|
||||
if (newName && newName !== oldName) {
|
||||
try {
|
||||
// 更新后端聊天名称
|
||||
await fetch(`/api/chats/${encodeURIComponent(selectedRole)}/${encodeURIComponent(oldName)}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
metadata: { chat_name: newName }
|
||||
})
|
||||
});
|
||||
|
||||
// 更新本地状态
|
||||
const newRoleData = { ...roleData };
|
||||
const chatIndex = newRoleData[selectedRole].indexOf(oldName);
|
||||
if (chatIndex !== -1) {
|
||||
@@ -114,17 +162,32 @@ const useRoleSelectorStore = create((set, get) => ({
|
||||
} else {
|
||||
set({ editingChat: null });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('重命名聊天失败:', error);
|
||||
set({ editingChat: null });
|
||||
}
|
||||
} else {
|
||||
set({ editingChat: null });
|
||||
}
|
||||
},
|
||||
|
||||
confirmDelete: () => {
|
||||
// 确认删除
|
||||
confirmDelete: async () => {
|
||||
const { roleData, selectedRole, selectedChat, showDeleteConfirm, deleteType } = get();
|
||||
const newRoleData = { ...roleData };
|
||||
|
||||
try {
|
||||
if (deleteType === 'role') {
|
||||
// 删除角色下的所有聊天
|
||||
const chats = roleData[showDeleteConfirm] || [];
|
||||
for (const chatName of chats) {
|
||||
await fetch(`/api/chats/${encodeURIComponent(showDeleteConfirm)}/${encodeURIComponent(chatName)}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
}
|
||||
|
||||
const newRoleData = { ...roleData };
|
||||
delete newRoleData[showDeleteConfirm];
|
||||
|
||||
if (selectedRole === showDeleteConfirm) {
|
||||
set({
|
||||
roleData: newRoleData,
|
||||
@@ -141,9 +204,16 @@ const useRoleSelectorStore = create((set, get) => ({
|
||||
});
|
||||
}
|
||||
} else if (deleteType === 'chat') {
|
||||
// 删除单个聊天
|
||||
await fetch(`/api/chats/${encodeURIComponent(selectedRole)}/${encodeURIComponent(showDeleteConfirm)}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
const newRoleData = { ...roleData };
|
||||
const chatIndex = newRoleData[selectedRole].indexOf(showDeleteConfirm);
|
||||
if (chatIndex !== -1) {
|
||||
newRoleData[selectedRole].splice(chatIndex, 1);
|
||||
|
||||
if (selectedChat === showDeleteConfirm) {
|
||||
set({
|
||||
roleData: newRoleData,
|
||||
@@ -165,34 +235,84 @@ const useRoleSelectorStore = create((set, get) => ({
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除失败:', error);
|
||||
set({
|
||||
showDeleteConfirm: null,
|
||||
deleteType: null
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// 取消删除
|
||||
cancelDelete: () => set({ showDeleteConfirm: null, deleteType: null }),
|
||||
|
||||
handleAddRole: () => {
|
||||
// 添加新角色
|
||||
handleAddRole: async () => {
|
||||
const { roleData } = get();
|
||||
const newRole = '新角色';
|
||||
|
||||
try {
|
||||
// 创建新角色(通过创建一个默认聊天)
|
||||
await fetch(`/api/chats/${encodeURIComponent(newRole)}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
chat_name: '默认聊天',
|
||||
metadata: {
|
||||
user_name: 'User',
|
||||
character_name: newRole
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
const newRoleData = { ...roleData };
|
||||
newRoleData[newRole] = [];
|
||||
newRoleData[newRole] = ['默认聊天'];
|
||||
set({
|
||||
roleData: newRoleData,
|
||||
editingRole: newRole
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('添加角色失败:', error);
|
||||
}
|
||||
},
|
||||
|
||||
handleAddChat: () => {
|
||||
// 添加新聊天
|
||||
handleAddChat: async () => {
|
||||
const { roleData, selectedRole } = get();
|
||||
if (!selectedRole) return;
|
||||
|
||||
const newChat = '新聊天';
|
||||
|
||||
try {
|
||||
await fetch(`/api/chats/${encodeURIComponent(selectedRole)}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
chat_name: newChat,
|
||||
metadata: {
|
||||
user_name: 'User',
|
||||
character_name: selectedRole
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
const newRoleData = { ...roleData };
|
||||
newRoleData[selectedRole].push(newChat);
|
||||
set({
|
||||
roleData: newRoleData,
|
||||
editingChat: newChat
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('添加聊天失败:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// 重置面板状态
|
||||
resetPanel: () => set({
|
||||
hoveredRole: null,
|
||||
clickedRole: null,
|
||||
|
||||
@@ -1,2 +1,5 @@
|
||||
// frontend-react/src/store/index.js
|
||||
export { default as useRoleSelectorStore } from './roleSelectorStore';
|
||||
export { default as useRoleSelectorStore } from './Slices/RoleSelectorSlice';
|
||||
export { default as useSideBarLeftStore } from './Slices/LeftTabsSlices/SideBarLeftSlice';
|
||||
export { default as useSideBarRightStore } from './Slices/RightTabsSlices/SideBarRightSlice';
|
||||
export { default as useChatBoxStore } from './Slices/ChatBoxSlice';
|
||||
@@ -1,11 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
const DicePanel = () => {
|
||||
return (
|
||||
<div className="dice-panel">
|
||||
<div>骰子区</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DicePanel;
|
||||
@@ -1,11 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
const ImageDisplay = () => {
|
||||
return (
|
||||
<div className="image-display">
|
||||
<div>图片展示区</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImageDisplay;
|
||||
@@ -1,57 +0,0 @@
|
||||
/* ==================== 顶部工具栏区域 ==================== */
|
||||
|
||||
.toolbar {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 50px;
|
||||
background-color: #fff;
|
||||
border-bottom: 1px solid #ddd;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
z-index: 100;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
||||
transition: height 0.3s ease;
|
||||
}
|
||||
|
||||
.toolbar.expanded {
|
||||
height: auto;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.toolbar-content {
|
||||
display: none;
|
||||
width: 100%;
|
||||
padding: 10px 20px;
|
||||
}
|
||||
|
||||
.toolbar.expanded .toolbar-content {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.toolbar-toggle-btn {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
height: 50px;
|
||||
width: 50px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-left: 1px solid #ddd;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
color: #666;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 101;
|
||||
}
|
||||
|
||||
.toolbar-toggle-btn:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import React from 'react';
|
||||
import './PresetPanel.css';
|
||||
|
||||
const PresetPanel = () => {
|
||||
return (
|
||||
<div className="preset-panel">
|
||||
{/* 顶部:预设选择与输入 */}
|
||||
<div className="preset-header">
|
||||
{/* 下拉框 */}
|
||||
<select className="preset-select">
|
||||
<option>选择预设...</option>
|
||||
</select>
|
||||
{/* 输入框组(待定) */}
|
||||
<div className="preset-inputs">
|
||||
{/* inputs here */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 下方:大槽位区域 */}
|
||||
<div className="preset-slots">
|
||||
{/* 槽位列表 */}
|
||||
<div className="slot-item">槽位 1</div>
|
||||
<div className="slot-item">槽位 2</div>
|
||||
{/* ... */}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PresetPanel;
|
||||
@@ -145,7 +145,7 @@
|
||||
flex-direction: column;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
overflow: hidden;
|
||||
min-height: 140px;
|
||||
height: 200px; /* 设置固定高度 */
|
||||
position: relative;
|
||||
/* 添加微妙的边框效果 */
|
||||
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||
@@ -167,7 +167,8 @@
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
flex: 0 0 auto; /* 不再自动伸缩,使用固定高度 */
|
||||
height: 60px; /* 设置固定高度 */
|
||||
}
|
||||
|
||||
.role-header .role-name {
|
||||
@@ -177,12 +178,16 @@
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 80%; /* 限制名称最大宽度 */
|
||||
}
|
||||
|
||||
|
||||
.role-item.active .role-header .role-name {
|
||||
color: #667eea;
|
||||
font-size: 16px; /* 激活状态时字体稍大 */
|
||||
}
|
||||
|
||||
|
||||
.role-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
@@ -191,8 +196,10 @@
|
||||
right: 12px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
z-index: 10; /* 确保操作按钮在最上层 */
|
||||
}
|
||||
|
||||
|
||||
.role-item:hover .role-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -227,13 +234,17 @@
|
||||
border-top: 1px solid #f0f0f0;
|
||||
padding: 8px 0;
|
||||
background-color: #fafbfc;
|
||||
max-height: 140px;
|
||||
height: 120px; /* 设置固定高度 */
|
||||
overflow-y: auto;
|
||||
/* 自定义滚动条样式 */
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #cbd5e1 transparent;
|
||||
flex: 1; /* 让聊天列表占据剩余空间 */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
|
||||
/* Webkit浏览器滚动条样式 */
|
||||
.chat-list::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import useRoleSelectorStore from '../../store/Slices/RoleSelectorSlice';
|
||||
import useRoleSelectorStore from '../../Store/Slices/RoleSelectorSlice';
|
||||
import useChatBoxStore from '../../Store/Slices/ChatBoxSlice';
|
||||
|
||||
import './RoleSelector.css';
|
||||
|
||||
91
frontend-react/src/components/SideBarLeft/SideBarLeft.css
Normal file
91
frontend-react/src/components/SideBarLeft/SideBarLeft.css
Normal file
@@ -0,0 +1,91 @@
|
||||
.sidebar-left {
|
||||
width: 250px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background-color: #ffffff;
|
||||
box-shadow: 2px 0 5px rgba(0, 0, 0, 0.05);
|
||||
border-right: 1px solid #e8e8e8;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.sidebar-tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid #e8e8e8;
|
||||
background-color: #fafafa;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
flex: 1;
|
||||
padding: 12px 5px;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
font-size: 14px;
|
||||
color: #555;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.tab-button:hover {
|
||||
background-color: #f0f0f0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.tab-button.active {
|
||||
color: #4a90e2;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tab-button.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 3px;
|
||||
background-color: #4a90e2;
|
||||
border-radius: 3px 3px 0 0;
|
||||
}
|
||||
|
||||
.sidebar-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 15px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
|
||||
/* 自定义滚动条样式 */
|
||||
.sidebar-content::-webkit-scrollbar,
|
||||
.tab-content::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.sidebar-content::-webkit-scrollbar-track,
|
||||
.tab-content::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
}
|
||||
|
||||
.sidebar-content::-webkit-scrollbar-thumb,
|
||||
.tab-content::-webkit-scrollbar-thumb {
|
||||
background: #c1c1c1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.sidebar-content::-webkit-scrollbar-thumb:hover,
|
||||
.tab-content::-webkit-scrollbar-thumb:hover {
|
||||
background: #a8a8a8;
|
||||
}
|
||||
38
frontend-react/src/components/SideBarLeft/SideBarLeft.jsx
Normal file
38
frontend-react/src/components/SideBarLeft/SideBarLeft.jsx
Normal file
@@ -0,0 +1,38 @@
|
||||
// frontend-react/src/components/SideBarLeft/SideBarLeft.jsx
|
||||
import React from 'react';
|
||||
import './SideBarLeft.css';
|
||||
import { useSideBarLeftStore } from '../../Store/indexStore';
|
||||
import useSideBarRightStore from '../../Store/Slices/LeftTabsSlices/SideBarLeftSlice';
|
||||
import Gallery from './tab/Gallery';
|
||||
import ApiConfig from './tab/ApiConfig';
|
||||
import Presets from './tab/Presets';
|
||||
import WorldBook from './tab/WorldBook';
|
||||
|
||||
const SideBarLeft = () => {
|
||||
const { activeTab, tabs, setActiveTab } = useSideBarLeftStore();
|
||||
|
||||
return (
|
||||
<div className="sidebar-left">
|
||||
<div className="sidebar-tabs">
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
className={`tab-button ${activeTab === tab.id ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="sidebar-content">
|
||||
{activeTab === 'gallery' && <Gallery />}
|
||||
{activeTab === 'api' && <ApiConfig />}
|
||||
{activeTab === 'presets' && <Presets />}
|
||||
{activeTab === 'worldbook' && <WorldBook />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SideBarLeft;
|
||||
13
frontend-react/src/components/SideBarLeft/tab/ApiConfig.jsx
Normal file
13
frontend-react/src/components/SideBarLeft/tab/ApiConfig.jsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import React from 'react';
|
||||
import '../tabcss/ApiConfig.css';
|
||||
|
||||
const ApiConfig = () => {
|
||||
return (
|
||||
<div className="api-config-content">
|
||||
<h2>API配置</h2>
|
||||
{/* 在这里实现API配置的具体内容 */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ApiConfig;
|
||||
13
frontend-react/src/components/SideBarLeft/tab/Gallery.jsx
Normal file
13
frontend-react/src/components/SideBarLeft/tab/Gallery.jsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import React from 'react';
|
||||
import '../tabcss/Gallery.css';
|
||||
|
||||
const Gallery = () => {
|
||||
return (
|
||||
<div className="gallery-content">
|
||||
<h2>画廊</h2>
|
||||
{/* 在这里实现画廊的具体内容 */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Gallery;
|
||||
787
frontend-react/src/components/SideBarLeft/tab/Presets.jsx
Normal file
787
frontend-react/src/components/SideBarLeft/tab/Presets.jsx
Normal file
@@ -0,0 +1,787 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import usePresetStore from '../../../Store/Slices/LeftTabsSlices/PresetSlice';
|
||||
import '../tabcss/Presets.css';
|
||||
|
||||
const PresetPanel = () => {
|
||||
const {
|
||||
selectedPreset,
|
||||
parameters,
|
||||
presets,
|
||||
isLoadingPresets,
|
||||
promptComponents,
|
||||
setSelectedPreset,
|
||||
updateParameter,
|
||||
saveCurrentAsPreset,
|
||||
editPresetName: updatePresetName,
|
||||
isParametersExpanded,
|
||||
toggleParametersExpanded,
|
||||
fetchPresets,
|
||||
setPromptComponents,
|
||||
toggleComponentEnabled,
|
||||
updateComponent,
|
||||
addComponent,
|
||||
removeComponent,
|
||||
moveComponent
|
||||
} = usePresetStore();
|
||||
|
||||
const [showSaveDialog, setShowSaveDialog] = useState(false);
|
||||
const [showEditDialog, setShowEditDialog] = useState(false);
|
||||
const [showImportDialog, setShowImportDialog] = useState(false);
|
||||
const [showComponentEditDialog, setShowComponentEditDialog] = useState(false);
|
||||
const [newPresetName, setNewPresetName] = useState('');
|
||||
const [editPresetId, setEditPresetId] = useState('');
|
||||
const [editPresetName, setEditPresetName] = useState('');
|
||||
const [importPresetData, setImportPresetData] = useState('');
|
||||
const [tooltip, setTooltip] = useState({ visible: false, content: '', x: 0, y: 0 });
|
||||
|
||||
// 组件编辑状态
|
||||
const [editingComponentIndex, setEditingComponentIndex] = useState(-1);
|
||||
const [editComponentContent, setEditComponentContent] = useState('');
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
// 拖拽状态
|
||||
const [draggedItem, setDraggedItem] = useState(null);
|
||||
const [dragOverItem, setDragOverItem] = useState(null);
|
||||
|
||||
// 参数描述映射
|
||||
const parameterDescriptions = {
|
||||
temperature: "生成温度,控制随机性(0-2)",
|
||||
frequency_penalty: "频率惩罚,降低重复token概率",
|
||||
presence_penalty: "存在惩罚,鼓励谈论新话题",
|
||||
top_p: "核采样,控制词汇选择范围",
|
||||
top_k: "随机采样范围,从概率最高的K个词中选择",
|
||||
max_context: "上下文窗口大小(Token上限)",
|
||||
max_tokens: "单次回复的最大长度",
|
||||
max_context_unlocked: "是否允许超出限制的上下文",
|
||||
stream_openai: "是否使用流式输出",
|
||||
seed: "随机种子(-1为随机)",
|
||||
n: "生成回复的数量"
|
||||
};
|
||||
|
||||
// 显示工具提示
|
||||
const showTooltip = (event, content) => {
|
||||
setTooltip({
|
||||
visible: true,
|
||||
content,
|
||||
x: event.clientX,
|
||||
y: event.clientY
|
||||
});
|
||||
};
|
||||
|
||||
// 隐藏工具提示
|
||||
const hideTooltip = () => {
|
||||
setTooltip({ ...tooltip, visible: false });
|
||||
};
|
||||
|
||||
// 处理参数更新
|
||||
const handleParameterChange = (name, value) => {
|
||||
let convertedValue = value;
|
||||
if (name === 'temperature' || name === 'frequency_penalty' || name === 'presence_penalty' || name === 'top_p') {
|
||||
convertedValue = parseFloat(value);
|
||||
} else if (name === 'top_k' || name === 'max_context' || name === 'max_tokens' || name === 'seed' || name === 'n') {
|
||||
convertedValue = parseInt(value, 10);
|
||||
} else if (name === 'max_context_unlocked' || name === 'stream_openai') {
|
||||
convertedValue = value;
|
||||
}
|
||||
|
||||
updateParameter({ name, value: convertedValue });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchPresets();
|
||||
}, [fetchPresets]);
|
||||
|
||||
// 保存当前设置为预设
|
||||
const handleSavePreset = async () => {
|
||||
if (newPresetName.trim()) {
|
||||
try {
|
||||
await saveCurrentAsPreset({ name: newPresetName });
|
||||
setNewPresetName('');
|
||||
setShowSaveDialog(false);
|
||||
// 重新加载预设列表
|
||||
fetchPresets();
|
||||
} catch (error) {
|
||||
console.error('保存预设失败:', error);
|
||||
alert('保存预设失败: ' + error.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 编辑预设名称
|
||||
const handleEditPreset = async () => {
|
||||
if (editPresetId && editPresetName.trim()) {
|
||||
try {
|
||||
await updatePresetName(editPresetId, editPresetName);
|
||||
setEditPresetId('');
|
||||
setEditPresetName('');
|
||||
setShowEditDialog(false);
|
||||
// 重新加载预设列表
|
||||
fetchPresets();
|
||||
} catch (error) {
|
||||
console.error('编辑预设名称失败:', error);
|
||||
alert('编辑预设名称失败: ' + error.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 导入预设
|
||||
const handleImportPreset = async () => {
|
||||
try {
|
||||
const importedPreset = JSON.parse(importPresetData);
|
||||
if (importedPreset.name && importedPreset.parameters) {
|
||||
await saveCurrentAsPreset({ name: importedPreset.name });
|
||||
// 更新参数
|
||||
Object.keys(importedPreset.parameters).forEach(key => {
|
||||
updateParameter({ name: key, value: importedPreset.parameters[key] });
|
||||
});
|
||||
|
||||
// 更新组件列表
|
||||
if (importedPreset.promptComponents) {
|
||||
setPromptComponents(importedPreset.promptComponents);
|
||||
}
|
||||
|
||||
setImportPresetData('');
|
||||
setShowImportDialog(false);
|
||||
// 重新加载预设列表
|
||||
fetchPresets();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('导入预设失败:', error);
|
||||
alert('导入预设失败: ' + error.message);
|
||||
}
|
||||
};
|
||||
|
||||
// 导出预设
|
||||
const handleExportPreset = () => {
|
||||
if (selectedPreset) {
|
||||
const preset = presets.find(p => p.id === selectedPreset);
|
||||
if (preset) {
|
||||
const exportData = {
|
||||
...preset,
|
||||
parameters,
|
||||
promptComponents
|
||||
};
|
||||
const dataStr = JSON.stringify(exportData, null, 2);
|
||||
const dataBlob = new Blob([dataStr], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(dataBlob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `${preset.name}.json`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 添加新组件
|
||||
const handleAddNewComponent = () => {
|
||||
const newComponent = {
|
||||
identifier: `component_${Date.now()}`,
|
||||
name: '新组件',
|
||||
content: '',
|
||||
role: 0,
|
||||
system_prompt: false,
|
||||
marker: false,
|
||||
enabled: true
|
||||
};
|
||||
addComponent(newComponent);
|
||||
};
|
||||
|
||||
// 开始编辑组件
|
||||
const handleStartEditComponent = (index) => {
|
||||
setEditingComponentIndex(index);
|
||||
setEditComponentContent(promptComponents[index].content);
|
||||
setIsEditing(true);
|
||||
setShowComponentEditDialog(true);
|
||||
};
|
||||
|
||||
// 查看组件内容
|
||||
const handleViewComponent = (index) => {
|
||||
setEditingComponentIndex(index);
|
||||
setEditComponentContent(promptComponents[index].content);
|
||||
setIsEditing(false);
|
||||
setShowComponentEditDialog(true);
|
||||
};
|
||||
|
||||
// 保存组件编辑
|
||||
const handleSaveComponentEdit = () => {
|
||||
if (editingComponentIndex >= 0 && isEditing) {
|
||||
updateComponent(editingComponentIndex, { content: editComponentContent });
|
||||
}
|
||||
setEditingComponentIndex(-1);
|
||||
setEditComponentContent('');
|
||||
setShowComponentEditDialog(false);
|
||||
};
|
||||
|
||||
// 取消组件编辑
|
||||
const handleCancelComponentEdit = () => {
|
||||
setEditingComponentIndex(-1);
|
||||
setEditComponentContent('');
|
||||
setShowComponentEditDialog(false);
|
||||
};
|
||||
|
||||
// 关闭组件查看对话框
|
||||
const handleCloseComponentView = () => {
|
||||
setEditingComponentIndex(-1);
|
||||
setEditComponentContent('');
|
||||
setShowComponentEditDialog(false);
|
||||
};
|
||||
|
||||
// 切换组件启用状态
|
||||
const handleToggleComponentEnabled = (index) => {
|
||||
toggleComponentEnabled(index);
|
||||
};
|
||||
|
||||
// 删除组件
|
||||
const handleDeleteComponent = (index) => {
|
||||
if (window.confirm('确定要删除这个组件吗?')) {
|
||||
removeComponent(index);
|
||||
}
|
||||
};
|
||||
|
||||
// 拖拽开始
|
||||
const handleDragStart = (e, index) => {
|
||||
setDraggedItem(index);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', index.toString());
|
||||
setTimeout(() => {
|
||||
e.target.classList.add('dragging');
|
||||
}, 0);
|
||||
};
|
||||
|
||||
// 拖拽结束
|
||||
const handleDragEnd = (e) => {
|
||||
setDraggedItem(null);
|
||||
setDragOverItem(null);
|
||||
e.target.classList.remove('dragging');
|
||||
};
|
||||
|
||||
// 拖拽经过
|
||||
const handleDragOver = (e, index) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
if (draggedItem === null || draggedItem === index) return;
|
||||
setDragOverItem(index);
|
||||
};
|
||||
|
||||
// 放置
|
||||
const handleDrop = (e, index) => {
|
||||
e.preventDefault();
|
||||
if (draggedItem === null || draggedItem === index) return;
|
||||
|
||||
moveComponent(draggedItem, index);
|
||||
|
||||
setDraggedItem(null);
|
||||
setDragOverItem(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="preset-panel">
|
||||
{/* 工具提示 */}
|
||||
{tooltip.visible && (
|
||||
<div
|
||||
className="tooltip"
|
||||
style={{ left: `${tooltip.x}px`, top: `${tooltip.y}px` }}
|
||||
>
|
||||
{tooltip.content}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 顶部:预设选择与操作 */}
|
||||
<div className="preset-header">
|
||||
{/* 预设选择下拉框 */}
|
||||
<div className="preset-select-container">
|
||||
<label
|
||||
className="preset-label"
|
||||
onMouseEnter={(e) => showTooltip(e, "选择预设配置")}
|
||||
onMouseLeave={hideTooltip}
|
||||
>
|
||||
预设:
|
||||
</label>
|
||||
<select
|
||||
className="preset-select"
|
||||
value={selectedPreset}
|
||||
onChange={(e) => setSelectedPreset(e.target.value)}
|
||||
disabled={isLoadingPresets}
|
||||
>
|
||||
<option value="">{isLoadingPresets ? "加载中..." : "选择预设..."}</option>
|
||||
{presets.map(preset => (
|
||||
<option key={preset.id} value={preset.id}>{preset.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="preset-actions">
|
||||
<button
|
||||
className="preset-action-btn"
|
||||
onClick={() => setShowSaveDialog(true)}
|
||||
onMouseEnter={(e) => showTooltip(e, "保存当前设置为新预设")}
|
||||
onMouseLeave={hideTooltip}
|
||||
>
|
||||
💾
|
||||
</button>
|
||||
<button
|
||||
className="preset-action-btn"
|
||||
onClick={() => {
|
||||
if (selectedPreset) {
|
||||
const preset = presets.find(p => p.id === selectedPreset);
|
||||
if (preset) {
|
||||
setEditPresetId(selectedPreset);
|
||||
setEditPresetName(preset.name);
|
||||
setShowEditDialog(true);
|
||||
}
|
||||
}
|
||||
}}
|
||||
onMouseEnter={(e) => showTooltip(e, "编辑当前预设")}
|
||||
onMouseLeave={hideTooltip}
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
<button
|
||||
className="preset-action-btn"
|
||||
onClick={() => setShowImportDialog(true)}
|
||||
onMouseEnter={(e) => showTooltip(e, "导入预设")}
|
||||
onMouseLeave={hideTooltip}
|
||||
>
|
||||
📥
|
||||
</button>
|
||||
<button
|
||||
className="preset-action-btn"
|
||||
onClick={handleExportPreset}
|
||||
onMouseEnter={(e) => showTooltip(e, "导出当前预设")}
|
||||
onMouseLeave={hideTooltip}
|
||||
>
|
||||
📤
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 保存预设对话框 */}
|
||||
{showSaveDialog && (
|
||||
<div className="preset-save-dialog">
|
||||
<input
|
||||
type="text"
|
||||
value={newPresetName}
|
||||
onChange={(e) => setNewPresetName(e.target.value)}
|
||||
placeholder="预设名称"
|
||||
/>
|
||||
<div className="dialog-buttons">
|
||||
<button onClick={handleSavePreset}>保存</button>
|
||||
<button onClick={() => setShowSaveDialog(false)}>取消</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 编辑预设对话框 */}
|
||||
{showEditDialog && (
|
||||
<div className="preset-edit-dialog">
|
||||
<input
|
||||
type="text"
|
||||
value={editPresetName}
|
||||
onChange={(e) => setEditPresetName(e.target.value)}
|
||||
placeholder="预设名称"
|
||||
/>
|
||||
<div className="dialog-buttons">
|
||||
<button onClick={handleEditPreset}>保存</button>
|
||||
<button onClick={() => setShowEditDialog(false)}>取消</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 导入预设对话框 */}
|
||||
{showImportDialog && (
|
||||
<div className="preset-import-dialog">
|
||||
<textarea
|
||||
value={importPresetData}
|
||||
onChange={(e) => setImportPresetData(e.target.value)}
|
||||
placeholder="粘贴预设JSON数据"
|
||||
rows="5"
|
||||
/>
|
||||
<div className="dialog-buttons">
|
||||
<button onClick={handleImportPreset}>导入</button>
|
||||
<button onClick={() => setShowImportDialog(false)}>取消</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 编辑/查看组件内容对话框 */}
|
||||
{showComponentEditDialog && (
|
||||
<div className="component-edit-dialog">
|
||||
<div className="dialog-header">
|
||||
<h3>{isEditing ? '编辑' : '查看'}组件: {editingComponentIndex >= 0 && promptComponents[editingComponentIndex].name}</h3>
|
||||
<button className="close-btn" onClick={handleCloseComponentView}>×</button>
|
||||
</div>
|
||||
<div className="dialog-content">
|
||||
<textarea
|
||||
value={editComponentContent}
|
||||
onChange={(e) => setEditComponentContent(e.target.value)}
|
||||
className="component-textarea"
|
||||
readOnly={!isEditing}
|
||||
rows={20}
|
||||
/>
|
||||
</div>
|
||||
<div className="dialog-footer">
|
||||
<span className="token-count">
|
||||
{editComponentContent ? editComponentContent.length : 0}
|
||||
</span>
|
||||
{isEditing && (
|
||||
<div className="dialog-buttons">
|
||||
<button onClick={handleSaveComponentEdit}>保存</button>
|
||||
<button onClick={handleCancelComponentEdit}>取消</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 参数设置区域 */}
|
||||
<div className="preset-parameters-container">
|
||||
<div
|
||||
className="parameters-header"
|
||||
onClick={toggleParametersExpanded}
|
||||
>
|
||||
<span>参数设置</span>
|
||||
<span className={`expand-icon ${isParametersExpanded ? 'expanded' : ''}`}>▼</span>
|
||||
</div>
|
||||
|
||||
{isParametersExpanded && (
|
||||
<div className="preset-parameters">
|
||||
{/* 温度滑块 */}
|
||||
<div className="parameter-row">
|
||||
<label
|
||||
className="parameter-label"
|
||||
onMouseEnter={(e) => showTooltip(e, parameterDescriptions.temperature)}
|
||||
onMouseLeave={hideTooltip}
|
||||
>
|
||||
Temperature
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="2"
|
||||
step="0.1"
|
||||
value={parameters.temperature}
|
||||
onChange={(e) => handleParameterChange('temperature', e.target.value)}
|
||||
className="parameter-slider"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="2"
|
||||
step="0.1"
|
||||
value={parameters.temperature}
|
||||
onChange={(e) => handleParameterChange('temperature', e.target.value)}
|
||||
className="parameter-number"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 频率惩罚滑块 */}
|
||||
<div className="parameter-row">
|
||||
<label
|
||||
className="parameter-label"
|
||||
onMouseEnter={(e) => showTooltip(e, parameterDescriptions.frequency_penalty)}
|
||||
onMouseLeave={hideTooltip}
|
||||
>
|
||||
Frequency Penalty
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="-2"
|
||||
max="2"
|
||||
step="0.1"
|
||||
value={parameters.frequency_penalty}
|
||||
onChange={(e) => handleParameterChange('frequency_penalty', e.target.value)}
|
||||
className="parameter-slider"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min="-2"
|
||||
max="2"
|
||||
step="0.1"
|
||||
value={parameters.frequency_penalty}
|
||||
onChange={(e) => handleParameterChange('frequency_penalty', e.target.value)}
|
||||
className="parameter-number"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 存在惩罚滑块 */}
|
||||
<div className="parameter-row">
|
||||
<label
|
||||
className="parameter-label"
|
||||
onMouseEnter={(e) => showTooltip(e, parameterDescriptions.presence_penalty)}
|
||||
onMouseLeave={hideTooltip}
|
||||
>
|
||||
Presence Penalty
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="-2"
|
||||
max="2"
|
||||
step="0.1"
|
||||
value={parameters.presence_penalty}
|
||||
onChange={(e) => handleParameterChange('presence_penalty', e.target.value)}
|
||||
className="parameter-slider"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min="-2"
|
||||
max="2"
|
||||
step="0.1"
|
||||
value={parameters.presence_penalty}
|
||||
onChange={(e) => handleParameterChange('presence_penalty', e.target.value)}
|
||||
className="parameter-number"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Top P 滑块 */}
|
||||
<div className="parameter-row">
|
||||
<label
|
||||
className="parameter-label"
|
||||
onMouseEnter={(e) => showTooltip(e, parameterDescriptions.top_p)}
|
||||
onMouseLeave={hideTooltip}
|
||||
>
|
||||
Top P
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.05"
|
||||
value={parameters.top_p}
|
||||
onChange={(e) => handleParameterChange('top_p', e.target.value)}
|
||||
className="parameter-slider"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.05"
|
||||
value={parameters.top_p}
|
||||
onChange={(e) => handleParameterChange('top_p', e.target.value)}
|
||||
className="parameter-number"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Top K 输入框 */}
|
||||
<div className="parameter-row">
|
||||
<label
|
||||
className="parameter-label"
|
||||
onMouseEnter={(e) => showTooltip(e, parameterDescriptions.top_k)}
|
||||
onMouseLeave={hideTooltip}
|
||||
>
|
||||
Top K
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={parameters.top_k}
|
||||
onChange={(e) => handleParameterChange('top_k', e.target.value)}
|
||||
className="parameter-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 最大上下文输入框 */}
|
||||
<div className="parameter-row">
|
||||
<label
|
||||
className="parameter-label"
|
||||
onMouseEnter={(e) => showTooltip(e, parameterDescriptions.max_context)}
|
||||
onMouseLeave={hideTooltip}
|
||||
>
|
||||
Max Context
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="10000000"
|
||||
value={parameters.max_context}
|
||||
onChange={(e) => handleParameterChange('max_context', e.target.value)}
|
||||
className="parameter-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 最大Token输入框 */}
|
||||
<div className="parameter-row">
|
||||
<label
|
||||
className="parameter-label"
|
||||
onMouseEnter={(e) => showTooltip(e, parameterDescriptions.max_tokens)}
|
||||
onMouseLeave={hideTooltip}
|
||||
>
|
||||
Max Tokens
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="100000"
|
||||
value={parameters.max_tokens}
|
||||
onChange={(e) => handleParameterChange('max_tokens', e.target.value)}
|
||||
className="parameter-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 随机种子输入框 */}
|
||||
<div className="parameter-row">
|
||||
<label
|
||||
className="parameter-label"
|
||||
onMouseEnter={(e) => showTooltip(e, parameterDescriptions.seed)}
|
||||
onMouseLeave={hideTooltip}
|
||||
>
|
||||
Seed
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={parameters.seed}
|
||||
onChange={(e) => handleParameterChange('seed', e.target.value)}
|
||||
className="parameter-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 生成数量输入框 */}
|
||||
<div className="parameter-row">
|
||||
<label
|
||||
className="parameter-label"
|
||||
onMouseEnter={(e) => showTooltip(e, parameterDescriptions.n)}
|
||||
onMouseLeave={hideTooltip}
|
||||
>
|
||||
N (生成数量)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={parameters.n}
|
||||
onChange={(e) => handleParameterChange('n', e.target.value)}
|
||||
className="parameter-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 开关选项 */}
|
||||
<div className="parameter-toggles">
|
||||
<div className="toggle-row">
|
||||
<label
|
||||
className="toggle-label"
|
||||
onMouseEnter={(e) => showTooltip(e, parameterDescriptions.max_context_unlocked)}
|
||||
onMouseLeave={hideTooltip}
|
||||
>
|
||||
Max Context Unlocked
|
||||
</label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={parameters.max_context_unlocked}
|
||||
onChange={(e) => handleParameterChange('max_context_unlocked', e.target.checked)}
|
||||
className="toggle-checkbox"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="toggle-row">
|
||||
<label
|
||||
className="toggle-label"
|
||||
onMouseEnter={(e) => showTooltip(e, parameterDescriptions.stream_openai)}
|
||||
onMouseLeave={hideTooltip}
|
||||
>
|
||||
Stream Output
|
||||
</label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={parameters.stream_openai}
|
||||
onChange={(e) => handleParameterChange('stream_openai', e.target.checked)}
|
||||
className="toggle-checkbox"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 预设组件列表 */}
|
||||
<div className="preset-components-section">
|
||||
<div className="components-header">
|
||||
<h3>预设组件</h3>
|
||||
<button
|
||||
onClick={handleAddNewComponent}
|
||||
className="add-component-btn"
|
||||
onMouseEnter={(e) => showTooltip(e, "添加新组件")}
|
||||
onMouseLeave={hideTooltip}
|
||||
>
|
||||
+ 添加组件
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="components-list draggable-container">
|
||||
{promptComponents.map((component, index) => (
|
||||
<React.Fragment key={component.identifier}>
|
||||
{/* 拖拽指示器 - 在组件上方 */}
|
||||
<div
|
||||
className={`drag-indicator ${dragOverItem === index ? 'visible' : ''}`}
|
||||
onDragOver={(e) => handleDragOver(e, index)}
|
||||
onDrop={(e) => handleDrop(e, index)}
|
||||
/>
|
||||
|
||||
{/* 组件项 */}
|
||||
<div
|
||||
className={`prompt-component-item ${!component.enabled ? 'disabled' : ''} ${component.marker ? 'marker' : ''} ${draggedItem === index ? 'dragging' : ''}`}
|
||||
draggable={!component.marker}
|
||||
onDragStart={(e) => handleDragStart(e, index)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<div className="component-header">
|
||||
<div className="component-controls">
|
||||
<div className="drag-handle">⋮⋮</div>
|
||||
<button
|
||||
className={`toggle-btn ${component.enabled ? 'enabled' : 'disabled'}`}
|
||||
onClick={() => handleToggleComponentEnabled(index)}
|
||||
onMouseEnter={(e) => showTooltip(e, component.enabled ? "禁用组件" : "启用组件")}
|
||||
onMouseLeave={hideTooltip}
|
||||
>
|
||||
{component.enabled ? '✓' : '○'}
|
||||
</button>
|
||||
<span className="component-name">{component.name}</span>
|
||||
{component.marker && (
|
||||
<span className="component-marker-badge">🔒</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="component-actions">
|
||||
<button
|
||||
className="edit-btn"
|
||||
onClick={() => handleStartEditComponent(index)}
|
||||
onMouseEnter={(e) => showTooltip(e, "编辑/查看组件")}
|
||||
onMouseLeave={hideTooltip}
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
<span className="token-count">
|
||||
{component.content ? component.content.length : 0}
|
||||
</span>
|
||||
{!component.marker && (
|
||||
<button
|
||||
className="delete-btn"
|
||||
onClick={() => handleDeleteComponent(index)}
|
||||
onMouseEnter={(e) => showTooltip(e, "删除组件")}
|
||||
onMouseLeave={hideTooltip}
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
))}
|
||||
|
||||
{/* 最后一个拖拽指示器 - 在列表末尾 */}
|
||||
<div
|
||||
className={`drag-indicator ${dragOverItem === promptComponents.length ? 'visible' : ''}`}
|
||||
onDragOver={(e) => handleDragOver(e, promptComponents.length)}
|
||||
onDrop={(e) => handleDrop(e, promptComponents.length)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PresetPanel;
|
||||
939
frontend-react/src/components/SideBarLeft/tab/WorldBook.jsx
Normal file
939
frontend-react/src/components/SideBarLeft/tab/WorldBook.jsx
Normal file
@@ -0,0 +1,939 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import '../tabcss/WorldBook.css';
|
||||
import useWorldBookStore from '../../../Store/Slices/LeftTabsSlices/WorldBookSlice';
|
||||
|
||||
const WorldBook = () => {
|
||||
const {
|
||||
worldBooks,
|
||||
globalWorldBooks,
|
||||
currentWorldBook,
|
||||
currentEntries,
|
||||
currentEntry,
|
||||
loading,
|
||||
error,
|
||||
success,
|
||||
message,
|
||||
fetchWorldBooks,
|
||||
fetchWorldBook,
|
||||
createWorldBook,
|
||||
deleteWorldBook,
|
||||
fetchWorldBookEntries,
|
||||
createWorldBookEntry,
|
||||
updateWorldBookEntry,
|
||||
deleteWorldBookEntry,
|
||||
toggleGlobalWorldBook,
|
||||
setCurrentWorldBook,
|
||||
setCurrentEntry,
|
||||
resetCurrentWorldBook,
|
||||
clearError,
|
||||
clearSuccess,
|
||||
} = useWorldBookStore();
|
||||
|
||||
const [newEntry, setNewEntry] = useState({
|
||||
uid: 0,
|
||||
content: '',
|
||||
comment: '',
|
||||
position: 0,
|
||||
order: 100,
|
||||
depth: 4,
|
||||
role: 0,
|
||||
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 [activeTriggerStrategy, setActiveTriggerStrategy] = useState('constant');
|
||||
|
||||
useEffect(() => {
|
||||
fetchWorldBooks();
|
||||
}, [fetchWorldBooks]);
|
||||
|
||||
useEffect(() => {
|
||||
if (success && message) {
|
||||
alert(message);
|
||||
clearSuccess();
|
||||
}
|
||||
}, [success, message, clearSuccess]);
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
alert(error);
|
||||
clearError();
|
||||
}
|
||||
}, [error, clearError]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentEntry && currentEntry.trigger_config) {
|
||||
const triggers = currentEntry.trigger_config.triggers;
|
||||
if (triggers.constant[0]) {
|
||||
setActiveTriggerStrategy('constant');
|
||||
} else if (triggers.keyword[0]) {
|
||||
setActiveTriggerStrategy('keyword');
|
||||
} else if (triggers.rag[0]) {
|
||||
setActiveTriggerStrategy('rag');
|
||||
} else if (triggers.condition[0]) {
|
||||
setActiveTriggerStrategy('condition');
|
||||
}
|
||||
}
|
||||
}, [currentEntry]);
|
||||
|
||||
const handleCreateWorldBook = async () => {
|
||||
const name = prompt('请输入世界书名称:');
|
||||
if (name) {
|
||||
try {
|
||||
await createWorldBook({ name });
|
||||
const newBook = worldBooks.find(wb => wb.name === name);
|
||||
if (newBook) {
|
||||
handleSelectWorldBook(newBook);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('创建世界书失败:', err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
const maxUid = currentEntries.reduce((max, entry) => Math.max(max, entry.uid), 0);
|
||||
const newUid = maxUid + 1;
|
||||
|
||||
const triggerConfig = {
|
||||
triggers: {
|
||||
constant: [newEntry.trigger_config.triggers.constant[0], null],
|
||||
keyword: [!newEntry.trigger_config.triggers.constant[0] && newEntry.trigger_config.triggers.keyword[1].key.length > 0, {
|
||||
key: newEntry.trigger_config.triggers.keyword[1].key,
|
||||
keysecondary: newEntry.trigger_config.triggers.keyword[1].keysecondary,
|
||||
selective: newEntry.trigger_config.triggers.keyword[1].selective,
|
||||
selectiveLogic: newEntry.trigger_config.triggers.keyword[1].selectiveLogic,
|
||||
matchWholeWords: newEntry.trigger_config.triggers.keyword[1].matchWholeWords,
|
||||
caseSensitive: newEntry.trigger_config.triggers.keyword[1].caseSensitive
|
||||
}],
|
||||
rag: [false, {
|
||||
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,
|
||||
trigger_config: triggerConfig
|
||||
};
|
||||
|
||||
try {
|
||||
await createWorldBookEntry(currentWorldBook.name, entryData);
|
||||
setNewEntry({
|
||||
uid: 0,
|
||||
content: '',
|
||||
comment: '',
|
||||
position: 0,
|
||||
order: 100,
|
||||
depth: 4,
|
||||
role: 0,
|
||||
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) => {
|
||||
setCurrentEntry(entry);
|
||||
setShowEditPanel(true);
|
||||
};
|
||||
|
||||
const handleEntryUpdate = async (field, value) => {
|
||||
if (!currentEntry || !currentWorldBook) return;
|
||||
|
||||
const updatedEntry = { ...currentEntry, [field]: value };
|
||||
try {
|
||||
await updateWorldBookEntry(currentWorldBook.name, currentEntry.uid, updatedEntry);
|
||||
setCurrentEntry(updatedEntry);
|
||||
} catch (err) {
|
||||
console.error('更新条目失败:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTriggerStrategyChange = async (strategy) => {
|
||||
if (!currentEntry || !currentWorldBook) return;
|
||||
|
||||
setActiveTriggerStrategy(strategy);
|
||||
|
||||
const updatedTriggers = {
|
||||
constant: [false, 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: ''
|
||||
}]
|
||||
};
|
||||
|
||||
if (strategy !== 'constant') {
|
||||
updatedTriggers[strategy][0] = true;
|
||||
} else {
|
||||
updatedTriggers.constant[0] = true;
|
||||
}
|
||||
|
||||
const updatedTriggerConfig = {
|
||||
...currentEntry.trigger_config,
|
||||
triggers: updatedTriggers
|
||||
};
|
||||
|
||||
try {
|
||||
await updateWorldBookEntry(currentWorldBook.name, currentEntry.uid, {
|
||||
...currentEntry,
|
||||
trigger_config: updatedTriggerConfig
|
||||
});
|
||||
setCurrentEntry({
|
||||
...currentEntry,
|
||||
trigger_config: updatedTriggerConfig
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('更新触发策略失败:', err);
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
const getPositionInfo = (position) => {
|
||||
const positions = {
|
||||
0: { label: '角色定义之后', weight: '高', desc: 'AI读完人设紧接着就读到这里,非常适合补充角色的详细设定、性格细节或特殊规则' },
|
||||
1: { label: '角色定义之前', weight: '中', desc: '在角色卡内容的最上方,通常用于定义角色的基础背景,让人设部分来解释这些背景' },
|
||||
2: { label: '示例对话之前', weight: '低', desc: '在对话示例的最上方' },
|
||||
3: { label: '示例对话之后', weight: '低', desc: '用于在对话开始前提供最后的上下文补充' },
|
||||
4: { label: '系统提示/作者注释', weight: '极高', desc: 'AI对最近看到的信息记忆最清晰,适合动态信息、当前场景描述或临时规则' },
|
||||
5: { label: '作为系统消息', weight: '最高', desc: '强制作为System Prompt插入,通常用于强制指令' }
|
||||
};
|
||||
return positions[position] || positions[0];
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="worldbook-content">
|
||||
{/* 全局世界书区域 */}
|
||||
<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={globalWorldBooks.some(wb => wb.name === book.name)}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation();
|
||||
handleToggleGlobal(book.name, e.target.checked);
|
||||
}}
|
||||
/>
|
||||
<span className="book-name">{book.name}</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">
|
||||
{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.trigger_config.triggers.constant[0] ? '常驻' : '触发'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="compact-params">
|
||||
<div className="param-item">
|
||||
<span className="param-label">位置:</span>
|
||||
<span className="param-value">{getPositionInfo(entry.position).label}</span>
|
||||
</div>
|
||||
<div className="param-item">
|
||||
<span className="param-label">权重:</span>
|
||||
<span className="param-value">{getPositionInfo(entry.position).weight}</span>
|
||||
</div>
|
||||
<div className="param-item">
|
||||
<span className="param-label">顺序:</span>
|
||||
<span className="param-value">{entry.order}</span>
|
||||
</div>
|
||||
<div className="param-item">
|
||||
<span className="param-label">深度:</span>
|
||||
<span className="param-value">{entry.depth}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="loading">暂无条目</div>
|
||||
)}
|
||||
<button className="btn btn-primary" onClick={handleAddEntry}>
|
||||
+ 添加条目
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="loading">请选择一个世界书</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 编辑面板 */}
|
||||
{showEditPanel && currentEntry && (
|
||||
<div className={`edit-panel ${showEditPanel ? 'open' : ''}`}>
|
||||
<div className="edit-panel-header">
|
||||
<h2>编辑条目</h2>
|
||||
<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.comment || ''}
|
||||
onChange={(e) => handleEntryUpdate('comment', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">内容</label>
|
||||
<textarea
|
||||
className="form-input"
|
||||
value={currentEntry.content || ''}
|
||||
onChange={(e) => handleEntryUpdate('content', e.target.value)}
|
||||
rows={10}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">插入位置</label>
|
||||
<div className="position-selector">
|
||||
{[0, 1, 2, 3, 4, 5].map(pos => {
|
||||
const info = getPositionInfo(pos);
|
||||
return (
|
||||
<div
|
||||
key={pos}
|
||||
className={`position-option ${currentEntry.position === pos ? 'active' : ''}`}
|
||||
onClick={() => handleEntryUpdate('position', pos)}
|
||||
>
|
||||
<div className="position-tooltip" data-tooltip={info.desc}>
|
||||
<span className="position-label">{info.label}</span>
|
||||
<span className="position-weight">{info.weight}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">顺序权重</label>
|
||||
<input
|
||||
type="number"
|
||||
className="form-input"
|
||||
value={currentEntry.order || 100}
|
||||
onChange={(e) => handleEntryUpdate('order', parseInt(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">扫描深度</label>
|
||||
<input
|
||||
type="number"
|
||||
className="form-input"
|
||||
value={currentEntry.depth || 4}
|
||||
onChange={(e) => handleEntryUpdate('depth', parseInt(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">角色匹配</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={currentEntry.role || 0}
|
||||
onChange={(e) => handleEntryUpdate('role', parseInt(e.target.value))}
|
||||
>
|
||||
<option value={0}>Both</option>
|
||||
<option value={1}>User</option>
|
||||
<option value={2}>Assistant</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 触发策略选择器 */}
|
||||
<div className="form-group">
|
||||
<label className="form-label">触发策略</label>
|
||||
<div className="trigger-strategy-selector">
|
||||
<button
|
||||
className={`trigger-strategy-btn ${activeTriggerStrategy === 'constant' ? 'active' : ''}`}
|
||||
onClick={() => handleTriggerStrategyChange('constant')}
|
||||
>
|
||||
常驻触发
|
||||
</button>
|
||||
<button
|
||||
className={`trigger-strategy-btn ${activeTriggerStrategy === 'keyword' ? 'active' : ''}`}
|
||||
onClick={() => handleTriggerStrategyChange('keyword')}
|
||||
>
|
||||
关键词触发
|
||||
</button>
|
||||
<button
|
||||
className={`trigger-strategy-btn ${activeTriggerStrategy === 'rag' ? 'active' : ''}`}
|
||||
onClick={() => handleTriggerStrategyChange('rag')}
|
||||
>
|
||||
RAG触发
|
||||
</button>
|
||||
<button
|
||||
className={`trigger-strategy-btn ${activeTriggerStrategy === 'condition' ? 'active' : ''}`}
|
||||
onClick={() => handleTriggerStrategyChange('condition')}
|
||||
>
|
||||
条件触发
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 根据选择的触发策略显示对应的配置表单 */}
|
||||
{activeTriggerStrategy === 'keyword' && (
|
||||
<>
|
||||
<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: [
|
||||
true,
|
||||
{
|
||||
...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: [
|
||||
true,
|
||||
{
|
||||
...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: [
|
||||
true,
|
||||
{
|
||||
...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: [
|
||||
true,
|
||||
{
|
||||
...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: [
|
||||
true,
|
||||
{
|
||||
...currentEntry.trigger_config?.triggers?.keyword?.[1],
|
||||
caseSensitive: e.target.checked
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
handleEntryUpdate('trigger_config', updatedTriggerConfig);
|
||||
}}
|
||||
/>
|
||||
区分大小写
|
||||
</label>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTriggerStrategy === 'rag' && (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<label className="form-label">相似度阈值</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
max="1"
|
||||
className="form-input"
|
||||
value={currentEntry.trigger_config?.triggers?.rag?.[1]?.threshold || 0.75}
|
||||
onChange={(e) => {
|
||||
const updatedTriggerConfig = {
|
||||
...currentEntry.trigger_config,
|
||||
triggers: {
|
||||
...currentEntry.trigger_config?.triggers,
|
||||
rag: [
|
||||
true,
|
||||
{
|
||||
...currentEntry.trigger_config?.triggers?.rag?.[1],
|
||||
threshold: parseFloat(e.target.value)
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
handleEntryUpdate('trigger_config', updatedTriggerConfig);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">返回条目数</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
className="form-input"
|
||||
value={currentEntry.trigger_config?.triggers?.rag?.[1]?.top_k || 5}
|
||||
onChange={(e) => {
|
||||
const updatedTriggerConfig = {
|
||||
...currentEntry.trigger_config,
|
||||
triggers: {
|
||||
...currentEntry.trigger_config?.triggers,
|
||||
rag: [
|
||||
true,
|
||||
{
|
||||
...currentEntry.trigger_config?.triggers?.rag?.[1],
|
||||
top_k: parseInt(e.target.value)
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
handleEntryUpdate('trigger_config', updatedTriggerConfig);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">查询模板</label>
|
||||
<textarea
|
||||
className="form-input"
|
||||
value={currentEntry.trigger_config?.triggers?.rag?.[1]?.query_template || ''}
|
||||
onChange={(e) => {
|
||||
const updatedTriggerConfig = {
|
||||
...currentEntry.trigger_config,
|
||||
triggers: {
|
||||
...currentEntry.trigger_config?.triggers,
|
||||
rag: [
|
||||
true,
|
||||
{
|
||||
...currentEntry.trigger_config?.triggers?.rag?.[1],
|
||||
query_template: e.target.value
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
handleEntryUpdate('trigger_config', updatedTriggerConfig);
|
||||
}}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTriggerStrategy === 'condition' && (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<label className="form-label">变量A</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
value={currentEntry.trigger_config?.triggers?.condition?.[1]?.variable_a || ''}
|
||||
onChange={(e) => {
|
||||
const updatedTriggerConfig = {
|
||||
...currentEntry.trigger_config,
|
||||
triggers: {
|
||||
...currentEntry.trigger_config?.triggers,
|
||||
condition: [
|
||||
true,
|
||||
{
|
||||
...currentEntry.trigger_config?.triggers?.condition?.[1],
|
||||
variable_a: e.target.value
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
handleEntryUpdate('trigger_config', updatedTriggerConfig);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">运算符</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={currentEntry.trigger_config?.triggers?.condition?.[1]?.operator || '='}
|
||||
onChange={(e) => {
|
||||
const updatedTriggerConfig = {
|
||||
...currentEntry.trigger_config,
|
||||
triggers: {
|
||||
...currentEntry.trigger_config?.triggers,
|
||||
condition: [
|
||||
true,
|
||||
{
|
||||
...currentEntry.trigger_config?.triggers?.condition?.[1],
|
||||
operator: e.target.value
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
handleEntryUpdate('trigger_config', updatedTriggerConfig);
|
||||
}}
|
||||
>
|
||||
<option value="等于">等于</option>
|
||||
<option value="大于">大于</option>
|
||||
<option value="小于">小于</option>
|
||||
<option value="不小于">不小于</option>
|
||||
<option value="不大于">不大于</option>
|
||||
<option value="不等于">不等于</option>
|
||||
<option value="包括">包括</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">变量B</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
value={currentEntry.trigger_config?.triggers?.condition?.[1]?.variable_b || ''}
|
||||
onChange={(e) => {
|
||||
const updatedTriggerConfig = {
|
||||
...currentEntry.trigger_config,
|
||||
triggers: {
|
||||
...currentEntry.trigger_config?.triggers,
|
||||
condition: [
|
||||
true,
|
||||
{
|
||||
...currentEntry.trigger_config?.triggers?.condition?.[1],
|
||||
variable_b: e.target.value
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
handleEntryUpdate('trigger_config', updatedTriggerConfig);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="btn btn-danger"
|
||||
onClick={handleDeleteEntry}
|
||||
>
|
||||
删除条目
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WorldBook;
|
||||
@@ -0,0 +1 @@
|
||||
WorldBook.css
|
||||
735
frontend-react/src/components/SideBarLeft/tabcss/Presets.css
Normal file
735
frontend-react/src/components/SideBarLeft/tabcss/Presets.css
Normal file
@@ -0,0 +1,735 @@
|
||||
/* 主容器 */
|
||||
.preset-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: 12px;
|
||||
gap: 12px;
|
||||
background: #f8f9fa;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* 工具提示 */
|
||||
.tooltip {
|
||||
position: fixed;
|
||||
padding: 6px 10px;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
|
||||
transform: translate(-50%, -100%);
|
||||
margin-top: -6px;
|
||||
}
|
||||
|
||||
/* 预设头部 */
|
||||
.preset-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
background: white;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.preset-select-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.preset-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #2c3e50;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.preset-select {
|
||||
flex: 1;
|
||||
padding: 6px 10px;
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
color: #495057;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.preset-select:hover {
|
||||
border-color: #ced4da;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.preset-select:focus {
|
||||
outline: none;
|
||||
border-color: #4a6cf7;
|
||||
box-shadow: 0 0 0 2px rgba(74, 108, 247, 0.1);
|
||||
}
|
||||
|
||||
.preset-select:disabled {
|
||||
background: #f1f3f5;
|
||||
cursor: not-allowed;
|
||||
color: #adb5bd;
|
||||
}
|
||||
|
||||
/* 操作按钮 */
|
||||
.preset-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.preset-action-btn {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: white;
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.preset-action-btn:hover {
|
||||
background: #f8f9fa;
|
||||
border-color: #ced4da;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.preset-action-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* 对话框 */
|
||||
.preset-save-dialog,
|
||||
.preset-edit-dialog,
|
||||
.preset-import-dialog {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: white;
|
||||
padding: 16px;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
z-index: 1000;
|
||||
min-width: 280px;
|
||||
}
|
||||
|
||||
.preset-save-dialog input,
|
||||
.preset-edit-dialog input {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
margin-bottom: 12px;
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.preset-save-dialog input:focus,
|
||||
.preset-edit-dialog input:focus {
|
||||
outline: none;
|
||||
border-color: #4a6cf7;
|
||||
box-shadow: 0 0 0 2px rgba(74, 108, 247, 0.1);
|
||||
}
|
||||
|
||||
.preset-import-dialog textarea {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
margin-bottom: 12px;
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
resize: vertical;
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
.preset-import-dialog textarea:focus {
|
||||
outline: none;
|
||||
border-color: #4a6cf7;
|
||||
box-shadow: 0 0 0 2px rgba(74, 108, 247, 0.1);
|
||||
}
|
||||
|
||||
.dialog-buttons {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.dialog-buttons button {
|
||||
padding: 6px 12px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.dialog-buttons button:first-child {
|
||||
background: #4a6cf7;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.dialog-buttons button:first-child:hover {
|
||||
background: #3a5ce5;
|
||||
}
|
||||
|
||||
.dialog-buttons button:last-child {
|
||||
background: #f1f3f5;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.dialog-buttons button:last-child:hover {
|
||||
background: #e9ecef;
|
||||
}
|
||||
|
||||
/* 组件编辑对话框 */
|
||||
.component-edit-dialog {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: white;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
z-index: 1000;
|
||||
min-width: 450px;
|
||||
max-width: 80vw;
|
||||
max-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.dialog-header h3 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.dialog-header .close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 20px;
|
||||
color: #adb5bd;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.dialog-header .close-btn:hover {
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.dialog-content {
|
||||
padding: 16px;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.component-textarea {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
line-height: 1.5;
|
||||
resize: none;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.component-textarea:focus {
|
||||
outline: none;
|
||||
border-color: #4a6cf7;
|
||||
box-shadow: 0 0 0 2px rgba(74, 108, 247, 0.1);
|
||||
}
|
||||
|
||||
.component-textarea[readonly] {
|
||||
background: #f1f3f5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.token-count {
|
||||
font-size: 11px;
|
||||
color: #6c757d;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 参数设置区域 */
|
||||
.preset-parameters-container {
|
||||
background: white;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.parameters-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
background: #f8f9fa;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.parameters-header:hover {
|
||||
background: #f1f3f5;
|
||||
}
|
||||
|
||||
.parameters-header span:first-child {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.expand-icon {
|
||||
font-size: 10px;
|
||||
color: #6c757d;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.expand-icon.expanded {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.preset-parameters {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.parameter-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.parameter-row:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.parameter-label {
|
||||
width: 100px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.parameter-slider {
|
||||
flex: 1;
|
||||
-webkit-appearance: none;
|
||||
height: 4px;
|
||||
background: #e9ecef;
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.parameter-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: #4a6cf7;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.parameter-slider::-webkit-slider-thumb:hover {
|
||||
background: #3a5ce5;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.parameter-slider::-moz-range-thumb {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: #4a6cf7;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.parameter-slider::-moz-range-thumb:hover {
|
||||
background: #3a5ce5;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.parameter-number {
|
||||
width: 60px;
|
||||
padding: 4px 6px;
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.parameter-number:focus {
|
||||
outline: none;
|
||||
border-color: #4a6cf7;
|
||||
box-shadow: 0 0 0 2px rgba(74, 108, 247, 0.1);
|
||||
}
|
||||
|
||||
.parameter-input {
|
||||
flex: 1;
|
||||
padding: 6px 10px;
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.parameter-input:focus {
|
||||
outline: none;
|
||||
border-color: #4a6cf7;
|
||||
box-shadow: 0 0 0 2px rgba(74, 108, 247, 0.1);
|
||||
}
|
||||
|
||||
.parameter-toggles {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.toggle-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.toggle-label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.toggle-checkbox {
|
||||
width: 38px;
|
||||
height: 20px;
|
||||
-webkit-appearance: none;
|
||||
background: #e9ecef;
|
||||
border-radius: 10px;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.toggle-checkbox::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.toggle-checkbox:checked {
|
||||
background: #4a6cf7;
|
||||
}
|
||||
|
||||
.toggle-checkbox:checked::after {
|
||||
left: 20px;
|
||||
}
|
||||
|
||||
/* 预设组件区域 */
|
||||
.preset-components-section {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: white;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.components-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
background: #f8f9fa;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.components-header h3 {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.add-component-btn {
|
||||
padding: 4px 10px;
|
||||
background: #4a6cf7;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.add-component-btn:hover {
|
||||
background: #3a5ce5;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 4px rgba(74, 108, 247, 0.2);
|
||||
}
|
||||
|
||||
.components-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.drag-indicator {
|
||||
height: 3px;
|
||||
background: #4a6cf7;
|
||||
border-radius: 2px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
margin: 3px 0;
|
||||
}
|
||||
|
||||
.drag-indicator.visible {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.prompt-component-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 10px;
|
||||
margin-bottom: 6px;
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s ease;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.prompt-component-item:hover {
|
||||
background: white;
|
||||
border-color: #ced4da;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.prompt-component-item.disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.prompt-component-item.marker {
|
||||
background: rgba(74, 108, 247, 0.05);
|
||||
border-color: rgba(74, 108, 247, 0.2);
|
||||
}
|
||||
|
||||
.prompt-component-item.dragging {
|
||||
opacity: 0.5;
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.component-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.component-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
color: #adb5bd;
|
||||
cursor: grab;
|
||||
font-size: 12px;
|
||||
user-select: none;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.drag-handle:hover {
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: white;
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
font-size: 10px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.toggle-btn:hover {
|
||||
border-color: #ced4da;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.toggle-btn.enabled {
|
||||
background: #4a6cf7;
|
||||
color: white;
|
||||
border-color: #4a6cf7;
|
||||
}
|
||||
|
||||
.toggle-btn.enabled:hover {
|
||||
background: #3a5ce5;
|
||||
}
|
||||
|
||||
.component-name {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #2c3e50;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.component-marker-badge {
|
||||
padding: 1px 6px;
|
||||
background: rgba(74, 108, 247, 0.1);
|
||||
color: #4a6cf7;
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.component-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.edit-btn {
|
||||
padding: 3px 6px;
|
||||
background: white;
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
color: #495057;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.edit-btn:hover {
|
||||
background: #f8f9fa;
|
||||
border-color: #ced4da;
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
padding: 3px 6px;
|
||||
background: white;
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
color: #dc3545;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.delete-btn:hover {
|
||||
background: #fff5f5;
|
||||
border-color: #ff6b6b;
|
||||
}
|
||||
|
||||
/* 滚动条样式 */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f3f5;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #ced4da;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #adb5bd;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.preset-panel {
|
||||
padding: 10px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.preset-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.preset-select-container {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.preset-actions {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.component-edit-dialog {
|
||||
min-width: 85vw;
|
||||
}
|
||||
}
|
||||
642
frontend-react/src/components/SideBarLeft/tabcss/WorldBook.css
Normal file
642
frontend-react/src/components/SideBarLeft/tabcss/WorldBook.css
Normal file
@@ -0,0 +1,642 @@
|
||||
.worldbook-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: 8px;
|
||||
gap: 8px;
|
||||
background: #f8f9fa;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* 全局世界书区域 */
|
||||
.global-worldbooks-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.global-worldbooks-slot {
|
||||
background: white;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #e9ecef;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.global-books-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
padding: 8px 10px;
|
||||
background: #f8f9fa;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.title-text {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.active-books-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.active-book-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
font-size: 11px;
|
||||
color: #4a6cf7;
|
||||
font-weight: 500;
|
||||
padding: 3px 6px;
|
||||
background: rgba(74, 108, 247, 0.1);
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.active-book-item:hover {
|
||||
background: rgba(74, 108, 247, 0.2);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 1px 2px rgba(74, 108, 247, 0.15);
|
||||
}
|
||||
|
||||
.active-book-item .remove-btn {
|
||||
opacity: 0.7;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.active-book-item .remove-btn:hover {
|
||||
opacity: 1;
|
||||
color: #e74c3c;
|
||||
}
|
||||
|
||||
/* 操作按钮组 */
|
||||
.worldbook-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 4px 8px;
|
||||
background: white;
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 3px;
|
||||
color: #495057;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
transition: all 0.15s ease;
|
||||
font-weight: 500;
|
||||
flex: 1;
|
||||
min-width: 50px;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: #f8f9fa;
|
||||
border-color: #4a6cf7;
|
||||
color: #4a6cf7;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.action-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* 世界书选择区域 */
|
||||
.worldbook-selector {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.dropdown {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.dropdown-btn {
|
||||
width: 100%;
|
||||
padding: 5px 8px;
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 3px;
|
||||
color: #495057;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 11px;
|
||||
transition: all 0.15s ease;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dropdown-btn:hover {
|
||||
background: white;
|
||||
border-color: #ced4da;
|
||||
}
|
||||
|
||||
.dropdown-btn:focus {
|
||||
outline: none;
|
||||
border-color: #4a6cf7;
|
||||
box-shadow: 0 0 0 2px rgba(74, 108, 247, 0.1);
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: white;
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 3px;
|
||||
margin-top: 3px;
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
z-index: 1000;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
padding: 5px 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
font-size: 11px;
|
||||
color: #495057;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dropdown-item:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.dropdown-item.active {
|
||||
background: rgba(74, 108, 247, 0.1);
|
||||
color: #4a6cf7;
|
||||
}
|
||||
|
||||
/* 条目列表区域 */
|
||||
.entries-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.entry-item {
|
||||
padding: 8px;
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.entry-item:hover {
|
||||
background: white;
|
||||
border-color: #ced4da;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.entry-item.active {
|
||||
background: rgba(74, 108, 247, 0.05);
|
||||
border-color: rgba(74, 108, 247, 0.2);
|
||||
}
|
||||
|
||||
.entry-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.entry-name {
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.entry-status {
|
||||
font-size: 10px;
|
||||
color: #6c757d;
|
||||
padding: 2px 5px;
|
||||
border-radius: 2px;
|
||||
background: #f1f3f5;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.entry-status.enabled {
|
||||
color: #4a6cf7;
|
||||
background: rgba(74, 108, 247, 0.1);
|
||||
}
|
||||
|
||||
.entry-meta {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
font-size: 10px;
|
||||
color: #6c757d;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 编辑面板 */
|
||||
.edit-panel {
|
||||
position: fixed;
|
||||
top: 60px;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 280px;
|
||||
background: white;
|
||||
z-index: 1000;
|
||||
padding: 12px;
|
||||
overflow-y: auto;
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.2s ease-out;
|
||||
border-left: 1px solid #e9ecef;
|
||||
box-shadow: -2px 0 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.edit-panel.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.edit-panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.edit-panel-header h2 {
|
||||
font-size: 13px;
|
||||
color: #2c3e50;
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #adb5bd;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
font-size: 11px;
|
||||
color: #495057;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-input,
|
||||
.form-textarea,
|
||||
.form-select {
|
||||
width: 100%;
|
||||
padding: 5px 8px;
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 3px;
|
||||
color: #495057;
|
||||
font-size: 11px;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.form-input:focus,
|
||||
.form-textarea:focus,
|
||||
.form-select:focus {
|
||||
outline: none;
|
||||
border-color: #4a6cf7;
|
||||
box-shadow: 0 0 0 2px rgba(74, 108, 247, 0.1);
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
min-height: 80px;
|
||||
resize: vertical;
|
||||
font-family: inherit;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.form-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
color: #495057;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-checkbox input {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 按钮样式 */
|
||||
.btn {
|
||||
padding: 5px 10px;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
transition: all 0.15s ease;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #4a6cf7;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #3a5ce5;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 1px 2px rgba(74, 108, 247, 0.15);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #e74c3c;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #c0392b;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 1px 2px rgba(231, 76, 60, 0.15);
|
||||
}
|
||||
|
||||
/* 加载和错误状态 */
|
||||
.loading,
|
||||
.error {
|
||||
text-align: center;
|
||||
padding: 16px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.loading {
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #e74c3c;
|
||||
background: rgba(231, 76, 60, 0.1);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* 滚动条样式 */
|
||||
::-webkit-scrollbar {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f3f5;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #ced4da;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #adb5bd;
|
||||
}
|
||||
|
||||
/* 插入位置权重提示 */
|
||||
.position-tooltip {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.position-tooltip::after {
|
||||
content: attr(data-tooltip);
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 6px 10px;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
color: white;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: all 0.2s ease;
|
||||
z-index: 1000;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.position-tooltip:hover::after {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
/* 紧凑的参数显示 */
|
||||
.compact-params {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.param-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 10px;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.param-label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.param-value {
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.worldbook-content {
|
||||
padding: 6px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.edit-panel {
|
||||
left: 0;
|
||||
top: 50px;
|
||||
}
|
||||
|
||||
.compact-params {
|
||||
gap: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 编辑面板打开状态优化 */
|
||||
.edit-panel.open {
|
||||
transform: translateX(0);
|
||||
box-shadow: -4px 0 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
/* 位置选择器样式 */
|
||||
.position-selector {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.position-option {
|
||||
padding: 6px 8px;
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.position-option:hover {
|
||||
background: white;
|
||||
border-color: #ced4da;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.position-option.active {
|
||||
background: rgba(74, 108, 247, 0.05);
|
||||
border-color: rgba(74, 108, 247, 0.2);
|
||||
}
|
||||
|
||||
.position-label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: #2c3e50;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.position-weight {
|
||||
display: inline-block;
|
||||
padding: 1px 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
border-radius: 2px;
|
||||
background: #f1f3f5;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.position-option.active .position-weight {
|
||||
background: rgba(74, 108, 247, 0.1);
|
||||
color: #4a6cf7;
|
||||
}
|
||||
|
||||
/* 触发策略选择器样式 */
|
||||
.trigger-strategy-selector {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.trigger-strategy-btn {
|
||||
flex: 1;
|
||||
min-width: 80px;
|
||||
padding: 5px 8px;
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: #495057;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.trigger-strategy-btn:hover {
|
||||
background: white;
|
||||
border-color: #ced4da;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.trigger-strategy-btn.active {
|
||||
background: rgba(74, 108, 247, 0.05);
|
||||
border-color: rgba(74, 108, 247, 0.2);
|
||||
color: #4a6cf7;
|
||||
}
|
||||
|
||||
/* 编辑面板内容区域优化 */
|
||||
.edit-panel .form-group {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.edit-panel .form-label {
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.edit-panel .form-input,
|
||||
.edit-panel .form-textarea,
|
||||
.edit-panel .form-select {
|
||||
padding: 4px 7px;
|
||||
}
|
||||
|
||||
.edit-panel .form-textarea {
|
||||
min-height: 60px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
/* 编辑面板按钮优化 */
|
||||
.edit-panel .btn {
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* 权重标签颜色区分 */
|
||||
.weight-high {
|
||||
background: rgba(231, 76, 60, 0.1);
|
||||
color: #e74c3c;
|
||||
}
|
||||
|
||||
.weight-medium {
|
||||
background: rgba(241, 196, 15, 0.1);
|
||||
color: #f39c12;
|
||||
}
|
||||
|
||||
.weight-low {
|
||||
background: rgba(52, 152, 219, 0.1);
|
||||
color: #3498db;
|
||||
}
|
||||
|
||||
.weight-extreme {
|
||||
background: rgba(192, 57, 43, 0.1);
|
||||
color: #c0392b;
|
||||
}
|
||||
|
||||
.weight-maximum {
|
||||
background: rgba(142, 68, 173, 0.1);
|
||||
color: #8e44ad;
|
||||
}
|
||||
100
frontend-react/src/components/SideBarRight/SideBarRight.css
Normal file
100
frontend-react/src/components/SideBarRight/SideBarRight.css
Normal file
@@ -0,0 +1,100 @@
|
||||
.sidebar-right {
|
||||
width: 300px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background-color: #ffffff;
|
||||
box-shadow: -2px 0 5px rgba(0, 0, 0, 0.05);
|
||||
border-left: 1px solid #e8e8e8;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.sidebar-tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid #e8e8e8;
|
||||
flex-shrink: 0;
|
||||
background-color: #fafafa;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
flex: 1;
|
||||
padding: 12px 5px;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
font-size: 14px;
|
||||
color: #555;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.tab-button:hover {
|
||||
background-color: #f0f0f0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.tab-button.active {
|
||||
color: #4a90e2;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tab-button.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 3px;
|
||||
background-color: #4a90e2;
|
||||
border-radius: 3px 3px 0 0;
|
||||
}
|
||||
|
||||
.sidebar-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 15px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.tab-content:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.tab-content.full-height {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* 自定义滚动条样式 */
|
||||
.sidebar-content::-webkit-scrollbar,
|
||||
.tab-content::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.sidebar-content::-webkit-scrollbar-track,
|
||||
.tab-content::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
}
|
||||
|
||||
.sidebar-content::-webkit-scrollbar-thumb,
|
||||
.tab-content::-webkit-scrollbar-thumb {
|
||||
background: #c1c1c1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.sidebar-content::-webkit-scrollbar-thumb:hover,
|
||||
.tab-content::-webkit-scrollbar-thumb:hover {
|
||||
background: #a8a8a8;
|
||||
}
|
||||
48
frontend-react/src/components/SideBarRight/SideBarRight.jsx
Normal file
48
frontend-react/src/components/SideBarRight/SideBarRight.jsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import './SideBarRight.css';
|
||||
import Dice from './tab/Dice';
|
||||
import Debug from './tab/Debug';
|
||||
import Macros from './tab/Macros';
|
||||
import Table from './tab/Table';
|
||||
import useSideBarRightStore from '../../Store/Slices/RightTabsSlices/SideBarRightSlice';
|
||||
|
||||
const SideBarRight = () => {
|
||||
const { selectedTabs, allTabs, handleTabClick, setTabComponent } = useSideBarRightStore();
|
||||
|
||||
// 设置标签组件
|
||||
useEffect(() => {
|
||||
setTabComponent('dice', Dice);
|
||||
setTabComponent('debug', Debug);
|
||||
setTabComponent('macros', Macros);
|
||||
setTabComponent('table', Table);
|
||||
}, [setTabComponent]);
|
||||
|
||||
return (
|
||||
<div className="sidebar-right">
|
||||
<div className="sidebar-tabs">
|
||||
{allTabs.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
className={`tab-button ${selectedTabs.includes(tab.id) ? 'active' : ''}`}
|
||||
onClick={() => handleTabClick(tab.id)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="sidebar-content">
|
||||
{selectedTabs.map(tabId => {
|
||||
const tab = allTabs.find(t => t.id === tabId);
|
||||
return (
|
||||
<div key={tabId} className={`tab-content ${selectedTabs.length === 1 ? 'full-height' : ''}`}>
|
||||
{tab.component ? <tab.component /> : <div className="tab-content">{tab.label}内容</div>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SideBarRight;
|
||||
12
frontend-react/src/components/SideBarRight/tab/Debug.jsx
Normal file
12
frontend-react/src/components/SideBarRight/tab/Debug.jsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import React from 'react';
|
||||
|
||||
const Debug = () => {
|
||||
return (
|
||||
<div className="debug-panel">
|
||||
<h2>上下文调试</h2>
|
||||
<p>这是上下文调试面板的占位页面</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Debug;
|
||||
12
frontend-react/src/components/SideBarRight/tab/Dice.jsx
Normal file
12
frontend-react/src/components/SideBarRight/tab/Dice.jsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import React from 'react';
|
||||
|
||||
const Dice = () => {
|
||||
return (
|
||||
<div className="dice-panel">
|
||||
<h2>骰子面板</h2>
|
||||
<p>这是骰子面板的占位页面</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dice;
|
||||
12
frontend-react/src/components/SideBarRight/tab/Macros.jsx
Normal file
12
frontend-react/src/components/SideBarRight/tab/Macros.jsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import React from 'react';
|
||||
|
||||
const Macros = () => {
|
||||
return (
|
||||
<div className="macros-panel">
|
||||
<h2>快捷宏</h2>
|
||||
<p>这是快捷宏面板的占位页面</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Macros;
|
||||
12
frontend-react/src/components/SideBarRight/tab/Table.jsx
Normal file
12
frontend-react/src/components/SideBarRight/tab/Table.jsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import React from 'react';
|
||||
|
||||
const Table = () => {
|
||||
return (
|
||||
<div className="table-panel">
|
||||
<h2>动态表格</h2>
|
||||
<p>这是动态表格面板的占位页面</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Table;
|
||||
@@ -34,17 +34,18 @@
|
||||
|
||||
/* 工具栏图标 */
|
||||
.toolbar-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
font-size: 18px;
|
||||
color: #555;
|
||||
background-color: #f5f5f5;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.toolbar-icon:hover {
|
||||
@@ -59,6 +60,15 @@
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* 图标标签文本 */
|
||||
.icon-label {
|
||||
font-size: 14px;
|
||||
max-width: 150px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ==================== 弹出面板通用样式 ==================== */
|
||||
|
||||
.close-panel-button {
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
// frontend-react/src/components/ToolBar/ToolBar.jsx
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import RoleSelector from '../RoleSelector/RoleSelector';
|
||||
import useRoleSelectorStore from '../../Store/Slices/RoleSelectorSlice';
|
||||
import './ToolBar.css';
|
||||
|
||||
const Toolbar = () => {
|
||||
const [activePanel, setActivePanel] = useState(null);
|
||||
const panelRef = useRef(null);
|
||||
const selectedRole = useRoleSelectorStore((state) => state.selectedRole);
|
||||
const selectedChat = useRoleSelectorStore((state) => state.selectedChat);
|
||||
|
||||
// 点击外部关闭面板
|
||||
React.useEffect(() => {
|
||||
@@ -20,6 +24,13 @@ const Toolbar = () => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 监听selectedRole和selectedChat的变化
|
||||
useEffect(() => {
|
||||
console.log('当前选中的角色:', selectedRole);
|
||||
console.log('当前选中的聊天:', selectedChat);
|
||||
// 这里可以添加其他需要响应角色变化的逻辑
|
||||
}, [selectedRole, selectedChat]);
|
||||
|
||||
// 处理面板切换
|
||||
const handlePanelToggle = (panelName) => {
|
||||
if (activePanel === panelName) {
|
||||
@@ -34,38 +45,73 @@ const Toolbar = () => {
|
||||
setActivePanel(null);
|
||||
};
|
||||
|
||||
// 截断文本
|
||||
const truncateText = (text, maxLength = 20) => {
|
||||
if (!text) return '未选择';
|
||||
return text.length > maxLength ? text.substring(0, maxLength) + '...' : text;
|
||||
};
|
||||
|
||||
// 构建显示文本
|
||||
const getDisplayText = () => {
|
||||
if (!selectedRole) return '未选择';
|
||||
return selectedChat ? `${selectedRole} / ${selectedChat}` : selectedRole;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="toolbar">
|
||||
{/* 左侧工具栏图标 */}
|
||||
{/* 左侧:当前角色 */}
|
||||
<div className="toolbar-section">
|
||||
<div className="toolbar-icons">
|
||||
{/* Logo图标 */}
|
||||
<div
|
||||
className="toolbar-icon"
|
||||
title="首页"
|
||||
onClick={() => handlePanelToggle(null)}
|
||||
>
|
||||
🤖
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 中间操作图标 */}
|
||||
<div className="toolbar-section">
|
||||
<div className="toolbar-icons">
|
||||
{/* 角色选择图标 */}
|
||||
<div
|
||||
className={`toolbar-icon ${activePanel === 'role' ? 'active' : ''}`}
|
||||
title="角色管理"
|
||||
onClick={() => handlePanelToggle('role')}
|
||||
title="玩家角色"
|
||||
onClick={() => handlePanelToggle('currentRole')}
|
||||
>
|
||||
👤
|
||||
<span className="icon-label">当前角色</span>
|
||||
</div>
|
||||
<div className="toolbar-display-box">
|
||||
{truncateText(getDisplayText())}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧工具栏图标 */}
|
||||
{/* 中间:角色管理 */}
|
||||
<div className="toolbar-section">
|
||||
<div className="toolbar-icons">
|
||||
<div
|
||||
className={`toolbar-icon ${activePanel === 'role' ? 'active' : ''}`}
|
||||
title="ai角色"
|
||||
onClick={() => handlePanelToggle('role')}
|
||||
>
|
||||
🎭
|
||||
<span className="icon-label">角色管理</span>
|
||||
</div>
|
||||
<div className="toolbar-display-box">
|
||||
{truncateText(getDisplayText())}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 全局世界书 */}
|
||||
<div className="toolbar-section">
|
||||
<div className="toolbar-icons">
|
||||
<div
|
||||
className="toolbar-icon"
|
||||
title="全局世界书"
|
||||
onClick={() => handlePanelToggle('worldBook')}
|
||||
>
|
||||
📚
|
||||
<span className="icon-label">全局世界书</span>
|
||||
</div>
|
||||
<div className="toolbar-display-box">
|
||||
全局世界书
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧:设置和拓展 */}
|
||||
<div className="toolbar-section">
|
||||
<div className="toolbar-icons" style={{ justifyContent: 'flex-end' }}>
|
||||
<div
|
||||
@@ -77,10 +123,10 @@ const Toolbar = () => {
|
||||
</div>
|
||||
<div
|
||||
className="toolbar-icon"
|
||||
title="帮助"
|
||||
onClick={() => handlePanelToggle('help')}
|
||||
title="拓展"
|
||||
onClick={() => handlePanelToggle('extensions')}
|
||||
>
|
||||
❓
|
||||
➕
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -91,7 +137,7 @@ const Toolbar = () => {
|
||||
<div className="panel-overlay" ref={panelRef}>
|
||||
<div className="panel-content">
|
||||
<div className="panel-header">
|
||||
<h3>角色管理</h3>
|
||||
<h3>用户角色管理</h3>
|
||||
<button className="close-panel-button" onClick={handleClosePanel} title="关闭">
|
||||
✕
|
||||
</button>
|
||||
@@ -103,33 +149,69 @@ const Toolbar = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activePanel === 'settings' && (
|
||||
{/* 当前角色面板(暂时留空) */}
|
||||
{activePanel === 'currentRole' && (
|
||||
<div className="panel-overlay" ref={panelRef}>
|
||||
<div className="panel-content">
|
||||
<div className="panel-header">
|
||||
<h3>设置</h3>
|
||||
<h3>当前ai角色</h3>
|
||||
<button className="close-panel-button" onClick={handleClosePanel} title="关闭">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="panel-body">
|
||||
<p>设置内容...</p>
|
||||
<p>当前角色详情...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activePanel === 'help' && (
|
||||
{/* 全局世界书面板 */}
|
||||
{activePanel === 'worldBook' && (
|
||||
<div className="panel-overlay" ref={panelRef}>
|
||||
<div className="panel-content">
|
||||
<div className="panel-header">
|
||||
<h3>帮助</h3>
|
||||
<h3>全局世界书</h3>
|
||||
<button className="close-panel-button" onClick={handleClosePanel} title="关闭">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="panel-body">
|
||||
<p>帮助内容...</p>
|
||||
<p>全局世界书内容...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 设置面板 */}
|
||||
{activePanel === 'settings' && (
|
||||
<div className="panel-overlay" ref={panelRef}>
|
||||
<div className="panel-content">
|
||||
<div className="panel-header">
|
||||
<h3>系统设置</h3>
|
||||
<button className="close-panel-button" onClick={handleClosePanel} title="关闭">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="panel-body">
|
||||
<p>系统设置内容...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 拓展面板 */}
|
||||
{activePanel === 'extensions' && (
|
||||
<div className="panel-overlay" ref={panelRef}>
|
||||
<div className="panel-content">
|
||||
<div className="panel-header">
|
||||
<h3>功能拓展</h3>
|
||||
<button className="close-panel-button" onClick={handleClosePanel} title="关闭">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="panel-body">
|
||||
<p>功能拓展内容...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,85 +1,21 @@
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100vh; /* 使用视口高度作为基准 */
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.app {
|
||||
height: 100%; /* 继承body的100vh */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
#root {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 重置样式 */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body, .app {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: hidden; /* 防止出现滚动条 */
|
||||
}
|
||||
|
||||
.app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 主内容区域 */
|
||||
.main-container {
|
||||
flex: 1; /* 这会让主容器占据剩余的所有空间 */
|
||||
display: flex;
|
||||
overflow: hidden; /* 防止内容溢出 */
|
||||
}
|
||||
|
||||
/* 左侧栏 */
|
||||
.sidebar-left {
|
||||
width: 250px; /* 或者你想要的宽度 */
|
||||
height: 继承父元素高度; /* 确保高度填满 */
|
||||
overflow-y: auto; /* 内容过多时显示滚动条 */
|
||||
width: 22.5%; /* 修改为30% */
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 中间聊天区域 */
|
||||
.chat-area {
|
||||
flex: 1; /* 占据剩余空间 */
|
||||
height: 100%; /* 确保高度填满 */
|
||||
flex: 55%; /* 修改为0.4 */
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden; /* 防止内容溢出 */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 右侧栏 */
|
||||
.sidebar-right {
|
||||
width: 300px; /* 或者你想要的宽度 */
|
||||
height: 100%; /* 确保高度填满 */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 右侧栏顶部 */
|
||||
.right-top {
|
||||
flex: 1; /* 占据剩余空间 */
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* 右侧栏底部 */
|
||||
.right-bottom {
|
||||
height: 200px; /* 或者你想要的高度 */
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
height: 60px; /* 或其他合适的固定高度 */
|
||||
flex-shrink: 0; /* 防止被压缩 */
|
||||
width: 22.5%; /* 修改为30% */
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user