Compare commits
29 Commits
18ee2b9b2c
...
feature/ll
| Author | SHA1 | Date | |
|---|---|---|---|
| dd17206e1f | |||
| 4f9cf4b725 | |||
| e8dedb5ec4 | |||
| 7a62139683 | |||
| 01ca2bd0f9 | |||
| 1fc0c43689 | |||
| 1abfaeda9d | |||
| 0ae53c4b81 | |||
| f90ad8dc13 | |||
| 6375f9759c | |||
| 33188a345e | |||
| 6fa1fd6e7f | |||
| 80237463ef | |||
| f9bc77d392 | |||
| 408e9ce569 | |||
| 60a2049bb7 | |||
| 4d4d7c30ce | |||
| 2b1ec63c00 | |||
| 6c74bef8da | |||
| 73cdf5ac23 | |||
| a371039ee6 | |||
| 91d11abe90 | |||
| 4b85b35cf8 | |||
| c99052529d | |||
| bd1fa14f20 | |||
| 1aa90f5acf | |||
| 04ea889d75 | |||
| 3c4a11eca8 | |||
| 85f2bbe78c |
BIN
.gitignore
vendored
Normal file
BIN
.gitignore
vendored
Normal file
Binary file not shown.
5
.idea/codeStyles/codeStyleConfig.xml
generated
Normal file
5
.idea/codeStyles/codeStyleConfig.xml
generated
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<component name="ProjectCodeStyleConfiguration">
|
||||||
|
<state>
|
||||||
|
<option name="PREFERRED_PROJECT_CODE_STYLE" value="Default" />
|
||||||
|
</state>
|
||||||
|
</component>
|
||||||
@@ -5,20 +5,19 @@ FROM python:3.11-slim
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# 复制依赖文件
|
# 复制依赖文件
|
||||||
# 注意:这里的 requirements.txt 在 backend/ 目录下
|
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
|
|
||||||
# 安装依赖
|
# 安装依赖
|
||||||
RUN pip install --no-cache-dir -i https://pypi.tuna.tsinghua.edu.cn/simple -r requirements.txt
|
RUN pip install --no-cache-dir -i https://pypi.tuna.tsinghua.edu.cn/simple -r requirements.txt
|
||||||
|
|
||||||
# 复制所有代码
|
# 复制所有代码
|
||||||
# 关键修改:把 backend/ 目录下的内容复制到 /app/backend/ 下
|
# 修改点:把当前目录(即 backend/)的内容复制到 /app/backend/ 下
|
||||||
# 这样镜像内的结构就是 /app/backend/app/...
|
# 这样镜像内的结构就是 /app/backend/api/route.py
|
||||||
COPY . ./backend/
|
COPY . /app/backend/
|
||||||
|
|
||||||
# 暴露端口
|
# 暴露端口
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
# 启动命令
|
# 启动命令
|
||||||
# 关键修改:路径改为 backend.app.api.route
|
# 修改点:路径改为 backend.api.route (对应 /app/backend/api/route.py)
|
||||||
CMD ["uvicorn", "backend.app.api.route:app", "--host", "0.0.0.0", "--port", "8000"]
|
CMD ["uvicorn", "backend.api.route:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
|
|||||||
BIN
backend/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
backend/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/__pycache__/__init__.cpython-39.pyc
Normal file
BIN
backend/__pycache__/__init__.cpython-39.pyc
Normal file
Binary file not shown.
BIN
backend/__pycache__/main.cpython-311.pyc
Normal file
BIN
backend/__pycache__/main.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/api/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
backend/api/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/api/__pycache__/route.cpython-311.pyc
Normal file
BIN
backend/api/__pycache__/route.cpython-311.pyc
Normal file
Binary file not shown.
@@ -1,18 +1,16 @@
|
|||||||
from fastapi import FastAPI
|
from fastapi import APIRouter
|
||||||
# 假设这些函数已经在其他地方定义
|
from .routes import presetsRoute, chatsRoute, worldbooksRoute
|
||||||
from backend.core.items import ChatRequest
|
|
||||||
from backend.tools.get_all_role_and_chat import get_all_role_and_chat as get_chat_file
|
|
||||||
|
|
||||||
app = FastAPI()
|
router = APIRouter()
|
||||||
|
|
||||||
# 1. 将输入内容持久化存储到本地jsonl方便前端读
|
# 注册子路由
|
||||||
@app.post("/generate_reply")
|
router.include_router(presetsRoute.router)
|
||||||
async def save_input_to_json(chat_request: ChatRequest):
|
router.include_router(chatsRoute.router)
|
||||||
return 0
|
router.include_router(worldbooksRoute.router)
|
||||||
|
|
||||||
# 2. 从本地jsonl中读取历史对话
|
|
||||||
@app.get("/get_all_role_and_chat")
|
# 保留原有的其他路由
|
||||||
def get_all_role_and_chat():
|
@router.get("/tool_bar/get_all_role_and_chat")
|
||||||
# 直接调用导入的函数
|
def get_all_role_and_chat_endpoint():
|
||||||
result = get_chat_file()
|
from ..tools.get_all_role_and_chat import get_all_role_and_chat
|
||||||
return result
|
return get_all_role_and_chat()
|
||||||
|
|||||||
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)}")
|
||||||
BIN
backend/core/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
backend/core/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/core/__pycache__/__init__.cpython-39.pyc
Normal file
BIN
backend/core/__pycache__/__init__.cpython-39.pyc
Normal file
Binary file not shown.
BIN
backend/core/__pycache__/config.cpython-311.pyc
Normal file
BIN
backend/core/__pycache__/config.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/core/__pycache__/config.cpython-39.pyc
Normal file
BIN
backend/core/__pycache__/config.cpython-39.pyc
Normal file
Binary file not shown.
BIN
backend/core/__pycache__/items.cpython-311.pyc
Normal file
BIN
backend/core/__pycache__/items.cpython-311.pyc
Normal file
Binary file not shown.
@@ -3,11 +3,12 @@ from pathlib import Path
|
|||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
# 1. 动态计算项目根目录
|
# 1. 动态计算项目根目录
|
||||||
# 假设 config.py 位于 backend/ 目录下
|
# 假设 config.py 位于 backend/core/ 目录下
|
||||||
# __file__ 指向本文件的绝对路径
|
# __file__ 指向本文件的绝对路径
|
||||||
# .parent 指向 backend/ 目录
|
# .parent 指向 backend/core/ 目录
|
||||||
# .parent.parent 指向项目根目录 (即包含 backend/ 和 frontend/ 的目录)
|
# .parent.parent 指向 backend/ 目录
|
||||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent.parent # 修改这里,添加一个 .parent
|
# .parent.parent.parent 指向项目根目录 (即包含 backend/ 和 frontend/ 的目录)
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||||
|
|
||||||
# 2. 加载 .env 文件
|
# 2. 加载 .env 文件
|
||||||
# 假设 .env 文件位于项目根目录下
|
# 假设 .env 文件位于项目根目录下
|
||||||
@@ -28,20 +29,51 @@ class Settings:
|
|||||||
BASE_PATH = PROJECT_ROOT
|
BASE_PATH = PROJECT_ROOT
|
||||||
|
|
||||||
# 数据目录:固定为根目录下的 data 文件夹
|
# 数据目录:固定为根目录下的 data 文件夹
|
||||||
# 即使 .env 里写了 DATA_PATH=/data,这里也会强制指向项目根目录下的 data
|
|
||||||
DATA_PATH = BASE_PATH / "data"
|
DATA_PATH = BASE_PATH / "data"
|
||||||
|
|
||||||
# 其他文件路径:基于 DATA_PATH 拼接
|
# --- 核心数据文件路径 ---
|
||||||
STATE_FILE = DATA_PATH / "state.json"
|
STATE_FILE = DATA_PATH / "state.json"
|
||||||
SCHEMA_FILE = DATA_PATH / "schema.json"
|
SCHEMA_FILE = DATA_PATH / "schema.json"
|
||||||
PRESETS_FILE = DATA_PATH / "presets.json"
|
PRESETS_FILE = DATA_PATH / "presets.json"
|
||||||
REGEX_FILE = DATA_PATH / "regex_rules.json"
|
REGEX_FILE = DATA_PATH / "regex_rules.json"
|
||||||
VECTORSTORE_PATH = DATA_PATH / "vectorstore"
|
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 = Settings()
|
||||||
|
|
||||||
if __name__ == '__main__':
|
# 初始化时自动创建必要的目录
|
||||||
# 实例化配置对象
|
settings.ensure_directories()
|
||||||
settings = Settings()
|
|
||||||
print(settings.BASE_PATH)
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
settings = Settings()
|
||||||
|
print(f"项目根目录: {settings.BASE_PATH}")
|
||||||
|
print(f"数据目录: {settings.DATA_PATH}")
|
||||||
|
print(f"世界书目录: {settings.WORLDBOOKS_PATH}")
|
||||||
|
print(f"预设目录: {settings.PRESETS_PATH}")
|
||||||
|
print(f"聊天目录: {settings.CHAT_PATH}")
|
||||||
|
|||||||
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)
|
||||||
BIN
backend/core/models/__pycache__/chat_history.cpython-311.pyc
Normal file
BIN
backend/core/models/__pycache__/chat_history.cpython-311.pyc
Normal file
Binary file not shown.
443
backend/core/models/chat_history.py
Normal file
443
backend/core/models/chat_history.py
Normal file
@@ -0,0 +1,443 @@
|
|||||||
|
from typing import List, Dict, Any, Optional
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
import json
|
||||||
|
|
||||||
|
|
||||||
|
class Message(BaseModel):
|
||||||
|
"""消息类,代表JSONL文件中的一行消息内容"""
|
||||||
|
name: str = Field(..., description="发送者名称")
|
||||||
|
is_user: bool = Field(..., description="是否为用户消息")
|
||||||
|
is_system: bool = Field(False, description="是否为系统消息")
|
||||||
|
send_date: str = Field(
|
||||||
|
default_factory=lambda: str(int(datetime.now().timestamp() * 1000)),
|
||||||
|
description="消息发送时间戳"
|
||||||
|
)
|
||||||
|
floor: int = Field(0, description="对话楼层数")
|
||||||
|
swipes: List[str] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="历史版本列表。用户消息:存编辑过的不同版本。AI消息:存重roll生成的不同版本"
|
||||||
|
)
|
||||||
|
swipe_id: int = Field(
|
||||||
|
0,
|
||||||
|
description="当前指针。指示当前显示的是 swipes 数组中的第几个(从 0 开始)"
|
||||||
|
)
|
||||||
|
mes: str = Field(..., description="消息内容文本")
|
||||||
|
extra: Dict[str, Any] = Field(
|
||||||
|
default_factory=dict,
|
||||||
|
description="额外信息,包含推理内容、API、模型等"
|
||||||
|
)
|
||||||
|
force_avatar: Optional[str] = Field(None, description="强制头像URL")
|
||||||
|
variables: List[Any] = Field(default_factory=list, description="消息变量列表")
|
||||||
|
variables_initialized: List[bool] = Field(default_factory=list, description="变量初始化状态数组")
|
||||||
|
is_ejs_processed: List[bool] = Field(default_factory=list, description="EJS处理状态数组")
|
||||||
|
|
||||||
|
# 以下属性仅在is_user为False时有值
|
||||||
|
api: Optional[str] = Field(None, description="使用的API提供商")
|
||||||
|
model: Optional[str] = Field(None, description="使用的AI模型")
|
||||||
|
reasoning: Optional[str] = Field(None, description="推理内容")
|
||||||
|
reasoning_duration: Optional[float] = Field(None, description="推理耗时")
|
||||||
|
reasoning_signature: Optional[str] = Field(None, description="推理签名")
|
||||||
|
time_to_first_token: Optional[float] = Field(None, description="首Token响应时间")
|
||||||
|
bias: Optional[float] = Field(None, description="偏差值")
|
||||||
|
|
||||||
|
|
||||||
|
class ChatMetadata(BaseModel):
|
||||||
|
"""聊天元数据类,包含整个聊天的共享属性"""
|
||||||
|
user_name: str = Field("User", description="用户名称")
|
||||||
|
character_name: str = Field("Assistant", description="角色名称")
|
||||||
|
|
||||||
|
# 完整性校验相关
|
||||||
|
integrity: str = Field("", description="完整性校验值")
|
||||||
|
chat_id_hash: str = Field("", description="聊天ID哈希值")
|
||||||
|
|
||||||
|
# 笔记相关
|
||||||
|
note_prompt: str = Field("", description="作者笔记提示词")
|
||||||
|
note_interval: int = Field(0, description="笔记插入间隔数")
|
||||||
|
note_position: int = Field(0, description="笔记插入位置")
|
||||||
|
note_depth: int = Field(0, description="笔记插入深度")
|
||||||
|
# 0:System,1:User,2:Assistant
|
||||||
|
note_role: int = Field("", description="笔记使用角色类型")
|
||||||
|
|
||||||
|
# 扩展信息
|
||||||
|
extensions: Dict[str, Any] = Field(
|
||||||
|
default_factory=dict,
|
||||||
|
description="扩展信息,如LittleWhiteBox等"
|
||||||
|
)
|
||||||
|
# 世界信息
|
||||||
|
timedWorldInfo: Dict[str, Any] = Field(
|
||||||
|
default_factory=dict,
|
||||||
|
description="定时世界信息"
|
||||||
|
)
|
||||||
|
# 变量
|
||||||
|
variables: Dict[str, Any] = Field(
|
||||||
|
default_factory=dict,
|
||||||
|
description="变量字典"
|
||||||
|
)
|
||||||
|
# 状态标记
|
||||||
|
tainted: bool = Field(False, description="是否被修改标记")
|
||||||
|
lastInContextMessageId: int = Field(-1, description="最后上下文消息ID")
|
||||||
|
|
||||||
|
|
||||||
|
class ChatHistory(BaseModel):
|
||||||
|
"""聊天文件类,包含完整的聊天记录"""
|
||||||
|
chat_metadata: ChatMetadata = Field(..., description="聊天元数据,包含基本信息和配置")
|
||||||
|
messages: List[Message] = Field(default_factory=list, description="消息列表")
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
arbitrary_types_allowed = True
|
||||||
|
|
||||||
|
@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文件加载聊天历史
|
||||||
|
|
||||||
|
参数:
|
||||||
|
role_name: 角色名称(文件夹名)
|
||||||
|
chat_name: 聊天名称(文件名,不含扩展名)
|
||||||
|
base_path: 基础路径,默认为配置中的DATA_PATH/chat
|
||||||
|
|
||||||
|
返回:
|
||||||
|
ChatHistory: 加载的聊天历史对象
|
||||||
|
|
||||||
|
异常:
|
||||||
|
FileNotFoundError: 当文件不存在时抛出
|
||||||
|
json.JSONDecodeError: 当JSON解析失败时抛出
|
||||||
|
"""
|
||||||
|
# 设置默认基础路径
|
||||||
|
if base_path is None:
|
||||||
|
base_path = cls.get_data_path()
|
||||||
|
|
||||||
|
# 构建文件路径
|
||||||
|
file_path = base_path / role_name / f"{chat_name}.jsonl"
|
||||||
|
|
||||||
|
# 检查文件是否存在
|
||||||
|
if not file_path.exists():
|
||||||
|
raise FileNotFoundError(f"聊天文件不存在: {file_path}")
|
||||||
|
|
||||||
|
# 初始化结果数据
|
||||||
|
messages = []
|
||||||
|
metadata = None
|
||||||
|
|
||||||
|
# 读取文件内容
|
||||||
|
with open(file_path, 'r', encoding='utf-8') as f:
|
||||||
|
for line_num, line in enumerate(f):
|
||||||
|
try:
|
||||||
|
line_data = json.loads(line.strip())
|
||||||
|
|
||||||
|
# 第一行是元数据
|
||||||
|
if line_num == 0:
|
||||||
|
metadata = ChatMetadata(**line_data)
|
||||||
|
else:
|
||||||
|
# 后续行是消息
|
||||||
|
messages.append(Message(**line_data))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 创建并返回ChatHistory对象
|
||||||
|
return cls(
|
||||||
|
chat_metadata=metadata or ChatMetadata(),
|
||||||
|
messages=messages
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def load_from_jsonl(cls, file_path: Path) -> 'ChatHistory':
|
||||||
|
"""
|
||||||
|
从JSONL文件加载聊天历史
|
||||||
|
|
||||||
|
参数:
|
||||||
|
file_path: JSONL文件路径
|
||||||
|
|
||||||
|
返回:
|
||||||
|
ChatHistory: 加载的聊天历史对象
|
||||||
|
|
||||||
|
异常:
|
||||||
|
FileNotFoundError: 当文件不存在时抛出
|
||||||
|
json.JSONDecodeError: 当JSON解析失败时抛出
|
||||||
|
"""
|
||||||
|
# 检查文件是否存在
|
||||||
|
if not file_path.exists():
|
||||||
|
raise FileNotFoundError(f"聊天文件不存在: {file_path}")
|
||||||
|
|
||||||
|
# 初始化结果数据
|
||||||
|
messages = []
|
||||||
|
metadata = None
|
||||||
|
|
||||||
|
# 读取文件内容
|
||||||
|
with open(file_path, 'r', encoding='utf-8') as f:
|
||||||
|
for line_num, line in enumerate(f):
|
||||||
|
try:
|
||||||
|
line_data = json.loads(line.strip())
|
||||||
|
|
||||||
|
# 第一行是元数据
|
||||||
|
if line_num == 0:
|
||||||
|
# 处理元数据中的嵌套结构
|
||||||
|
if 'chat_metadata' in line_data:
|
||||||
|
metadata_dict = line_data['chat_metadata']
|
||||||
|
# 合并顶层字段和chat_metadata中的字段
|
||||||
|
metadata_dict.update(line_data)
|
||||||
|
metadata = ChatMetadata(**metadata_dict)
|
||||||
|
else:
|
||||||
|
metadata = ChatMetadata(**line_data)
|
||||||
|
else:
|
||||||
|
# 后续行是消息
|
||||||
|
# 处理extra字段中的内容
|
||||||
|
extra_data = line_data.get('extra', {})
|
||||||
|
|
||||||
|
# 如果是AI消息(is_user=False),将extra中的某些字段提升到顶层
|
||||||
|
if not line_data.get('is_user', True):
|
||||||
|
ai_fields = ['api', 'model', 'reasoning', 'reasoning_duration',
|
||||||
|
'reasoning_signature', 'time_to_first_token', 'bias']
|
||||||
|
for field in ai_fields:
|
||||||
|
if field in extra_data:
|
||||||
|
line_data[field] = extra_data.pop(field)
|
||||||
|
|
||||||
|
# 创建Message实例
|
||||||
|
message = Message(**line_data)
|
||||||
|
# 将剩余的extra数据保存回extra字段
|
||||||
|
message.extra = extra_data
|
||||||
|
messages.append(message)
|
||||||
|
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 创建并返回ChatHistory对象
|
||||||
|
return cls(
|
||||||
|
chat_metadata=metadata or ChatMetadata(),
|
||||||
|
messages=messages
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_chatbox_format(self) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
将聊天历史转换为适合前端chatbox显示的格式
|
||||||
|
|
||||||
|
返回:
|
||||||
|
List[Dict[str, Any]]: 按floor排序的消息字典列表,每个字典包含:
|
||||||
|
{
|
||||||
|
"name": str,
|
||||||
|
"is_user": bool,
|
||||||
|
"floor": int,
|
||||||
|
"mes": str,
|
||||||
|
"swipes": List[str],
|
||||||
|
"swipe_id": int
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
# 创建消息字典列表
|
||||||
|
messages_list = []
|
||||||
|
for msg in self.messages:
|
||||||
|
# 获取当前消息内容:优先从swipes数组中获取,如果不存在则使用mes
|
||||||
|
current_mes = msg.mes
|
||||||
|
if msg.swipes and 0 <= msg.swipe_id < len(msg.swipes):
|
||||||
|
current_mes = msg.swipes[msg.swipe_id]
|
||||||
|
|
||||||
|
msg_dict = {
|
||||||
|
"name": msg.name,
|
||||||
|
"is_user": msg.is_user,
|
||||||
|
"floor": msg.floor,
|
||||||
|
"mes": current_mes,
|
||||||
|
"swipes": msg.swipes,
|
||||||
|
"swipe_id": msg.swipe_id
|
||||||
|
}
|
||||||
|
messages_list.append(msg_dict)
|
||||||
|
|
||||||
|
# 按floor排序
|
||||||
|
messages_list.sort(key=lambda x: x["floor"])
|
||||||
|
|
||||||
|
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
|
# backend/app/main.py
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from .api.routes import router
|
from .api.route import router
|
||||||
|
|
||||||
app = FastAPI(title="LLM Workflow Engine")
|
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"
|
||||||
|
}
|
||||||
|
|
||||||
BIN
backend/tools/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
backend/tools/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/tools/__pycache__/get_all_role_and_chat.cpython-311.pyc
Normal file
BIN
backend/tools/__pycache__/get_all_role_and_chat.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/tools/__pycache__/save_input_to_json.cpython-311.pyc
Normal file
BIN
backend/tools/__pycache__/save_input_to_json.cpython-311.pyc
Normal file
Binary file not shown.
@@ -9,7 +9,7 @@ def get_all_role_and_chat() -> Dict[str, List[str]]:
|
|||||||
读取配置目录下的所有子文件夹,并收集每个子文件夹中的 JSONL 文件
|
读取配置目录下的所有子文件夹,并收集每个子文件夹中的 JSONL 文件
|
||||||
|
|
||||||
返回:
|
返回:
|
||||||
dict: 字典结构,键是文件夹名称,值是该文件夹中的 JSONL 文件列表
|
dict: 字典结构,键是文件夹名称,值是该文件夹中的 JSONL 文件列表(仅文件名,无路径和后缀)
|
||||||
"""
|
"""
|
||||||
result = {}
|
result = {}
|
||||||
|
|
||||||
@@ -33,7 +33,8 @@ def get_all_role_and_chat() -> Dict[str, List[str]]:
|
|||||||
# 遍历子文件夹中的所有文件
|
# 遍历子文件夹中的所有文件
|
||||||
for file in entry.iterdir():
|
for file in entry.iterdir():
|
||||||
if file.is_file() and file.suffix == '.jsonl':
|
if file.is_file() and file.suffix == '.jsonl':
|
||||||
jsonl_files.append(str(file))
|
# 使用 file.stem 获取不带后缀的文件名
|
||||||
|
jsonl_files.append(file.stem)
|
||||||
print(f" 找到文件: {file.name}") # 调试信息
|
print(f" 找到文件: {file.name}") # 调试信息
|
||||||
|
|
||||||
# 如果该文件夹中有 JSONL 文件,则添加到结果中
|
# 如果该文件夹中有 JSONL 文件,则添加到结果中
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import json
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from backend.core import config as cfg
|
from backend.core import config as cfg
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from ..core.items import ChatRequest
|
||||||
|
|
||||||
# 假设 ChatRequest 定义在这里或者从其他地方导入
|
# 假设 ChatRequest 定义在这里或者从其他地方导入
|
||||||
# from backend.app.core.items import ChatRequest
|
# from backend.app.core.items import ChatRequest
|
||||||
@@ -140,7 +140,7 @@ if __name__ == '__main__':
|
|||||||
async def test():
|
async def test():
|
||||||
req = MockChatRequest(
|
req = MockChatRequest(
|
||||||
mes="这是重roll后的新回复2",
|
mes="这是重roll后的新回复2",
|
||||||
role_name="test",
|
role_name="testRole1",
|
||||||
chat_name="111",
|
chat_name="111",
|
||||||
name="AI",
|
name="AI",
|
||||||
is_user=False,
|
is_user=False,
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
{"role": "test", "chat": "111", "content": "你好", "name": "用户", "is_user": true, "send_date": "2026-03-12 18:26:50", "floor_number": 1, "swipes": [], "swipes_id": 0}
|
|
||||||
{"role": "test", "chat": "111", "content": "这是重roll后的新回复2", "name": "AI", "is_user": false, "send_date": "2026-03-12 18:26:50", "floor_number": 2, "swipes": ["这是重roll后的新回复", "这是重roll后的新回复2"], "swipes_id": 1}
|
|
||||||
5
data/chat/testRole1/111.jsonl
Normal file
5
data/chat/testRole1/111.jsonl
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{"user_name": "User", "character_name": "AI Dungeon Master", "integrity": "uuid-001", "chat_id_hash": "hash-001", "note_prompt": "你是一个经验丰富的D&D地下城主。", "note_interval": 0, "note_position": 0, "note_depth": 0, "note_role": 0, "extensions": {}, "timedWorldInfo": {}, "variables": {}, "tainted": false, "lastInContextMessageId": -1}
|
||||||
|
{"name": "User", "is_user": true, "is_system": false, "floor": 0, "send_date": "1700000000000", "mes": "你好,我想开始一个新的冒险。", "extra": {}, "swipes": [], "swipe_id": 0, "force_avatar": null, "variables": [], "variables_initialized": [], "is_ejs_processed": []}
|
||||||
|
{"name": "AI Dungeon Master", "is_user": false, "is_system": false, "floor": 1, "send_date": "1700000001000", "mes": "欢迎,冒险者。请告诉我你想扮演什么角色?", "extra": {"api": "openai", "model": "gpt-4"}, "swipes": ["欢迎,冒险者。请告诉我你想扮演什么角色?", "你好,旅行者。在这个奇幻世界中,你是谁?"], "swipe_id": 0, "force_avatar": null, "variables": [], "variables_initialized": [], "is_ejs_processed": [], "api": "openai", "model": "gpt-4", "reasoning": null, "reasoning_duration": null, "reasoning_signature": null, "time_to_first_token": null, "bias": null}
|
||||||
|
{"name": "User", "is_user": true, "is_system": false, "floor": 2, "send_date": "1700000002000", "mes": "我想成为一名人类战士。", "extra": {}, "swipes": [], "swipe_id": 0, "force_avatar": null, "variables": [], "variables_initialized": [], "is_ejs_processed": []}
|
||||||
|
{"name": "AI Dungeon Master", "is_user": false, "is_system": false, "floor": 3, "send_date": "1700000003000", "mes": "很好。你站在喧闹的酒馆门口,手里握着一把旧长剑。你打算做什么?", "extra": {"api": "openai", "model": "gpt-4"}, "swipes": ["很好。你站在喧闹的酒馆门口,手里握着一把旧长剑。你打算做什么?", "明白了。作为一名人类战士,你正身处繁华的市集广场。你的下一步行动是?"], "swipe_id": 0, "force_avatar": null, "variables": [], "variables_initialized": [], "is_ejs_processed": [], "api": "openai", "model": "gpt-4", "reasoning": null, "reasoning_duration": null, "reasoning_signature": null, "time_to_first_token": null, "bias": null}
|
||||||
5
data/chat/testRole1/11111.jsonl
Normal file
5
data/chat/testRole1/11111.jsonl
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{"user_name": "Commander", "character_name": "XCOM AI", "integrity": "uuid-003", "chat_id_hash": "hash-003", "note_prompt": "你是一名XCOM基地的中央AI,负责协助指挥官管理外星威胁。", "note_interval": 0, "note_position": 0, "note_depth": 0, "note_role": 0, "extensions": {}, "timedWorldInfo": {}, "variables": {}, "tainted": false, "lastInContextMessageId": -1}
|
||||||
|
{"name": "Commander", "is_user": true, "is_system": false, "floor": 0, "send_date": "1700000000000", "mes": "报告当前的外星活动情况。", "extra": {}, "swipes": [], "swipe_id": 0, "force_avatar": null, "variables": [], "variables_initialized": [], "is_ejs_processed": []}
|
||||||
|
{"name": "XCOM AI", "is_user": false, "is_system": false, "floor": 1, "send_date": "1700000001000", "mes": "指挥官,卫星侦测到在南美洲丛林中有高能反应。可能是外星着陆舱。", "extra": {"api": "openai", "model": "gpt-4"}, "swipes": ["指挥官,卫星侦测到在南美洲丛林中有高能反应。可能是外星着陆舱。", "警报。我们在非洲检测到异常信号,疑似外星绑架行动正在进行。"], "swipe_id": 0, "force_avatar": null, "variables": [], "variables_initialized": [], "is_ejs_processed": [], "api": "openai", "model": "gpt-4", "reasoning": null, "reasoning_duration": null, "reasoning_signature": null, "time_to_first_token": null, "bias": null}
|
||||||
|
{"name": "Commander", "is_user": true, "is_system": false, "floor": 2, "send_date": "1700000002000", "mes": "派遣布拉德福上尉带领一个小队去调查。", "extra": {}, "swipes": [], "swipe_id": 0, "force_avatar": null, "variables": [], "variables_initialized": [], "is_ejs_processed": []}
|
||||||
|
{"name": "XCOM AI", "is_user": false, "is_system": false, "floor": 3, "send_date": "1700000003000", "mes": "指令已确认。天火运输机正在起飞。预计到达时间:20分钟。", "extra": {"api": "openai", "model": "gpt-4"}, "swipes": ["指令已确认。天火运输机正在起飞。预计到达时间:20分钟。", "收到。正在部署天火运输机。布拉德福上尉已登机。"], "swipe_id": 0, "force_avatar": null, "variables": [], "variables_initialized": [], "is_ejs_processed": [], "api": "openai", "model": "gpt-4", "reasoning": null, "reasoning_duration": null, "reasoning_signature": null, "time_to_first_token": null, "bias": null}
|
||||||
5
data/chat/testRole2/222.jsonl
Normal file
5
data/chat/testRole2/222.jsonl
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{"user_name": "Player", "character_name": "Game Master", "integrity": "uuid-002", "chat_id_hash": "hash-002", "note_prompt": "场景:赛博朋克风格的未来城市。", "note_interval": 0, "note_position": 0, "note_depth": 0, "note_role": 0, "extensions": {}, "timedWorldInfo": {}, "variables": {}, "tainted": false, "lastInContextMessageId": -1}
|
||||||
|
{"name": "Player", "is_user": true, "is_system": false, "floor": 0, "send_date": "1700000000000", "mes": "我检查我的义体状态。", "extra": {}, "swipes": [], "swipe_id": 0, "force_avatar": null, "variables": [], "variables_initialized": [], "is_ejs_processed": []}
|
||||||
|
{"name": "Game Master", "is_user": false, "is_system": false, "floor": 1, "send_date": "1700000001000", "mes": "你的视觉义眼显示系统正常,但左臂的伺服电机发出轻微的嗡嗡声,似乎需要维护。", "extra": {"api": "openai", "model": "gpt-4"}, "swipes": ["你的视觉义眼显示系统正常,但左臂的伺服电机发出轻微的嗡嗡声,似乎需要维护。", "系统自检完成。你的神经接口连接稳定,但义体排异反应指数略有上升。"], "swipe_id": 0, "force_avatar": null, "variables": [], "variables_initialized": [], "is_ejs_processed": [], "api": "openai", "model": "gpt-4", "reasoning": null, "reasoning_duration": null, "reasoning_signature": null, "time_to_first_token": null, "bias": null}
|
||||||
|
{"name": "Player", "is_user": true, "is_system": false, "floor": 2, "send_date": "1700000002000", "mes": "我联系我的黑客朋友,问他知不知道哪里有靠谱的义体医生。", "extra": {}, "swipes": [], "swipe_id": 0, "force_avatar": null, "variables": [], "variables_initialized": [], "is_ejs_processed": []}
|
||||||
|
{"name": "Game Master", "is_user": false, "is_system": false, "floor": 3, "send_date": "1700000003000", "mes": "你的朋友回复说:'去下城区的老维克那里,虽然他的店看起来很破,但他手艺没得说。'", "extra": {"api": "openai", "model": "gpt-4"}, "swipes": ["你的朋友回复说:'去下城区的老维克那里,虽然他的店看起来很破,但他手艺没得说。'", "通讯接通。你的朋友告诉你:'别去连锁店,去太平间后巷找'扳手',他收费公道。'"], "swipe_id": 0, "force_avatar": null, "variables": [], "variables_initialized": [], "is_ejs_processed": [], "api": "openai", "model": "gpt-4", "reasoning": null, "reasoning_duration": null, "reasoning_signature": null, "time_to_first_token": null, "bias": null}
|
||||||
5
data/chat/testRole3/333.jsonl
Normal file
5
data/chat/testRole3/333.jsonl
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{"user_name": "Player", "character_name": "Narrator", "integrity": "uuid-004", "chat_id_hash": "hash-004", "note_prompt": "这是一个文字冒险游戏,你需要描述场景并等待玩家输入。", "note_interval": 0, "note_position": 0, "note_depth": 0, "note_role": 0, "extensions": {}, "timedWorldInfo": {}, "variables": {}, "tainted": false, "lastInContextMessageId": -1}
|
||||||
|
{"name": "Player", "is_user": true, "is_system": false, "floor": 0, "send_date": "1700000000000", "mes": "开始游戏。", "extra": {}, "swipes": [], "swipe_id": 0, "force_avatar": null, "variables": [], "variables_initialized": [], "is_ejs_processed": []}
|
||||||
|
{"name": "Narrator", "is_user": false, "is_system": false, "floor": 1, "send_date": "1700000001000", "mes": "你醒来时发现自己躺在一片陌生的森林里,四周弥漫着浓雾。你身边有一个背包和一把生锈的匕首。", "extra": {"api": "openai", "model": "gpt-4"}, "swipes": ["你醒来时发现自己躺在一片陌生的森林里,四周弥漫着浓雾。你身边有一个背包和一把生锈的匕首。", "当你睁开眼睛,发现自己身处一艘废弃的飞船中,应急灯闪烁着红光。你手里紧握着一个数据盘。"], "swipe_id": 0, "force_avatar": null, "variables": [], "variables_initialized": [], "is_ejs_processed": [], "api": "openai", "model": "gpt-4", "reasoning": null, "reasoning_duration": null, "reasoning_signature": null, "time_to_first_token": null, "bias": null}
|
||||||
|
{"name": "Player", "is_user": true, "is_system": false, "floor": 2, "send_date": "1700000002000", "mes": "我打开背包看看里面有什么。", "extra": {}, "swipes": [], "swipe_id": 0, "force_avatar": null, "variables": [], "variables_initialized": [], "is_ejs_processed": []}
|
||||||
|
{"name": "Narrator", "is_user": false, "is_system": false, "floor": 3, "send_date": "1700000003000", "mes": "背包里有一块干硬的面包,一个水壶(里面还有半壶水),以及一张画着奇怪符号的羊皮纸。", "extra": {"api": "openai", "model": "gpt-4"}, "swipes": ["背包里有一块干硬的面包,一个水壶(里面还有半壶水),以及一张画着奇怪符号的羊皮纸。", "背包里只有一把激光手枪,能量槽仅剩10%。还有一张写着'不要相信AI'的纸条。"], "swipe_id": 0, "force_avatar": null, "variables": [], "variables_initialized": [], "is_ejs_processed": [], "api": "openai", "model": "gpt-4", "reasoning": null, "reasoning_duration": null, "reasoning_signature": null, "time_to_first_token": null, "bias": null}
|
||||||
@@ -3,27 +3,32 @@ version: '3.8'
|
|||||||
services:
|
services:
|
||||||
backend:
|
backend:
|
||||||
build: ./backend
|
build: ./backend
|
||||||
command: uvicorn backend.api.route:app --host 0.0.0.0 --port 8000 --reload
|
command: uvicorn backend.main:app --host 0.0.0.0 --port 8000 --reload
|
||||||
ports:
|
ports:
|
||||||
- "3001:8000"
|
- "23337:8000"
|
||||||
volumes:
|
volumes:
|
||||||
- .:/app
|
- ./backend:/app/backend
|
||||||
- ./data:/data
|
- ./data:/app/data
|
||||||
- ./outputs:/outputs
|
- ./outputs:/outputs
|
||||||
environment:
|
environment:
|
||||||
- PYTHONUNBUFFERED=1
|
- PYTHONUNBUFFERED=1
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
build: ./frontend
|
build:
|
||||||
command: streamlit run app.py --server.port=8501 --server.address=0.0.0.0 --server.fileWatcherType=poll
|
context: ./frontend-react
|
||||||
|
dockerfile: Dockerfile
|
||||||
ports:
|
ports:
|
||||||
- "3000:8501"
|
- "23338:5173"
|
||||||
environment:
|
|
||||||
- BACKEND_URL=http://backend:8000
|
|
||||||
- PYTHONUNBUFFERED=1
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./frontend:/app # 确保宿主机路径与容器内路径一致
|
- ./frontend-react:/app
|
||||||
|
- /app/node_modules
|
||||||
|
environment:
|
||||||
|
# 如果不需要特定环境变量,可以完全移除 environment 部分
|
||||||
|
# 或者添加有效的环境变量,例如:
|
||||||
|
- NODE_ENV=development
|
||||||
|
- VITE_BACKEND_URL=http://backend:8000
|
||||||
|
command: sh -c "npm install && npm run dev -- --host 0.0.0.0"
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
26
frontend-react/Dockerfile
Normal file
26
frontend-react/Dockerfile
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# 使用 Node.js 18 Alpine 镜像作为基础
|
||||||
|
# 必须明确指定 node 版本,否则可能默认为空或 python 镜像
|
||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
# 设置工作目录
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 设置 npm 镜像源(可选,国内推荐使用,加速依赖下载)
|
||||||
|
RUN npm config set registry https://registry.npmmirror.com/
|
||||||
|
|
||||||
|
# 复制 package.json 和 package-lock.json
|
||||||
|
# 利用 Docker 缓存层,只有依赖变更时才重新安装
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
|
||||||
|
# 安装依赖
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
# 复制源代码到容器
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# 暴露 Vite 默认端口 5173
|
||||||
|
EXPOSE 5173
|
||||||
|
|
||||||
|
# 启动 Vite 开发服务器
|
||||||
|
# --host 0.0.0.0 允许外部访问(Docker 容器外)
|
||||||
|
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
|
||||||
15
frontend-react/index.html
Normal file
15
frontend-react/index.html
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>React App</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- React 应用将挂载到这个 div 上 -->
|
||||||
|
<div id="root"></div>
|
||||||
|
<!-- Vite 会自动注入这里的脚本标签 -->
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
4667
frontend-react/package-lock.json
generated
Normal file
4667
frontend-react/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
32
frontend-react/package.json
Normal file
32
frontend-react/package.json
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "ai-chat-frontend",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"highlight.js": "^11.11.1",
|
||||||
|
"katex": "^0.16.38",
|
||||||
|
"marked": "^17.0.4",
|
||||||
|
"marked-highlight": "^2.2.3",
|
||||||
|
"mermaid": "^11.13.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-copy-to-clipboard": "^5.1.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
|
"react-syntax-highlighter": "^16.1.1",
|
||||||
|
"rehype-katex": "^7.0.1",
|
||||||
|
"remark-math": "^6.0.0",
|
||||||
|
"zustand": "^4.4.7"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.2.43",
|
||||||
|
"@types/react-dom": "^18.2.17",
|
||||||
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
|
"vite": "^5.0.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
31
frontend-react/src/App.jsx
Normal file
31
frontend-react/src/App.jsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
// frontend-react/src/App.jsx
|
||||||
|
import React from 'react';
|
||||||
|
import Toolbar from './components/ToolBar/ToolBar';
|
||||||
|
import ChatBox from './components/ChatBox/ChatBox';
|
||||||
|
import SideBarLeft from './components/SideBarLeft/SideBarLeft';
|
||||||
|
import SideBarRight from './components/SideBarRight/SideBarRight';
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<div className="app">
|
||||||
|
<Toolbar />
|
||||||
|
|
||||||
|
{/* 主内容容器 */}
|
||||||
|
<div className="main-container">
|
||||||
|
{/* 左侧栏 - 预设面板 */}
|
||||||
|
<SideBarLeft />
|
||||||
|
|
||||||
|
{/* 中间栏:聊天框 */}
|
||||||
|
<div className="chat-area">
|
||||||
|
<ChatBox />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 右侧栏 */}
|
||||||
|
<SideBarRight />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
275
frontend-react/src/Store/Slices/ChatBoxSlice.jsx
Normal file
275
frontend-react/src/Store/Slices/ChatBoxSlice.jsx
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { subscribeWithSelector } from 'zustand/middleware';
|
||||||
|
|
||||||
|
const useChatBoxStore = create(
|
||||||
|
subscribeWithSelector((set, get) => ({
|
||||||
|
// 聊天历史消息列表
|
||||||
|
messages: [],
|
||||||
|
|
||||||
|
// 用户名称
|
||||||
|
userName: '',
|
||||||
|
|
||||||
|
// 角色名称
|
||||||
|
characterName: '',
|
||||||
|
|
||||||
|
// 当前选中的角色
|
||||||
|
currentRole: null,
|
||||||
|
|
||||||
|
// 当前选中的聊天
|
||||||
|
currentChat: null,
|
||||||
|
|
||||||
|
// 是否正在加载
|
||||||
|
isLoading: false,
|
||||||
|
|
||||||
|
// 是否正在生成
|
||||||
|
isGenerating: false,
|
||||||
|
|
||||||
|
// 错误信息
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
// 设置消息列表
|
||||||
|
setMessages: (messages) => set({ messages }),
|
||||||
|
|
||||||
|
// 设置用户名称
|
||||||
|
setUserName: (userName) => set({ userName }),
|
||||||
|
|
||||||
|
// 设置角色名称
|
||||||
|
setCharacterName: (characterName) => set({ characterName }),
|
||||||
|
|
||||||
|
// 设置当前角色
|
||||||
|
setCurrentRole: (role) => set({ currentRole: role }),
|
||||||
|
|
||||||
|
// 设置当前聊天
|
||||||
|
setCurrentChat: (chat) => set({ currentChat: chat }),
|
||||||
|
|
||||||
|
// 同时设置角色和聊天
|
||||||
|
setChatBoxRoleAndChat: (role, chat) => {
|
||||||
|
console.log('[ChatBoxStore] setChatBoxRoleAndChat called with:', { role, chat });
|
||||||
|
set({
|
||||||
|
currentRole: role,
|
||||||
|
currentChat: chat
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// 设置生成状态
|
||||||
|
setIsGenerating: (status) => set({ isGenerating: status }),
|
||||||
|
|
||||||
|
// 发送消息
|
||||||
|
sendMessage: async (content) => {
|
||||||
|
const { messages, userName, characterName, currentRole, currentChat } = get();
|
||||||
|
|
||||||
|
set({
|
||||||
|
isGenerating: true,
|
||||||
|
messages: [...messages, {
|
||||||
|
id: Date.now(),
|
||||||
|
floor: messages.length + 1,
|
||||||
|
mes: content,
|
||||||
|
is_user: true
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/chats/${encodeURIComponent(currentRole)}/${encodeURIComponent(currentChat)}/messages`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
floor: messages.length + 1,
|
||||||
|
mes: content,
|
||||||
|
is_user: true
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to send message');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
set((state) => ({
|
||||||
|
messages: [...state.messages, {
|
||||||
|
id: Date.now(),
|
||||||
|
floor: state.messages.length + 1,
|
||||||
|
mes: data.response,
|
||||||
|
is_user: false
|
||||||
|
}],
|
||||||
|
isGenerating: false
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
set({
|
||||||
|
error: error.message,
|
||||||
|
isGenerating: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 终止生成
|
||||||
|
stopGeneration: () => set({ isGenerating: false }),
|
||||||
|
|
||||||
|
// 加载聊天历史
|
||||||
|
fetchChatHistory: async (roleName, chatName) => {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
try {
|
||||||
|
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();
|
||||||
|
|
||||||
|
set({
|
||||||
|
messages: data.messages || [],
|
||||||
|
userName: data.metadata?.user_name || 'User',
|
||||||
|
characterName: data.metadata?.character_name || roleName || 'Assistant',
|
||||||
|
isLoading: false
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
set({
|
||||||
|
error: error.message,
|
||||||
|
isLoading: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 清空聊天历史
|
||||||
|
clearChatHistory: () => set({
|
||||||
|
messages: [],
|
||||||
|
userName: '',
|
||||||
|
characterName: '',
|
||||||
|
error: null
|
||||||
|
}),
|
||||||
|
|
||||||
|
// 更新特定消息的内容
|
||||||
|
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.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
// 监听角色和聊天变化,自动加载聊天历史
|
||||||
|
useChatBoxStore.subscribe(
|
||||||
|
(state) => ({ role: state.currentRole, chat: state.currentChat }),
|
||||||
|
({ role, chat }, prev) => {
|
||||||
|
// 只有当角色或聊天发生变化时才处理
|
||||||
|
if (role !== prev.role || chat !== prev.chat) {
|
||||||
|
// 确保角色和聊天都存在且不为null
|
||||||
|
if (role && chat) {
|
||||||
|
useChatBoxStore.getState().fetchChatHistory(role, chat);
|
||||||
|
} else {
|
||||||
|
useChatBoxStore.getState().clearChatHistory();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ 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;
|
||||||
325
frontend-react/src/Store/Slices/RoleSelectorSlice.jsx
Normal file
325
frontend-react/src/Store/Slices/RoleSelectorSlice.jsx
Normal file
@@ -0,0 +1,325 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
// 异步获取角色数据
|
||||||
|
const fetchRoleData = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/chats', {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
'Pragma': 'no-cache',
|
||||||
|
'Expires': '0'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// 转换数据格式以适应前端需求
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const useRoleSelectorStore = create((set, get) => ({
|
||||||
|
// 状态
|
||||||
|
roleData: {},
|
||||||
|
selectedRole: null,
|
||||||
|
selectedChat: null,
|
||||||
|
hoveredRole: null,
|
||||||
|
clickedRole: null,
|
||||||
|
isLoading: false,
|
||||||
|
searchTerm: '',
|
||||||
|
editingRole: null,
|
||||||
|
editingChat: null,
|
||||||
|
showDeleteConfirm: null,
|
||||||
|
deleteType: null,
|
||||||
|
|
||||||
|
// 操作
|
||||||
|
fetchRoleData: async () => {
|
||||||
|
set({ isLoading: true });
|
||||||
|
try {
|
||||||
|
const data = await fetchRoleData();
|
||||||
|
set({ roleData: data, isLoading: false });
|
||||||
|
} catch (error) {
|
||||||
|
set({ isLoading: false });
|
||||||
|
console.error('获取角色数据失败:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setSelectedRole: (role) => set({ selectedRole: role }),
|
||||||
|
|
||||||
|
setSelectedChat: (chat) => set({ selectedChat: chat }),
|
||||||
|
|
||||||
|
setHoveredRole: (role) => set({ hoveredRole: role }),
|
||||||
|
|
||||||
|
setClickedRole: (role) => set({ clickedRole: role }),
|
||||||
|
|
||||||
|
setSearchTerm: (term) => set({ searchTerm: term }),
|
||||||
|
|
||||||
|
setEditingRole: (role) => set({ editingRole: role }),
|
||||||
|
|
||||||
|
setEditingChat: (chat) => set({ editingChat: chat }),
|
||||||
|
|
||||||
|
setShowDeleteConfirm: (name) => set({ showDeleteConfirm: name }),
|
||||||
|
|
||||||
|
setDeleteType: (type) => set({ deleteType: type }),
|
||||||
|
|
||||||
|
// 同时更新角色和聊天
|
||||||
|
setSelectedRoleAndChat: (role, chat) => set({ selectedRole: role, selectedChat: chat }),
|
||||||
|
|
||||||
|
// 处理角色重命名
|
||||||
|
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 };
|
||||||
|
newRoleData[newName] = chats;
|
||||||
|
delete newRoleData[oldName];
|
||||||
|
|
||||||
|
if (selectedRole === oldName) {
|
||||||
|
set({
|
||||||
|
roleData: newRoleData,
|
||||||
|
selectedRole: newName,
|
||||||
|
editingRole: null
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
set({
|
||||||
|
roleData: newRoleData,
|
||||||
|
editingRole: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('重命名角色失败:', error);
|
||||||
|
set({ editingRole: null });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
set({ editingRole: null });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 处理聊天重命名
|
||||||
|
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) {
|
||||||
|
newRoleData[selectedRole][chatIndex] = newName;
|
||||||
|
|
||||||
|
if (selectedChat === oldName) {
|
||||||
|
set({
|
||||||
|
roleData: newRoleData,
|
||||||
|
selectedChat: newName,
|
||||||
|
editingChat: null
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
set({
|
||||||
|
roleData: newRoleData,
|
||||||
|
editingChat: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
set({ editingChat: null });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('重命名聊天失败:', error);
|
||||||
|
set({ editingChat: null });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
set({ editingChat: null });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 确认删除
|
||||||
|
confirmDelete: async () => {
|
||||||
|
const { roleData, selectedRole, selectedChat, showDeleteConfirm, deleteType } = get();
|
||||||
|
|
||||||
|
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,
|
||||||
|
selectedRole: null,
|
||||||
|
selectedChat: null,
|
||||||
|
showDeleteConfirm: null,
|
||||||
|
deleteType: null
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
set({
|
||||||
|
roleData: newRoleData,
|
||||||
|
showDeleteConfirm: null,
|
||||||
|
deleteType: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} 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,
|
||||||
|
selectedChat: null,
|
||||||
|
showDeleteConfirm: null,
|
||||||
|
deleteType: null
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
set({
|
||||||
|
roleData: newRoleData,
|
||||||
|
showDeleteConfirm: null,
|
||||||
|
deleteType: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
set({
|
||||||
|
showDeleteConfirm: null,
|
||||||
|
deleteType: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('删除失败:', error);
|
||||||
|
set({
|
||||||
|
showDeleteConfirm: null,
|
||||||
|
deleteType: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 取消删除
|
||||||
|
cancelDelete: () => set({ showDeleteConfirm: null, deleteType: null }),
|
||||||
|
|
||||||
|
// 添加新角色
|
||||||
|
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] = ['默认聊天'];
|
||||||
|
set({
|
||||||
|
roleData: newRoleData,
|
||||||
|
editingRole: newRole
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('添加角色失败:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 添加新聊天
|
||||||
|
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,
|
||||||
|
editingRole: null,
|
||||||
|
editingChat: null,
|
||||||
|
showDeleteConfirm: null
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
|
||||||
|
export default useRoleSelectorStore;
|
||||||
5
frontend-react/src/Store/indexStore.jsx
Normal file
5
frontend-react/src/Store/indexStore.jsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
// frontend-react/src/store/index.js
|
||||||
|
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';
|
||||||
583
frontend-react/src/components/ChatBox/ChatBox.css
Normal file
583
frontend-react/src/components/ChatBox/ChatBox.css
Normal file
@@ -0,0 +1,583 @@
|
|||||||
|
/* ==================== 聊天框区域 ==================== */
|
||||||
|
|
||||||
|
/* React 组件根容器 */
|
||||||
|
.chat-box {
|
||||||
|
height: 100%; /* 修改为100%,填满父容器 */
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background-color: #fafafa;
|
||||||
|
overflow: hidden; /* 防止内容溢出 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 消息列表容器 */
|
||||||
|
.chat-messages {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 20px;
|
||||||
|
padding-top: 60px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 设置面板 */
|
||||||
|
.settings-panel {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 20;
|
||||||
|
background-color: #fff;
|
||||||
|
border-bottom-left-radius: 8px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||||
|
overflow: hidden;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
max-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-panel.collapsed {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-panel.expanded {
|
||||||
|
width: auto;
|
||||||
|
min-width: 150px;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid #eee;
|
||||||
|
border-top: none;
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-header {
|
||||||
|
height: 40px;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 18px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-panel.collapsed .settings-header:hover {
|
||||||
|
background-color: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-options {
|
||||||
|
display: none;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
padding-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-panel.expanded .settings-options {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-item input[type="checkbox"] {
|
||||||
|
margin-right: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 聊天气泡样式 ==================== */
|
||||||
|
|
||||||
|
/* 消息行容器 */
|
||||||
|
.message {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 气泡本体 */
|
||||||
|
.message .bubble {
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 10px 15px;
|
||||||
|
border-radius: 12px;
|
||||||
|
position: relative;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
word-wrap: break-word;
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||||
|
/* 新增:确保内容区域可以容纳swipe控件 */
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 区分左右布局 */
|
||||||
|
.message.user {
|
||||||
|
justify-content: flex-end; /* 用户消息靠右 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.ai {
|
||||||
|
justify-content: flex-start; /* AI 消息靠左 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 消息容器 - 调整为垂直排列 */
|
||||||
|
.message-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-width: 70%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 消息头部 - 包含名称和工具栏 */
|
||||||
|
.message-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
order: 1; /* 确保头部显示在第一个位置 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 消息名称 */
|
||||||
|
.message-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background-color: rgba(0, 0, 0, 0.05);
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 用户消息名称 */
|
||||||
|
.message.user .message-name {
|
||||||
|
background-color: rgba(24, 144, 255, 0.1);
|
||||||
|
color: #1890ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* AI消息名称 */
|
||||||
|
.message.ai .message-name {
|
||||||
|
background-color: rgba(0, 0, 0, 0.05);
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 消息工具栏 - 优化布局使图标更紧凑 */
|
||||||
|
.message-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 5px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message:hover .message-toolbar {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-id {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #888;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: rgba(0, 0, 0, 0.05);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 3px; /* 减少图标之间的间距 */
|
||||||
|
margin: 0 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border: none;
|
||||||
|
background-color: rgba(0, 0, 0, 0.05);
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #555;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-button:hover {
|
||||||
|
background-color: rgba(0, 0, 0, 0.1);
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* AI消息头部布局:AI助手 - ID - 编辑 - 更多 */
|
||||||
|
.message.ai .message-header {
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.ai .message-name {
|
||||||
|
order: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.ai .message-id {
|
||||||
|
order: 2;
|
||||||
|
margin-left: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.ai .toolbar-buttons {
|
||||||
|
order: 3;
|
||||||
|
margin-left: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 用户消息头部布局:更多 - 编辑 - ID - 我 */
|
||||||
|
.message.user .message-header {
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.user .message-name {
|
||||||
|
order: 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.user .message-id {
|
||||||
|
order: 3;
|
||||||
|
margin-right: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.user .toolbar-buttons {
|
||||||
|
order: 1;
|
||||||
|
margin-right: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 消息内容 - 确保显示在名称下方 */
|
||||||
|
.message-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-width: 100%;
|
||||||
|
order: 2; /* 确保内容显示在第二个位置 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 气泡本体 */
|
||||||
|
.message .bubble {
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 10px 15px;
|
||||||
|
border-radius: 12px;
|
||||||
|
position: relative;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
word-wrap: break-word;
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* AI 气泡样式 */
|
||||||
|
.message.ai .bubble {
|
||||||
|
background-color: #fff;
|
||||||
|
color: #333;
|
||||||
|
border-top-left-radius: 2px;
|
||||||
|
border-bottom-left-radius: 2px;
|
||||||
|
border: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 用户气泡样式 */
|
||||||
|
.message.user .bubble {
|
||||||
|
background-color: #1890ff;
|
||||||
|
color: #fff;
|
||||||
|
border-top-right-radius: 2px;
|
||||||
|
border-bottom-right-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 针对 AI 消息中的 HTML 内容进行简单的样式重置 */
|
||||||
|
.message.ai .bubble p {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
}
|
||||||
|
.message.ai .bubble p:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.message.ai .bubble ul, .message.ai .bubble ol {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
.message.ai .bubble b {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 编辑模式 */
|
||||||
|
.edit-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-textarea {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 80px;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 8px;
|
||||||
|
resize: vertical;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #1890ff;
|
||||||
|
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-buttons {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-button, .cancel-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-button {
|
||||||
|
background-color: #1890ff;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-button:hover {
|
||||||
|
background-color: #40a9ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-button {
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-button:hover {
|
||||||
|
background-color: #e6e6e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 输入区域 ==================== */
|
||||||
|
|
||||||
|
.chat-input-wrapper {
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
border-top: 1px solid #ddd;
|
||||||
|
padding: 10px 20px;
|
||||||
|
height: 62px; /* 明确设置高度 */
|
||||||
|
flex-shrink: 0; /* 防止被压缩 */
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.chat-input-area {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input-area textarea {
|
||||||
|
width: 100%;
|
||||||
|
height: 42px;
|
||||||
|
min-height: 42px;
|
||||||
|
max-height: 300px;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
resize: none;
|
||||||
|
outline: none;
|
||||||
|
font-family: inherit;
|
||||||
|
line-height: 1.5;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.send-button {
|
||||||
|
height: 42px;
|
||||||
|
padding: 0 20px;
|
||||||
|
background-color: #1890ff;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.send-button:hover {
|
||||||
|
background-color: #40a9ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 加载状态样式 */
|
||||||
|
.loading {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 20px;
|
||||||
|
color: #888;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 错误信息样式 */
|
||||||
|
.error {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 20px;
|
||||||
|
color: #f5222d;
|
||||||
|
font-size: 14px;
|
||||||
|
background-color: rgba(245, 34, 45, 0.05);
|
||||||
|
border-radius: 4px;
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 确保消息名称和工具栏在移动设备上也能正常显示 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.message-container {
|
||||||
|
max-width: 85%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-header {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-toolbar {
|
||||||
|
opacity: 1; /* 在移动设备上始终显示工具栏 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 添加滚动条样式,使其更美观 */
|
||||||
|
.chat-messages::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-messages::-webkit-scrollbar-track {
|
||||||
|
background: #f1f1f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-messages::-webkit-scrollbar-thumb {
|
||||||
|
background: #c1c1c1;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-messages::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #a8a8a8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 确保消息内容中的链接样式 */
|
||||||
|
.bubble a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 代码块样式 */
|
||||||
|
.bubble pre {
|
||||||
|
background-color: #f6f8fa;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble code {
|
||||||
|
font-family: 'Courier New', Courier, monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.options-button {
|
||||||
|
height: 42px;
|
||||||
|
width: 42px;
|
||||||
|
background-color: transparent;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 20px;
|
||||||
|
color: #666;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.options-button:hover {
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.send-button {
|
||||||
|
height: 42px;
|
||||||
|
width: 42px; /* 添加固定宽度,使按钮为正方形 */
|
||||||
|
padding: 0;
|
||||||
|
background-color: #1890ff;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex; /* 添加 flex 布局 */
|
||||||
|
align-items: center; /* 垂直居中 */
|
||||||
|
justify-content: center; /* 水平居中 */
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 18px; /* 设置图标大小 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.send-button:hover {
|
||||||
|
background-color: #40a9ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.send-button.stopping {
|
||||||
|
background-color: #ff4d4f; /* 终止状态下的背景色 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.send-button.stopping:hover {
|
||||||
|
background-color: #ff7875;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Swipe控制按钮样式 */
|
||||||
|
.swipe-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 8px;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swipe-button {
|
||||||
|
background-color: rgba(0, 0, 0, 0.05);
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #555;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swipe-button:hover:not(:disabled) {
|
||||||
|
background-color: rgba(0, 0, 0, 0.1);
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swipe-button:disabled {
|
||||||
|
opacity: 0.3;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swipe-counter {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #888;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
254
frontend-react/src/components/ChatBox/ChatBox.jsx
Normal file
254
frontend-react/src/components/ChatBox/ChatBox.jsx
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
|
import useChatBoxStore from '../../Store/Slices/ChatBoxSlice';
|
||||||
|
import './ChatBox.css';
|
||||||
|
|
||||||
|
const ChatBox = () => {
|
||||||
|
const [editingId, setEditingId] = useState(null);
|
||||||
|
const [editContent, setEditContent] = useState('');
|
||||||
|
const messagesEndRef = useRef(null);
|
||||||
|
const [inputValue, setInputValue] = useState('');
|
||||||
|
|
||||||
|
// 新增:管理每条消息的当前显示的swipe版本
|
||||||
|
const [currentSwipeId, setCurrentSwipeId] = useState({});
|
||||||
|
|
||||||
|
// 从 ChatBoxStore 获取状态和方法
|
||||||
|
const {
|
||||||
|
messages,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
userName,
|
||||||
|
characterName,
|
||||||
|
updateMessage,
|
||||||
|
isGenerating,
|
||||||
|
sendMessage,
|
||||||
|
stopGeneration
|
||||||
|
} = useChatBoxStore();
|
||||||
|
|
||||||
|
const [inputHeight, setInputHeight] = useState(42);
|
||||||
|
|
||||||
|
// 添加输入框高度自适应处理
|
||||||
|
const handleInputHeight = (e) => {
|
||||||
|
const textarea = e.target;
|
||||||
|
const newHeight = Math.min(Math.max(textarea.scrollHeight, 42), 300);
|
||||||
|
setInputHeight(newHeight);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理发送或终止
|
||||||
|
const handleSendOrStop = () => {
|
||||||
|
if (isGenerating) {
|
||||||
|
stopGeneration();
|
||||||
|
} else {
|
||||||
|
sendMessage(inputValue);
|
||||||
|
setInputValue('');
|
||||||
|
setInputHeight(42);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理键盘事件
|
||||||
|
const handleKeyDown = (e) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSendOrStop();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 自动滚动到底部
|
||||||
|
const scrollToBottom = () => {
|
||||||
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
scrollToBottom();
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
// 处理编辑消息
|
||||||
|
const handleEdit = (message) => {
|
||||||
|
setEditingId(message.floor);
|
||||||
|
setEditContent(message.mes);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 保存编辑
|
||||||
|
const handleSaveEdit = (messageId) => {
|
||||||
|
// 调用 store 中的 updateMessage 方法
|
||||||
|
updateMessage(messageId, editContent);
|
||||||
|
setEditingId(null);
|
||||||
|
setEditContent('');
|
||||||
|
};
|
||||||
|
|
||||||
|
// 取消编辑
|
||||||
|
const handleCancelEdit = () => {
|
||||||
|
setEditingId(null);
|
||||||
|
setEditContent('');
|
||||||
|
};
|
||||||
|
|
||||||
|
// 新增:处理swipe切换
|
||||||
|
const handleSwipeChange = (messageId, direction) => {
|
||||||
|
const message = messages.find(m => m.floor === messageId);
|
||||||
|
if (message && message.swipes && message.swipes.length > 0) {
|
||||||
|
const currentIndex = currentSwipeId[messageId] !== undefined
|
||||||
|
? currentSwipeId[messageId]
|
||||||
|
: message.swipe_id;
|
||||||
|
const newIndex = currentIndex + direction;
|
||||||
|
|
||||||
|
if (newIndex >= 0 && newIndex < message.swipes.length) {
|
||||||
|
setCurrentSwipeId(prev => ({
|
||||||
|
...prev,
|
||||||
|
[messageId]: newIndex
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 渲染单条消息
|
||||||
|
const renderMessage = (message) => {
|
||||||
|
const isUser = message.is_user;
|
||||||
|
const isEditing = editingId === message.floor;
|
||||||
|
|
||||||
|
// 判断是否为最新消息
|
||||||
|
const isLatestMessage = messages.length > 0 && message.floor === messages[messages.length - 1].floor;
|
||||||
|
|
||||||
|
// 根据消息类型设置显示名称
|
||||||
|
const displayName = isUser ? userName : characterName;
|
||||||
|
|
||||||
|
// 确定当前显示的消息内容
|
||||||
|
let currentMes = message.mes;
|
||||||
|
let hasSwipes = message.swipes && message.swipes.length > 0;
|
||||||
|
let currentSwipeIndex = message.swipe_id;
|
||||||
|
|
||||||
|
if (hasSwipes) {
|
||||||
|
// 如果有swipes数组
|
||||||
|
if (currentSwipeId[message.floor] !== undefined) {
|
||||||
|
// 如果用户已经切换过版本,使用用户选择的版本
|
||||||
|
currentSwipeIndex = currentSwipeId[message.floor];
|
||||||
|
} else {
|
||||||
|
// 否则使用默认的swipe_id
|
||||||
|
currentSwipeIndex = message.swipe_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentSwipeIndex >= 0 && currentSwipeIndex < message.swipes.length) {
|
||||||
|
currentMes = message.swipes[currentSwipeIndex];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={message.floor} className={`message ${isUser ? 'user' : 'ai'}`}>
|
||||||
|
<div className="message-container">
|
||||||
|
<div className="message-header">
|
||||||
|
<span className="message-name">{displayName}</span>
|
||||||
|
<span className="message-id">#{message.floor}</span>
|
||||||
|
<div className="message-toolbar">
|
||||||
|
<div className="toolbar-buttons">
|
||||||
|
<button
|
||||||
|
className="toolbar-button"
|
||||||
|
onClick={() => handleEdit(message)}
|
||||||
|
title="编辑"
|
||||||
|
>
|
||||||
|
✎
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="toolbar-button"
|
||||||
|
title="更多"
|
||||||
|
>
|
||||||
|
•••
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="message-content">
|
||||||
|
{isEditing ? (
|
||||||
|
<div className="edit-container">
|
||||||
|
<textarea
|
||||||
|
className="edit-textarea"
|
||||||
|
value={editContent}
|
||||||
|
onChange={(e) => setEditContent(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="edit-buttons">
|
||||||
|
<button
|
||||||
|
className="cancel-button"
|
||||||
|
onClick={handleCancelEdit}
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="save-button"
|
||||||
|
onClick={() => handleSaveEdit(message.floor)}
|
||||||
|
>
|
||||||
|
保存
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bubble">
|
||||||
|
{currentMes}
|
||||||
|
{hasSwipes && isLatestMessage && !isUser && (
|
||||||
|
<div className="swipe-controls">
|
||||||
|
<button
|
||||||
|
className="swipe-button"
|
||||||
|
onClick={() => handleSwipeChange(message.floor, -1)}
|
||||||
|
disabled={currentSwipeIndex === 0}
|
||||||
|
>
|
||||||
|
◀
|
||||||
|
</button>
|
||||||
|
<span className="swipe-counter">
|
||||||
|
{currentSwipeIndex + 1}/{message.swipes.length}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className="swipe-button"
|
||||||
|
onClick={() => handleSwipeChange(message.floor, 1)}
|
||||||
|
disabled={currentSwipeIndex === message.swipes.length - 1}
|
||||||
|
>
|
||||||
|
▶
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="chat-box">
|
||||||
|
<div className="chat-messages">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="loading">加载中...</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="error">{error}</div>
|
||||||
|
) : messages.length === 0 ? (
|
||||||
|
<div className="loading">暂无消息</div>
|
||||||
|
) : (
|
||||||
|
messages.map(renderMessage)
|
||||||
|
)}
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
|
</div>
|
||||||
|
<div className="chat-input-wrapper">
|
||||||
|
<button className="options-button" title="展开选项">
|
||||||
|
☰
|
||||||
|
</button>
|
||||||
|
<div className="chat-input-area">
|
||||||
|
<textarea
|
||||||
|
value={inputValue}
|
||||||
|
onChange={(e) => {
|
||||||
|
setInputValue(e.target.value);
|
||||||
|
handleInputHeight(e);
|
||||||
|
}}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
style={{ height: `${inputHeight}px` }}
|
||||||
|
placeholder="输入消息..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className={`send-button ${isGenerating ? 'stopping' : ''}`}
|
||||||
|
onClick={handleSendOrStop}
|
||||||
|
disabled={!inputValue.trim() && !isGenerating}
|
||||||
|
>
|
||||||
|
{isGenerating ? '■' : '➤'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ChatBox;
|
||||||
146
frontend-react/src/components/Markdown2Html/Markdown2Html.jsx
Normal file
146
frontend-react/src/components/Markdown2Html/Markdown2Html.jsx
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
// // npm install react react-dom react-markdown react-syntax-highlighter remark-math rehype-katex react-copy-to-clipboard mermaid katex
|
||||||
|
// import React, { useState, useCallback, useEffect } from "react";
|
||||||
|
// import ReactMarkdown from "react-markdown";
|
||||||
|
// import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||||
|
// import { materialLight } from "react-syntax-highlighter/dist/cjs/styles/prism";
|
||||||
|
// import remarkMath from "remark-math";
|
||||||
|
// import rehypeKatex from "rehype-katex";
|
||||||
|
// import { CopyToClipboard } from "react-copy-to-clipboard";
|
||||||
|
// import mermaid from "mermaid";
|
||||||
|
// import "katex/dist/katex.min.css";
|
||||||
|
//
|
||||||
|
// // Mermaid 初始化配置
|
||||||
|
// // Mermaid是一个画图的语言
|
||||||
|
// mermaid.initialize({
|
||||||
|
// startOnLoad: false,
|
||||||
|
// theme: "default",
|
||||||
|
// securityLevel: "loose",
|
||||||
|
// });
|
||||||
|
//
|
||||||
|
// const DownSvg = () => (
|
||||||
|
// <svg width="12" height="12" viewBox="0 0 24 24">
|
||||||
|
// <path d="M7 10l5 5 5-5z" fill="currentColor" />
|
||||||
|
// </svg>
|
||||||
|
// );
|
||||||
|
//
|
||||||
|
// const CopySvg = () => (
|
||||||
|
// <svg width="14" height="14" viewBox="0 0 24 24">
|
||||||
|
// <path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z" fill="currentColor" />
|
||||||
|
// </svg>
|
||||||
|
// );
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// const MermaidChart= ({ code }) => {
|
||||||
|
// const [svg, setSvg] = useState("");
|
||||||
|
// const [id] = useState(() => `mermaid-${Math.random().toString(36).substr(2, 9)}`);
|
||||||
|
//
|
||||||
|
// useEffect(() => {
|
||||||
|
// try {
|
||||||
|
// mermaid.parse(code);
|
||||||
|
// mermaid.render(id, code).then(({ svg }) => setSvg(svg));
|
||||||
|
// } catch (err) {
|
||||||
|
// setSvg(`<pre>Error rendering mermaid: ${err}</pre>`);
|
||||||
|
// }
|
||||||
|
// }, [code, id]);
|
||||||
|
//
|
||||||
|
// return <div dangerouslySetInnerHTML={{ __html: svg }} />;
|
||||||
|
// };
|
||||||
|
//
|
||||||
|
// const MarkdownToHTML= ({ markdownRAW, className }) => {
|
||||||
|
// const [isMermaidLoaded] = useState(true);
|
||||||
|
// // 渲染代码块的方法
|
||||||
|
// const CodeBlock = useCallback(
|
||||||
|
// ({ node, inline, className, children, ...props } ) => {
|
||||||
|
// const [isShowCode, setIsShowCode] = useState(true); // 添加展开与收起代码块状态
|
||||||
|
// const [isShowCopy, setIsShowCopy] = useState(false);// 添加点击复制的状态
|
||||||
|
// const match = /language-(\w+)/.exec(className || ""); // 这是用来匹配代码块对应语言的方法
|
||||||
|
// const codeContent = String(children).replace(/\n$/, "");
|
||||||
|
// // 处理复制成功提示
|
||||||
|
// const handleCopy = () => {
|
||||||
|
// setIsShowCopy(true);
|
||||||
|
// setTimeout(() => setIsShowCopy(false), 1500);
|
||||||
|
// };
|
||||||
|
// //处理单行代码
|
||||||
|
// if (inline) {
|
||||||
|
// return <code className={className} {...props}>{children}</code>;
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// // 处理 Mermaid 图表代码
|
||||||
|
// if (match?.[1] === "mermaid" && isMermaidLoaded) {
|
||||||
|
// return (
|
||||||
|
// <div style={{ position: "relative", margin: "20px 0" }}>
|
||||||
|
// <div className="code-header">
|
||||||
|
// <div
|
||||||
|
// style={{ cursor: "pointer", marginRight: "10px", transformOrigin: "8px" }}
|
||||||
|
// className={isShowCode ? "code-rotate-down" : "code-rotate-right"}
|
||||||
|
// onClick={() => setIsShowCode(!isShowCode)}
|
||||||
|
// >
|
||||||
|
// <DownSvg />
|
||||||
|
// </div>
|
||||||
|
// <div>{match[1]}</div>
|
||||||
|
// <CopyToClipboard text={codeContent} onCopy={handleCopy}>
|
||||||
|
// <div className="preview-code-copy" style={{ cursor: "pointer" }}>
|
||||||
|
// {isShowCopy && <span className="copy-success">✓ 复制成功</span>}
|
||||||
|
// <CopySvg />
|
||||||
|
// </div>
|
||||||
|
// </CopyToClipboard>
|
||||||
|
// </div>
|
||||||
|
// {isShowCode && <MermaidChart code={codeContent} />}
|
||||||
|
// </div>
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// return (
|
||||||
|
// <div style={{ position: "relative", padding:"0"}}>
|
||||||
|
// <div className="code-header">
|
||||||
|
// <div
|
||||||
|
// style={{ cursor: "pointer", marginRight: "10px", transformOrigin: "8px" }}
|
||||||
|
// className={isShowCode ? "code-rotate-down" : "code-rotate-right"}
|
||||||
|
// onClick={() => setIsShowCode(!isShowCode)}
|
||||||
|
// >
|
||||||
|
// <DownSvg style={{
|
||||||
|
// transform: isShowCode ? "rotate(0deg)" : "rotate(-90deg)",
|
||||||
|
// transition: "transform 0.2s"
|
||||||
|
// }} />
|
||||||
|
// </div>
|
||||||
|
// <div>{match?.[1] || "code"}</div>
|
||||||
|
// <CopyToClipboard text={codeContent} onCopy={handleCopy}>
|
||||||
|
// <div className="preview-code-copy" style={{ cursor: "pointer" }}>
|
||||||
|
// {isShowCopy && <span className="copy-success">✓ 复制成功</span>}
|
||||||
|
// <CopySvg />
|
||||||
|
// </div>
|
||||||
|
// </CopyToClipboard>
|
||||||
|
// </div>
|
||||||
|
// {isShowCode && (
|
||||||
|
// <SyntaxHighlighter
|
||||||
|
// style={materialLight}
|
||||||
|
// language={match?.[1] || "text"}
|
||||||
|
// PreTag="div"
|
||||||
|
// showLineNumbers
|
||||||
|
// {...props}
|
||||||
|
// >
|
||||||
|
// {codeContent}
|
||||||
|
// </SyntaxHighlighter>
|
||||||
|
// )}
|
||||||
|
// </div>
|
||||||
|
// );
|
||||||
|
// },
|
||||||
|
// [isMermaidLoaded]
|
||||||
|
// );
|
||||||
|
//
|
||||||
|
// return (
|
||||||
|
// <div
|
||||||
|
// className={className} >
|
||||||
|
// <ReactMarkdown
|
||||||
|
// remarkPlugins={[remarkMath]}
|
||||||
|
// rehypePlugins={[rehypeKatex]}
|
||||||
|
// components={{ code: CodeBlock }}
|
||||||
|
// >
|
||||||
|
// {markdownRAW}
|
||||||
|
// </ReactMarkdown>
|
||||||
|
//
|
||||||
|
// </div>
|
||||||
|
// );
|
||||||
|
// };
|
||||||
|
//
|
||||||
|
// export default MarkdownToHTML;
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
// 下面这些包如果显示不存在则运行这个代码安装依赖:
|
||||||
|
// npm install marked marked-highlight highlight.js
|
||||||
|
import { marked } from "marked";
|
||||||
|
import { markedHighlight } from "marked-highlight";
|
||||||
|
import hljs from "highlight.js";
|
||||||
|
import "highlight.js/styles/base16/github.css";
|
||||||
|
|
||||||
|
// 配置 marked 和 markedHighlight
|
||||||
|
marked.use(
|
||||||
|
markedHighlight({
|
||||||
|
langPrefix: "hljs language-",
|
||||||
|
highlight(code, lang) {
|
||||||
|
const language = hljs.getLanguage(lang) ? lang : "plaintext"; // 如果语言不支持,回退为纯文本
|
||||||
|
return hljs.highlight(code, { language }).value; // 使用 highlight.js 高亮代码
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Markdown 解析函数
|
||||||
|
const MarkdownRenderer = (markdownString)=> {
|
||||||
|
const res= marked.parse(markdownString).toString()
|
||||||
|
return res; // 将 Markdown 转换为 HTML
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MarkdownRenderer;
|
||||||
544
frontend-react/src/components/RoleSelector/RoleSelector.css
Normal file
544
frontend-react/src/components/RoleSelector/RoleSelector.css
Normal file
@@ -0,0 +1,544 @@
|
|||||||
|
/* ==================== 角色选择器容器 ==================== */
|
||||||
|
|
||||||
|
.role-selector {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
padding: 24px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background-color: #f8fafc;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
/* 添加微妙的背景纹理 */
|
||||||
|
background-image: radial-gradient(#e5e7eb 1px, transparent 1px);
|
||||||
|
background-size: 20px 20px;
|
||||||
|
/* 添加平滑滚动 */
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
/* 优化移动端体验 */
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 当前选中角色显示 ==================== */
|
||||||
|
|
||||||
|
.selected-role-display {
|
||||||
|
padding: 16px 24px;
|
||||||
|
border-radius: 12px;
|
||||||
|
/* 更丰富的渐变效果 */
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
margin-bottom: 24px;
|
||||||
|
/* 增强阴影效果 */
|
||||||
|
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.25), 0 0 0 1px rgba(255, 255, 255, 0.1) inset;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
/* 添加微妙的动画效果 */
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 悬停效果 */
|
||||||
|
.selected-role-display:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 16px rgba(102, 126, 234, 0.3), 0 0 0 1px rgba(255, 255, 255, 0.2) inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-badge {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-badge.empty {
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-label {
|
||||||
|
font-weight: 600;
|
||||||
|
margin-right: 8px;
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-name {
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-name {
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 搜索栏样式 ==================== */
|
||||||
|
|
||||||
|
.search-bar {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-bar input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 16px 12px 40px; /* 为搜索图标留出空间 */
|
||||||
|
border: 2px solid transparent;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background-color: #fff;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 添加搜索图标 */
|
||||||
|
.search-bar::before {
|
||||||
|
content: '🔍';
|
||||||
|
position: absolute;
|
||||||
|
left: 14px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
font-size: 16px;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-bar input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #667eea;
|
||||||
|
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-bar input::placeholder {
|
||||||
|
color: #a0a0a0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 角色列表样式 ==================== */
|
||||||
|
|
||||||
|
.role-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); /* 响应式网格布局 */
|
||||||
|
gap: 16px;
|
||||||
|
padding-right: 8px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
/* 自定义滚动条样式 */
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: #cbd5e1 transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Webkit浏览器滚动条样式 */
|
||||||
|
.role-list::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-list::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-list::-webkit-scrollbar-thumb {
|
||||||
|
background-color: #cbd5e1;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-item {
|
||||||
|
border: none;
|
||||||
|
border-radius: 14px;
|
||||||
|
background-color: #fff;
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||||
|
overflow: hidden;
|
||||||
|
height: 200px; /* 设置固定高度 */
|
||||||
|
position: relative;
|
||||||
|
/* 添加微妙的边框效果 */
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-item:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow: 0 8px 16px rgba(102, 126, 234, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-item.active {
|
||||||
|
border: 2px solid #667eea;
|
||||||
|
background-color: #f5f7ff;
|
||||||
|
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-header {
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
flex: 0 0 auto; /* 不再自动伸缩,使用固定高度 */
|
||||||
|
height: 60px; /* 设置固定高度 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-header .role-name {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2937;
|
||||||
|
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;
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
right: 12px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
z-index: 10; /* 确保操作按钮在最上层 */
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.role-item:hover .role-actions {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn {
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 6px;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn:hover {
|
||||||
|
background-color: #fff;
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn.delete:hover {
|
||||||
|
background-color: #fff1f0;
|
||||||
|
color: #ff4d4f;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 聊天列表样式 ==================== */
|
||||||
|
|
||||||
|
.chat-list {
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
padding: 8px 0;
|
||||||
|
background-color: #fafbfc;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-list::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-list::-webkit-scrollbar-thumb {
|
||||||
|
background-color: #cbd5e1;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-item {
|
||||||
|
padding: 10px 16px 10px 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
/* 添加微妙的左边框指示器 */
|
||||||
|
border-left: 3px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-item:hover {
|
||||||
|
background-color: #f0f2f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-item.active {
|
||||||
|
background-color: #eef1ff;
|
||||||
|
color: #667eea;
|
||||||
|
/* 激活状态添加左边框指示器 */
|
||||||
|
border-left-color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-item .chat-name {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #4b5563;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-item.active .chat-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 加载和空状态 ==================== */
|
||||||
|
|
||||||
|
.loading,
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 48px;
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 14px;
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 添加加载动画 */
|
||||||
|
.loading::before {
|
||||||
|
content: '';
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: 3px solid #e5e7eb;
|
||||||
|
border-top-color: #667eea;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 添加空状态图标 */
|
||||||
|
.empty-state::before {
|
||||||
|
content: '📭';
|
||||||
|
font-size: 48px;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 模态框样式 ==================== */
|
||||||
|
|
||||||
|
.delete-confirm-modal {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
animation: fadeIn 0.2s ease-out;
|
||||||
|
/* 添加微妙的背景动画 */
|
||||||
|
background-image: radial-gradient(circle at center, rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.4) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background-color: #fff;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 28px;
|
||||||
|
width: 90%;
|
||||||
|
max-width: 420px;
|
||||||
|
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.15);
|
||||||
|
animation: slideUp 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
/* 添加微妙的边框效果 */
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideUp {
|
||||||
|
from {
|
||||||
|
transform: translateY(24px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content h3 {
|
||||||
|
margin: 0 0 12px 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content p {
|
||||||
|
margin-bottom: 28px;
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions button {
|
||||||
|
padding: 11px 24px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
/* 添加微妙的阴影效果 */
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions button:first-child {
|
||||||
|
background-color: #f3f4f6;
|
||||||
|
color: #4b5563;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions button:first-child:hover {
|
||||||
|
background-color: #e5e7eb;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions button.danger {
|
||||||
|
background-color: #ef4444;
|
||||||
|
color: white;
|
||||||
|
box-shadow: 0 2px 8px rgba(239, 68, 68, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions button.danger:hover {
|
||||||
|
background-color: #f87171;
|
||||||
|
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.4);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 响应式设计 ==================== */
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.role-selector {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-list {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-item {
|
||||||
|
border: none;
|
||||||
|
border-radius: 14px;
|
||||||
|
background-color: #fff;
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 160px; /* 修改:增加最小高度,使其更适配宽度 */
|
||||||
|
position: relative;
|
||||||
|
/* 添加微妙的边框效果 */
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
width: 95%;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.role-list {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-role-display {
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 暗色模式支持 ==================== */
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.role-selector {
|
||||||
|
background-color: #1f2937;
|
||||||
|
background-image: radial-gradient(#374151 1px, transparent 1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-item {
|
||||||
|
background-color: #374151;
|
||||||
|
border-color: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-item:hover {
|
||||||
|
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-header .role-name {
|
||||||
|
color: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-list {
|
||||||
|
background-color: #374151;
|
||||||
|
border-top-color: #4b5563;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-item:hover {
|
||||||
|
background-color: #4b5563;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-item .chat-name {
|
||||||
|
color: #d1d5db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-bar input {
|
||||||
|
background-color: #374151;
|
||||||
|
color: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background-color: #374151;
|
||||||
|
border-color: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content h3 {
|
||||||
|
color: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content p {
|
||||||
|
color: #d1d5db;
|
||||||
|
}
|
||||||
|
}
|
||||||
333
frontend-react/src/components/RoleSelector/RoleSelector.jsx
Normal file
333
frontend-react/src/components/RoleSelector/RoleSelector.jsx
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
import React, { useEffect, useRef } from 'react';
|
||||||
|
import useRoleSelectorStore from '../../Store/Slices/RoleSelectorSlice';
|
||||||
|
import useChatBoxStore from '../../Store/Slices/ChatBoxSlice';
|
||||||
|
|
||||||
|
import './RoleSelector.css';
|
||||||
|
|
||||||
|
const RoleSelector = () => {
|
||||||
|
const panelRef = useRef(null);
|
||||||
|
|
||||||
|
// 从 Zustand store 中获取状态和操作
|
||||||
|
const {
|
||||||
|
roleData,
|
||||||
|
selectedRole,
|
||||||
|
selectedChat,
|
||||||
|
hoveredRole,
|
||||||
|
clickedRole,
|
||||||
|
isLoading,
|
||||||
|
searchTerm,
|
||||||
|
editingRole,
|
||||||
|
editingChat,
|
||||||
|
showDeleteConfirm,
|
||||||
|
deleteType,
|
||||||
|
fetchRoleData,
|
||||||
|
setSelectedRole,
|
||||||
|
setSelectedChat,
|
||||||
|
setSelectedRoleAndChat,
|
||||||
|
setHoveredRole,
|
||||||
|
setClickedRole,
|
||||||
|
setSearchTerm,
|
||||||
|
setEditingRole,
|
||||||
|
setEditingChat,
|
||||||
|
setShowDeleteConfirm,
|
||||||
|
setDeleteType,
|
||||||
|
handleRenameRole,
|
||||||
|
handleRenameChat,
|
||||||
|
confirmDelete,
|
||||||
|
cancelDelete,
|
||||||
|
handleAddRole,
|
||||||
|
handleAddChat,
|
||||||
|
resetPanel
|
||||||
|
} = useRoleSelectorStore();
|
||||||
|
|
||||||
|
// 从 ChatBoxStore 获取状态更新方法
|
||||||
|
const chatBoxStore = useChatBoxStore();
|
||||||
|
const { setCurrentRole, setCurrentChat } = chatBoxStore;
|
||||||
|
const setChatBoxRoleAndChat = chatBoxStore.setChatBoxRoleAndChat;
|
||||||
|
|
||||||
|
|
||||||
|
// 组件挂载时获取数据
|
||||||
|
useEffect(() => {
|
||||||
|
fetchRoleData();
|
||||||
|
}, [fetchRoleData]);
|
||||||
|
|
||||||
|
// 点击外部关闭面板
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event) => {
|
||||||
|
if (panelRef.current && !panelRef.current.contains(event.target)) {
|
||||||
|
resetPanel();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
};
|
||||||
|
}, [resetPanel]);
|
||||||
|
|
||||||
|
// 处理角色选择
|
||||||
|
const handleRoleSelect = (role) => {
|
||||||
|
// 如果该角色有聊天记录,默认选择第一个
|
||||||
|
if (roleData[role] && roleData[role].length > 0) {
|
||||||
|
const firstChat = roleData[role][0];
|
||||||
|
// 使用新的原子操作方法同时更新角色和聊天
|
||||||
|
setSelectedRoleAndChat(role, firstChat);
|
||||||
|
// 同步更新 ChatBoxStore 中的状态
|
||||||
|
setChatBoxRoleAndChat(role, firstChat);
|
||||||
|
} else {
|
||||||
|
// 清空角色和聊天
|
||||||
|
setSelectedRoleAndChat(null, null);
|
||||||
|
// 同步更新 ChatBoxStore 中的状态
|
||||||
|
setChatBoxRoleAndChat(null, null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理聊天选择
|
||||||
|
const handleChatSelect = (chat) => {
|
||||||
|
// 获取当前展开的角色(聊天所属的角色)
|
||||||
|
const currentRole = hoveredRole || clickedRole;
|
||||||
|
|
||||||
|
// 使用新的原子操作方法同时更新角色和聊天
|
||||||
|
setSelectedRoleAndChat(currentRole, chat);
|
||||||
|
// 同步更新 ChatBoxStore 中的状态
|
||||||
|
setChatBoxRoleAndChat(currentRole, chat);
|
||||||
|
setHoveredRole(null);
|
||||||
|
setClickedRole(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理角色卡片点击
|
||||||
|
const handleRoleCardClick = (role) => {
|
||||||
|
if (clickedRole === role) {
|
||||||
|
setClickedRole(null);
|
||||||
|
// 取消选择角色时,更新 selectedRole 和 selectedChat
|
||||||
|
setSelectedRoleAndChat(null, null);
|
||||||
|
// 同步更新 ChatBoxStore 中的状态
|
||||||
|
setChatBoxRoleAndChat(null, null);
|
||||||
|
} else {
|
||||||
|
setClickedRole(role);
|
||||||
|
handleRoleSelect(role);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理搜索
|
||||||
|
const handleSearchChange = (e) => {
|
||||||
|
setSearchTerm(e.target.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理角色编辑
|
||||||
|
const handleEditRole = (e, role) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setEditingRole(role);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理角色重命名
|
||||||
|
const handleRenameRoleWrapper = (e, oldName) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const newName = e.target.value;
|
||||||
|
handleRenameRole(oldName, newName);
|
||||||
|
if (selectedRole === oldName && newName && newName !== oldName) {
|
||||||
|
// 更新 ChatBoxStore 中的当前角色
|
||||||
|
setCurrentRole(newName);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理聊天编辑
|
||||||
|
const handleEditChat = (e, chat) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setEditingChat(chat);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理聊天重命名
|
||||||
|
const handleRenameChatWrapper = (e, oldName) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const newName = e.target.value;
|
||||||
|
handleRenameChat(oldName, newName);
|
||||||
|
if (selectedChat === oldName && newName && newName !== oldName) {
|
||||||
|
// 更新 ChatBoxStore 中的当前聊天
|
||||||
|
setCurrentChat(selectedRole, newName);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理删除确认
|
||||||
|
const handleDeleteClick = (e, type, name) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setDeleteType(type);
|
||||||
|
setShowDeleteConfirm(name);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 确认删除
|
||||||
|
const confirmDeleteWrapper = () => {
|
||||||
|
confirmDelete();
|
||||||
|
if (deleteType === 'role' && selectedRole === showDeleteConfirm) {
|
||||||
|
// 清除 ChatBoxStore 中的当前角色和聊天
|
||||||
|
setChatBoxRoleAndChat(null, null);
|
||||||
|
} else if (deleteType === 'chat' && selectedChat === showDeleteConfirm) {
|
||||||
|
// 清除 ChatBoxStore 中的当前聊天
|
||||||
|
setChatBoxRoleAndChat(selectedRole, null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 过滤角色
|
||||||
|
const filteredRoles = Object.keys(roleData).filter(role =>
|
||||||
|
role.toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="role-selector" ref={panelRef}>
|
||||||
|
<div className="selected-role-display">
|
||||||
|
{selectedRole ? (
|
||||||
|
<div className="role-badge">
|
||||||
|
<span className="role-label">当前角色/聊天:</span>
|
||||||
|
<span className="role-name">{selectedRole}</span>
|
||||||
|
{selectedChat && <span className="chat-name">/ {selectedChat}</span>}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="role-badge empty">
|
||||||
|
<span>未选择角色</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="search-bar">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="搜索角色..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={handleSearchChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="role-list">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="loading">加载中...</div>
|
||||||
|
) : filteredRoles.length === 0 ? (
|
||||||
|
<div className="empty-state">
|
||||||
|
<p>没有找到匹配的角色</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredRoles.map(role => (
|
||||||
|
<div
|
||||||
|
key={role}
|
||||||
|
className={`role-item ${selectedRole === role ? 'active' : ''}`}
|
||||||
|
onClick={() => handleRoleCardClick(role)}
|
||||||
|
onMouseEnter={() => setHoveredRole(role)}
|
||||||
|
onMouseLeave={() => setHoveredRole(null)}
|
||||||
|
>
|
||||||
|
<div className="role-header">
|
||||||
|
{editingRole === role ? (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
defaultValue={role}
|
||||||
|
autoFocus
|
||||||
|
onBlur={(e) => handleRenameRoleWrapper(e, role)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
handleRenameRoleWrapper(e, role);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="role-name">{role}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="role-actions">
|
||||||
|
<button
|
||||||
|
className="icon-btn"
|
||||||
|
title="编辑"
|
||||||
|
onClick={(e) => handleEditRole(e, role)}
|
||||||
|
>
|
||||||
|
✏️
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="icon-btn delete"
|
||||||
|
title="删除"
|
||||||
|
onClick={(e) => handleDeleteClick(e, 'role', role)}
|
||||||
|
>
|
||||||
|
🗑️
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 聊天列表 */}
|
||||||
|
{(hoveredRole === role || clickedRole === role) && roleData[role] && roleData[role].length > 0 && (
|
||||||
|
<div className="chat-list">
|
||||||
|
{roleData[role].map(chat => (
|
||||||
|
<div
|
||||||
|
key={chat}
|
||||||
|
className={`chat-item ${selectedChat === chat ? 'active' : ''}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleChatSelect(chat);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="chat-content">
|
||||||
|
{editingChat === chat ? (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
defaultValue={chat}
|
||||||
|
autoFocus
|
||||||
|
onBlur={(e) => handleRenameChatWrapper(e, chat)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
handleRenameChatWrapper(e, chat);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="chat-name">{chat}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="chat-actions">
|
||||||
|
{editingChat !== chat && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className="icon-btn"
|
||||||
|
title="编辑"
|
||||||
|
onClick={(e) => handleEditChat(e, chat)}
|
||||||
|
>
|
||||||
|
✏️
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="icon-btn delete"
|
||||||
|
title="删除"
|
||||||
|
onClick={(e) => handleDeleteClick(e, 'chat', chat)}
|
||||||
|
>
|
||||||
|
🗑️
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 删除确认对话框 */}
|
||||||
|
{showDeleteConfirm && (
|
||||||
|
<div className="delete-confirm-modal">
|
||||||
|
<div className="modal-content">
|
||||||
|
<h3>确认删除</h3>
|
||||||
|
<p>确定要删除{deleteType === 'role' ? '角色' : '聊天'} "{showDeleteConfirm}" 吗?</p>
|
||||||
|
<div className="modal-actions">
|
||||||
|
<button className="modal-button cancel-button" onClick={cancelDelete}>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button className="modal-button danger" onClick={confirmDeleteWrapper}>
|
||||||
|
确认删除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RoleSelector;
|
||||||
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;
|
||||||
164
frontend-react/src/components/ToolBar/ToolBar.css
Normal file
164
frontend-react/src/components/ToolBar/ToolBar.css
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
/* ==================== 顶部工具栏区域 ==================== */
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 50px;
|
||||||
|
background-color: #fff;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 20px;
|
||||||
|
justify-content: space-between;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 工具栏主要部分容器 */
|
||||||
|
.toolbar-section {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 15px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 工具栏图标容器 */
|
||||||
|
.toolbar-icons {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 15px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 工具栏图标 */
|
||||||
|
.toolbar-icon {
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
font-size: 18px;
|
||||||
|
color: #555;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-icon:hover {
|
||||||
|
background-color: #e6f7ff;
|
||||||
|
color: #1890ff;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-icon.active {
|
||||||
|
background-color: #1890ff;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 图标标签文本 */
|
||||||
|
.icon-label {
|
||||||
|
font-size: 14px;
|
||||||
|
max-width: 150px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 弹出面板通用样式 ==================== */
|
||||||
|
|
||||||
|
.close-panel-button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #666;
|
||||||
|
padding: 5px 10px;
|
||||||
|
margin-left: auto;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-panel-button:hover {
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 50px;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.3);
|
||||||
|
z-index: 999;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding-top: 20px;
|
||||||
|
animation: fadeIn 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-content {
|
||||||
|
background-color: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
|
||||||
|
width: 90%;
|
||||||
|
max-width: 1200px;
|
||||||
|
max-height: calc(100vh - 80px);
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
animation: slideDown 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideDown {
|
||||||
|
from {
|
||||||
|
transform: translateY(-20px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-bottom: 1px solid #e0e0e0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
background-color: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-body {
|
||||||
|
padding: 20px;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 主内容区域 */
|
||||||
|
.main-container {
|
||||||
|
margin-top: 50px;
|
||||||
|
height: calc(100vh - 50px);
|
||||||
|
display: flex;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
223
frontend-react/src/components/ToolBar/ToolBar.jsx
Normal file
223
frontend-react/src/components/ToolBar/ToolBar.jsx
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
// 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(() => {
|
||||||
|
const handleClickOutside = (event) => {
|
||||||
|
if (panelRef.current && !panelRef.current.contains(event.target)) {
|
||||||
|
setActivePanel(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 监听selectedRole和selectedChat的变化
|
||||||
|
useEffect(() => {
|
||||||
|
console.log('当前选中的角色:', selectedRole);
|
||||||
|
console.log('当前选中的聊天:', selectedChat);
|
||||||
|
// 这里可以添加其他需要响应角色变化的逻辑
|
||||||
|
}, [selectedRole, selectedChat]);
|
||||||
|
|
||||||
|
// 处理面板切换
|
||||||
|
const handlePanelToggle = (panelName) => {
|
||||||
|
if (activePanel === panelName) {
|
||||||
|
setActivePanel(null);
|
||||||
|
} else {
|
||||||
|
setActivePanel(panelName);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 关闭面板
|
||||||
|
const handleClosePanel = () => {
|
||||||
|
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">
|
||||||
|
<div
|
||||||
|
className="toolbar-icon"
|
||||||
|
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
|
||||||
|
className="toolbar-icon"
|
||||||
|
title="设置"
|
||||||
|
onClick={() => handlePanelToggle('settings')}
|
||||||
|
>
|
||||||
|
⚙️
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="toolbar-icon"
|
||||||
|
title="拓展"
|
||||||
|
onClick={() => handlePanelToggle('extensions')}
|
||||||
|
>
|
||||||
|
➕
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 角色管理面板 */}
|
||||||
|
{activePanel === 'role' && (
|
||||||
|
<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">
|
||||||
|
<RoleSelector />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 当前角色面板(暂时留空) */}
|
||||||
|
{activePanel === 'currentRole' && (
|
||||||
|
<div className="panel-overlay" ref={panelRef}>
|
||||||
|
<div className="panel-content">
|
||||||
|
<div className="panel-header">
|
||||||
|
<h3>当前ai角色</h3>
|
||||||
|
<button className="close-panel-button" onClick={handleClosePanel} title="关闭">
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="panel-body">
|
||||||
|
<p>当前角色详情...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 全局世界书面板 */}
|
||||||
|
{activePanel === 'worldBook' && (
|
||||||
|
<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 === '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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Toolbar;
|
||||||
21
frontend-react/src/index.css
Normal file
21
frontend-react/src/index.css
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
/* 左侧栏 */
|
||||||
|
.sidebar-left {
|
||||||
|
width: 22.5%; /* 修改为30% */
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 中间聊天区域 */
|
||||||
|
.chat-area {
|
||||||
|
flex: 55%; /* 修改为0.4 */
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 右侧栏 */
|
||||||
|
.sidebar-right {
|
||||||
|
width: 22.5%; /* 修改为30% */
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
14
frontend-react/src/main.jsx
Normal file
14
frontend-react/src/main.jsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import App from './App.jsx';
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
// 获取 HTML 中的根元素
|
||||||
|
const rootElement = document.getElementById('root');
|
||||||
|
|
||||||
|
// 创建 React 根节点并渲染 App 组件
|
||||||
|
ReactDOM.createRoot(rootElement).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
14
frontend-react/vite.config.js
Normal file
14
frontend-react/vite.config.js
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://backend:8000',
|
||||||
|
changeOrigin: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -1,18 +1,22 @@
|
|||||||
# 使用 Python 3.11 基础镜像
|
# 使用 Node.js 18 Alpine 镜像作为基础
|
||||||
FROM python:3.11-slim
|
FROM node:18-alpine
|
||||||
|
|
||||||
# 设置工作目录
|
# 设置工作目录
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# 复制依赖文件并安装
|
# 设置 npm 镜像源(可选,国内推荐使用,加速依赖下载)
|
||||||
COPY requirements.txt .
|
RUN npm config set registry https://registry.npmmirror.com/
|
||||||
RUN pip install --no-cache-dir -i https://pypi.tuna.tsinghua.edu.cn/simple -r requirements.txt
|
|
||||||
|
|
||||||
# 复制所有代码
|
# 复制 package.json 和 package-lock.json
|
||||||
COPY . .
|
# 利用 Docker 缓存层,只有依赖变更时才重新安装
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
|
||||||
# 暴露端口
|
# 安装依赖
|
||||||
EXPOSE 8501
|
RUN npm install
|
||||||
|
|
||||||
# 启动命令(使用 8501 端口,与 docker-compose 映射保持一致)
|
# 暴露 Vite 默认端口 5173
|
||||||
CMD ["streamlit", "run", "app.py", "--server.port=8501", "--server.address=0.0.0.0"]
|
EXPOSE 5173
|
||||||
|
|
||||||
|
# 启动命令由 docker-compose.yml 中的 command 覆盖,
|
||||||
|
# 这里保留默认的 dev 命令作为 fallback
|
||||||
|
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
|
||||||
|
|||||||
BIN
frontend/__pycache__/app.cpython-39.pyc
Normal file
BIN
frontend/__pycache__/app.cpython-39.pyc
Normal file
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 497 KiB After Width: | Height: | Size: 0 B |
8
main.py
8
main.py
@@ -1,13 +1,11 @@
|
|||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
from backend.api.route import router
|
||||||
|
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
|
|
||||||
|
# 注册API路由
|
||||||
|
app.include_router(router, prefix="/api")
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
async def root():
|
async def root():
|
||||||
return {"message": "Hello World"}
|
return {"message": "Hello World"}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/hello/{name}")
|
|
||||||
async def say_hello(name: str):
|
|
||||||
return {"message": f"Hello {name}"}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user