17 Commits

Author SHA1 Message Date
dd17206e1f 世界书部分基本完成,剩余编辑条目部分,可以推进到下一步 2026-04-07 05:40:27 +08:00
4f9cf4b725 世界书部分基本完成 2026-04-07 01:56:13 +08:00
e8dedb5ec4 重构路由架构,修复导致的前端出错 2026-04-06 00:52:04 +08:00
7a62139683 重构路由架构 2026-04-05 12:04:18 +08:00
01ca2bd0f9 暂时完成预设部分、开始世界书部分 2026-04-04 15:42:43 +08:00
1fc0c43689 预设拖拽实现 2026-04-04 15:19:37 +08:00
1abfaeda9d 后端预设列表读取完成 2026-04-04 12:36:27 +08:00
0ae53c4b81 后端预设、世界书部分补充 2026-04-04 10:41:38 +08:00
f90ad8dc13 补充css修改 2026-04-03 13:44:35 +08:00
6375f9759c 完成前端初步重构、实现显示选中角色功能 2026-04-03 12:09:08 +08:00
33188a345e 完成后端chatmessage格式修改,可以在前端完成swipe切换显示 2026-04-01 19:15:10 +08:00
6fa1fd6e7f 修补输入框 2026-04-01 12:50:16 +08:00
80237463ef 完成聊天框功能开发 2026-03-30 20:28:50 +08:00
f9bc77d392 Merge branch 'fix/点击聊天不修改角色' 2026-03-29 20:27:26 +08:00
408e9ce569 chore: add .gitignore to ignore __pycache__ 2026-03-29 20:26:52 +08:00
60a2049bb7 merge feature/聊天框实现 2026-03-29 20:13:57 +08:00
4d4d7c30ce 修补选中聊天不更换角色bug 2026-03-29 20:04:16 +08:00
67 changed files with 9198 additions and 775 deletions

BIN
.gitignore vendored Normal file

Binary file not shown.

5
.idea/codeStyles/codeStyleConfig.xml generated Normal file
View File

@@ -0,0 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="PREFERRED_PROJECT_CODE_STYLE" value="Default" />
</state>
</component>

Binary file not shown.

View File

@@ -1,18 +1,16 @@
from fastapi import APIRouter from fastapi import APIRouter
from ..core.items import ChatRequest from .routes import presetsRoute, chatsRoute, worldbooksRoute
from ..tools.get_all_role_and_chat import get_all_role_and_chat
from ..tools.save_input_to_json import save_input_to_json
router = APIRouter() router = APIRouter()
# 1. 将输入内容持久化存储到本地jsonl方便前端读 # 注册子路由
@router.post("/generate_reply") router.include_router(presetsRoute.router)
async def save_chat_to_json(chat_request: ChatRequest): router.include_router(chatsRoute.router)
# 调用实际的保存函数 router.include_router(worldbooksRoute.router)
return await save_input_to_json(chat_request)
# 2. 从本地jsonl中读取历史对话
# 保留原有的其他路由
@router.get("/tool_bar/get_all_role_and_chat") @router.get("/tool_bar/get_all_role_and_chat")
def get_all_role_and_chat_endpoint(): def get_all_role_and_chat_endpoint():
# 正确调用函数并返回结果 from ..tools.get_all_role_and_chat import get_all_role_and_chat
return get_all_role_and_chat() return get_all_role_and_chat()

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

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

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

Binary file not shown.

Binary file not shown.

View File

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

View 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="组件内容文本")
# 0System1User2Assistant
role: int = Field(0, description="角色身份(0System1User2Assistant)")
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)

View 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)

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

View 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
}
# 测试用例2RAG触发
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)

View File

@@ -6,153 +6,438 @@ import json
class Message(BaseModel): class Message(BaseModel):
"""消息类代表JSONL文件中的一行消息内容""" """消息类代表JSONL文件中的一行消息内容"""
name: str = Field(..., description="者名称") name: str = Field(..., description="者名称")
is_user: bool = Field(..., description="是否为用户消息true=用户false=AI/角色)") is_user: bool = Field(..., description="是否为用户消息")
is_system: bool = Field(False, description="是否为系统消息(系统消息在文本导出时会被排除)") is_system: bool = Field(False, description="是否为系统消息")
send_date: str = Field(default_factory=lambda: str(int(datetime.now().timestamp() * 1000)), send_date: str = Field(
description="发送时间戳Unix毫秒数") default_factory=lambda: str(int(datetime.now().timestamp() * 1000)),
mes: str = Field(..., description="消息正文内容") description="消息发送时间戳"
extra: Dict[str, Any] = Field(default_factory=dict, description="额外信息包含推理内容、API、模型等") )
swipes: List[str] = Field(default_factory=list, description="备选回复列表") floor: int = Field(0, description="对话楼层数")
swipe_id: int = Field(0, description="当前选中的备选索引0=第一条)") swipes: List[str] = Field(
swipe_info: List[Dict[str, Any]] = Field(default_factory=list, description="每个备选回复的生成信息") default_factory=list,
title: str = Field("", description="消息标题,用于消息摘要或分支标记") description="历史版本列表。用户消息存编辑过的不同版本。AI消息存重roll生成的不同版本"
force_avatar: Optional[str] = Field(None, description="强制头像路径") )
variables: List[Any] = Field(default_factory=list, description="变量值数组") swipe_id: int = Field(
variables_initialized: List[bool] = Field(default_factory=list, description="变量初始化状态数组") 0,
is_ejs_processed: List[bool] = Field(default_factory=list, description="EJS模板处理状态数组") description="当前指针。指示当前显示的是 swipes 数组中的第几个(从 0 开始)"
gen_started: Optional[str] = Field(None, description="生成开始时间戳Unix毫秒数") )
gen_finished: Optional[str] = Field(None, description="生成结束时间戳Unix毫秒数") 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): class ChatMetadata(BaseModel):
"""聊天元数据模型代表JSONL文件的第一行内容""" """聊天元数据类,包含整个聊天的共享属性"""
integrity: str = Field("", description="完整性校验哈希值UUID格式") user_name: str = Field("User", description="用户名称")
chat_id_hash: str = Field("", description="聊天ID哈希值") character_name: str = Field("Assistant", description="角色名称")
note_prompt: str = Field("", description="笔记提示词")
note_interval: int = Field(0, description="笔记间隔(整数)") # 完整性校验相关
note_position: int = Field(0, description="笔记位置(整数)") integrity: str = Field("", description="完整性校验值")
note_depth: int = Field(0, description="笔记深度(整数)") chat_id_hash: str = Field("", description="聊天ID哈希值")
note_role: int = Field(0, description="笔记角色整数0=用户1=助手)")
extensions: Dict[str, Any] = Field(default_factory=dict, description="扩展信息如LittleWhiteBox等") # 笔记相关
timedWorldInfo: Dict[str, Any] = Field(default_factory=dict, description="定时世界信息") note_prompt: str = Field("", description="作者笔记提示词")
variables: Dict[str, Any] = Field(default_factory=dict, description="变量字典") note_interval: int = Field(0, description="笔记插入间隔数")
tainted: bool = Field(False, description="是否被污染") note_position: int = Field(0, description="笔记插入位置")
lastInContextMessageId: int = Field(-1, description="上下文中最后一条消息的ID") note_depth: int = Field(0, description="笔记插入深度")
# 0System1User2Assistant
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 ChatFile(BaseModel): class ChatHistory(BaseModel):
"""聊天文件类,包含元数据和消息列表""" """聊天文件类,包含完整的聊天记录"""
user_name: str = Field("User", description="用户名") chat_metadata: ChatMetadata = Field(..., description="聊天元数据,包含基本信息和配置")
character_name: str = Field("Assistant", description="角色名") messages: List[Message] = Field(default_factory=list, description="消息列表")
create_date: str = Field(default_factory=lambda: datetime.now().isoformat(), description="创建日期ISO 8601格式")
chat_metadata: ChatMetadata = Field(default_factory=ChatMetadata, description="聊天元数据")
messages: List[Message] = Field(default_factory=list, description="消息列表")
class Config: class Config:
arbitrary_types_allowed = True 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")
def load_chat_file_data(chat_name: str, role_name: str, base_path: Path = None) -> Dict[str, Any]: @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 = []
chat_name: 聊天名称 for role_dir in data_dir.iterdir():
role_name: 角色名称 if role_dir.is_dir():
base_path: 基础路径,默认为项目数据目录 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
dict: 包含元数据和消息列表的原始数据字典 async def get_chat(cls, role_name: str, chat_name: str) -> Dict[str, Any]:
""" """获取指定聊天的完整内容"""
# 设置默认基础路径 chat_history = cls.load_from_file(role_name, chat_name)
if base_path is None: return {
from backend.core.config import settings "metadata": chat_history.chat_metadata.dict(),
base_path = settings.DATA_PATH / "chat" "messages": chat_history.to_chatbox_format()
}
# 构建文件路径 @classmethod
file_path = base_path / role_name / f"{chat_name}.jsonl" 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():
if not file_path.exists(): raise FileExistsError(f"Chat already exists: {chat_path}")
raise FileNotFoundError(f"聊天文件不存在: {file_path}")
# 读取文件内容 # 创建聊天历史对象
result = { chat_history = cls(
"user_name": "User", chat_metadata=ChatMetadata(**(metadata or {})),
"character_name": role_name, messages=[]
"create_date": datetime.now().isoformat(), )
"chat_metadata": {},
"messages": []
}
with open(file_path, 'r', encoding='utf-8') as f: # 保存到文件
for line in f: chat_history.save_to_file(role_name, chat_name, base_path)
try: return {"message": "Chat created successfully"}
# 解析JSON行
line_data = json.loads(line.strip())
# 添加到消息列表 @classmethod
result["messages"].append(line_data) async def update_chat(cls, role_name: str, chat_name: str, update_data: Dict) -> Dict[str, str]:
except json.JSONDecodeError: """更新聊天元数据"""
continue chat_history = cls.load_from_file(role_name, chat_name)
return result # 更新元数据
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"}
def create_chat_file_from_data(data: Dict[str, Any]) -> ChatFile: @classmethod
""" async def delete_chat(cls, role_name: str, chat_name: str) -> Dict[str, str]:
从原始数据创建ChatFile对象 """删除指定聊天"""
base_path = cls.get_data_path()
chat_path = base_path / role_name / f"{chat_name}.jsonl"
参数: if not chat_path.exists():
data: 包含元数据和消息列表的原始数据字典 raise FileNotFoundError(f"Chat not found: {chat_path}")
返回: chat_path.unlink()
ChatFile: 创建的聊天文件对象 return {"message": "Chat deleted successfully"}
"""
# 提取元数据
metadata = data.get("chat_metadata", {})
chat_metadata = ChatMetadata(**metadata)
# 处理消息列表 @classmethod
messages = [] async def list_messages(cls, role_name: str, chat_name: str) -> Dict[str, List[Dict]]:
for msg_data in data.get("messages", []): """获取聊天的所有消息"""
# 转换为Message对象 chat_history = cls.load_from_file(role_name, chat_name)
message = Message( return {"messages": chat_history.to_chatbox_format()}
name=msg_data.get('name', ''),
is_user=msg_data.get('is_user', False),
send_date=msg_data.get('send_date', ''),
mes=msg_data.get('content', ''),
swipes=msg_data.get('swipes', []),
swipe_id=msg_data.get('swipes_id', 0)
)
messages.append(message)
# 创建并返回ChatFile对象 @classmethod
return ChatFile( async def get_message(cls, role_name: str, chat_name: str, floor: int) -> Dict[str, Any]:
user_name=data.get("user_name", "User"), """获取指定楼层的消息"""
character_name=data.get("character_name", "Assistant"), chat_history = cls.load_from_file(role_name, chat_name)
create_date=data.get("create_date", datetime.now().isoformat()), message = next((msg for msg in chat_history.messages if msg.floor == floor), None)
chat_metadata=chat_metadata,
messages=messages
)
if not message:
raise FileNotFoundError(f"Message not found: floor {floor}")
def load_chat_file(chat_name: str, role_name: str, base_path: Path = None) -> ChatFile: return message.dict()
"""
从文件系统加载聊天数据并创建ChatFile对象
参数: @classmethod
chat_name: 聊天名称 async def add_message(cls, role_name: str, chat_name: str, message_data: Dict) -> Dict[str, Any]:
role_name: 角色名称 """向聊天添加新消息"""
base_path: 基础路径,默认为项目数据目录 chat_history = cls.load_from_file(role_name, chat_name)
返回: # 创建消息对象
ChatFile: 加载的聊天文件对象 message = Message(**message_data)
"""
# 加载原始数据
data = load_chat_file_data(chat_name, role_name, base_path)
# 创建ChatFile对象 # 检查楼层是否已存在
return create_chat_file_from_data(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')

View File

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

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

View File

@@ -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,

View File

@@ -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}

View 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}

View 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}

View 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}

View 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}

View File

@@ -5,7 +5,7 @@ services:
build: ./backend build: ./backend
command: uvicorn backend.main: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:
- ./backend:/app/backend - ./backend:/app/backend
- ./data:/app/data - ./data:/app/data
@@ -19,7 +19,7 @@ services:
context: ./frontend-react context: ./frontend-react
dockerfile: Dockerfile dockerfile: Dockerfile
ports: ports:
- "3000:5173" - "23338:5173"
volumes: volumes:
- ./frontend-react:/app - ./frontend-react:/app
- /app/node_modules - /app/node_modules

View File

@@ -1,6 +1,6 @@
# 使用 Node.js 18 Alpine 镜像作为基础 # 使用 Node.js 18 Alpine 镜像作为基础
# 必须明确指定 node 版本,否则可能默认为空或 python 镜像 # 必须明确指定 node 版本,否则可能默认为空或 python 镜像
FROM node:18-alpine FROM node:20-alpine
# 设置工作目录 # 设置工作目录
WORKDIR /app WORKDIR /app

View File

@@ -1,56 +1,28 @@
import React, { useState } from 'react'; // frontend-react/src/App.jsx
import React from 'react';
import Toolbar from './components/ToolBar/ToolBar'; import Toolbar from './components/ToolBar/ToolBar';
import ChatBox from './components/ChatBox/ChatBox'; import ChatBox from './components/ChatBox/ChatBox';
import DicePanel from './components/DicePanel/DicePanel'; import SideBarLeft from './components/SideBarLeft/SideBarLeft';
import ImageDisplay from './components/ImageDisplay/ImageDisplay'; import SideBarRight from './components/SideBarRight/SideBarRight';
import PresetPanel from './components/PresetPanel/PresetPanel';
import './index.css'; import './index.css';
function App() { function App() {
const [selectedRole, setSelectedRole] = useState(null);
const [selectedChat, setSelectedChat] = useState(null);
const handleRoleChange = (role) => {
setSelectedRole(role);
console.log('角色已更改:', role);
};
const handleChatChange = (role, chat) => {
setSelectedChat(chat);
console.log('聊天已更改:', role, chat);
};
return ( return (
<div className="app"> <div className="app">
<Toolbar <Toolbar />
onRoleChange={handleRoleChange}
onChatChange={handleChatChange}
/>
{/* 主内容容器 */} {/* 主内容容器 */}
<div className="main-container"> <div className="main-container">
{/* 左侧栏 - 预设面板 */} {/* 左侧栏 - 预设面板 */}
<div className="sidebar-left"> <SideBarLeft />
<PresetPanel />
</div>
{/* 中间栏:聊天框 */} {/* 中间栏:聊天框 */}
<div className="chat-area"> <div className="chat-area">
<ChatBox <ChatBox />
selectedRole={selectedRole}
selectedChat={selectedChat}
/>
</div> </div>
{/* 右侧栏 */} {/* 右侧栏 */}
<div className="sidebar-right"> <SideBarRight />
<div className="right-top">
<ImageDisplay /> {/* 图片展示放在顶部 */}
</div>
<div className="right-bottom">
<DicePanel /> {/* 骰子面板放在底部 */}
</div>
</div>
</div> </div>
</div> </div>
); );

View 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;

View 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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -3,7 +3,7 @@ import { create } from 'zustand';
// 异步获取角色数据 // 异步获取角色数据
const fetchRoleData = async () => { const fetchRoleData = async () => {
try { try {
const response = await fetch('/api/tool_bar/get_all_role_and_chat', { const response = await fetch('/api/chats', {
method: 'GET', method: 'GET',
headers: { headers: {
'Cache-Control': 'no-cache', 'Cache-Control': 'no-cache',
@@ -12,7 +12,19 @@ const fetchRoleData = async () => {
} }
}); });
const data = await response.json(); const data = await response.json();
return data;
// 转换数据格式以适应前端需求
const roleData = {};
if (data.chats && Array.isArray(data.chats)) {
data.chats.forEach(chat => {
if (!roleData[chat.role_name]) {
roleData[chat.role_name] = [];
}
roleData[chat.role_name].push(chat.chat_name);
});
}
return roleData;
} catch (error) { } catch (error) {
console.error('获取角色数据失败:', error); console.error('获取角色数据失败:', error);
throw error; throw error;
@@ -63,52 +75,95 @@ const useRoleSelectorStore = create((set, get) => ({
setDeleteType: (type) => set({ deleteType: type }), setDeleteType: (type) => set({ deleteType: type }),
handleRenameRole: (oldName, newName) => { // 同时更新角色和聊天
setSelectedRoleAndChat: (role, chat) => set({ selectedRole: role, selectedChat: chat }),
// 处理角色重命名
handleRenameRole: async (oldName, newName) => {
const { roleData, selectedRole } = get(); const { roleData, selectedRole } = get();
if (newName && newName !== oldName) { if (newName && newName !== oldName) {
const newRoleData = { ...roleData }; try {
const chats = newRoleData[oldName]; // 获取该角色下的所有聊天
delete newRoleData[oldName]; const chats = roleData[oldName] || [];
newRoleData[newName] = chats;
if (selectedRole === oldName) { // 为每个聊天更新元数据中的角色名称
set({ for (const chatName of chats) {
roleData: newRoleData, await fetch(`/api/chats/${encodeURIComponent(oldName)}/${encodeURIComponent(chatName)}`, {
selectedRole: newName, method: 'PUT',
editingRole: null headers: {
}); 'Content-Type': 'application/json',
} else { },
set({ body: JSON.stringify({
roleData: newRoleData, metadata: { character_name: newName }
editingRole: null })
}); });
}
// 更新本地状态
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 { } else {
set({ editingRole: null }); set({ editingRole: null });
} }
}, },
handleRenameChat: (oldName, newName) => { // 处理聊天重命名
handleRenameChat: async (oldName, newName) => {
const { roleData, selectedRole, selectedChat } = get(); const { roleData, selectedRole, selectedChat } = get();
if (newName && newName !== oldName) { if (newName && newName !== oldName) {
const newRoleData = { ...roleData }; try {
const chatIndex = newRoleData[selectedRole].indexOf(oldName); // 更新后端聊天名称
if (chatIndex !== -1) { await fetch(`/api/chats/${encodeURIComponent(selectedRole)}/${encodeURIComponent(oldName)}`, {
newRoleData[selectedRole][chatIndex] = newName; method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
metadata: { chat_name: newName }
})
});
if (selectedChat === oldName) { // 更新本地状态
set({ const newRoleData = { ...roleData };
roleData: newRoleData, const chatIndex = newRoleData[selectedRole].indexOf(oldName);
selectedChat: newName, if (chatIndex !== -1) {
editingChat: null newRoleData[selectedRole][chatIndex] = newName;
});
if (selectedChat === oldName) {
set({
roleData: newRoleData,
selectedChat: newName,
editingChat: null
});
} else {
set({
roleData: newRoleData,
editingChat: null
});
}
} else { } else {
set({ set({ editingChat: null });
roleData: newRoleData,
editingChat: null
});
} }
} else { } catch (error) {
console.error('重命名聊天失败:', error);
set({ editingChat: null }); set({ editingChat: null });
} }
} else { } else {
@@ -116,34 +171,27 @@ const useRoleSelectorStore = create((set, get) => ({
} }
}, },
confirmDelete: () => { // 确认删除
confirmDelete: async () => {
const { roleData, selectedRole, selectedChat, showDeleteConfirm, deleteType } = get(); const { roleData, selectedRole, selectedChat, showDeleteConfirm, deleteType } = get();
const newRoleData = { ...roleData };
if (deleteType === 'role') { try {
delete newRoleData[showDeleteConfirm]; if (deleteType === 'role') {
if (selectedRole === showDeleteConfirm) { // 删除角色下的所有聊天
set({ const chats = roleData[showDeleteConfirm] || [];
roleData: newRoleData, for (const chatName of chats) {
selectedRole: null, await fetch(`/api/chats/${encodeURIComponent(showDeleteConfirm)}/${encodeURIComponent(chatName)}`, {
selectedChat: null, method: 'DELETE'
showDeleteConfirm: null, });
deleteType: null }
});
} else { const newRoleData = { ...roleData };
set({ delete newRoleData[showDeleteConfirm];
roleData: newRoleData,
showDeleteConfirm: null, if (selectedRole === showDeleteConfirm) {
deleteType: null
});
}
} else if (deleteType === 'chat') {
const chatIndex = newRoleData[selectedRole].indexOf(showDeleteConfirm);
if (chatIndex !== -1) {
newRoleData[selectedRole].splice(chatIndex, 1);
if (selectedChat === showDeleteConfirm) {
set({ set({
roleData: newRoleData, roleData: newRoleData,
selectedRole: null,
selectedChat: null, selectedChat: null,
showDeleteConfirm: null, showDeleteConfirm: null,
deleteType: null deleteType: null
@@ -155,41 +203,116 @@ const useRoleSelectorStore = create((set, get) => ({
deleteType: null deleteType: null
}); });
} }
} else { } else if (deleteType === 'chat') {
set({ // 删除单个聊天
showDeleteConfirm: null, await fetch(`/api/chats/${encodeURIComponent(selectedRole)}/${encodeURIComponent(showDeleteConfirm)}`, {
deleteType: null 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 }), cancelDelete: () => set({ showDeleteConfirm: null, deleteType: null }),
handleAddRole: () => { // 添加新角色
handleAddRole: async () => {
const { roleData } = get(); const { roleData } = get();
const newRole = '新角色'; const newRole = '新角色';
const newRoleData = { ...roleData };
newRoleData[newRole] = []; try {
set({ // 创建新角色(通过创建一个默认聊天)
roleData: newRoleData, await fetch(`/api/chats/${encodeURIComponent(newRole)}`, {
editingRole: 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: () => { // 添加新聊天
handleAddChat: async () => {
const { roleData, selectedRole } = get(); const { roleData, selectedRole } = get();
if (!selectedRole) return; if (!selectedRole) return;
const newChat = '新聊天'; const newChat = '新聊天';
const newRoleData = { ...roleData };
newRoleData[selectedRole].push(newChat); try {
set({ await fetch(`/api/chats/${encodeURIComponent(selectedRole)}`, {
roleData: newRoleData, method: 'POST',
editingChat: newChat 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({ resetPanel: () => set({
hoveredRole: null, hoveredRole: null,
clickedRole: null, clickedRole: null,
@@ -199,4 +322,4 @@ const useRoleSelectorStore = create((set, get) => ({
}) })
})); }));
export default useRoleSelectorStore; export default useRoleSelectorStore;

View File

@@ -1,2 +1,5 @@
// frontend-react/src/store/index.js // frontend-react/src/store/index.js
export { default as useRoleSelectorStore } from './roleSelectorStore'; export { default as useRoleSelectorStore } from './Slices/RoleSelectorSlice';
export { default as useSideBarLeftStore } from './Slices/LeftTabsSlices/SideBarLeftSlice';
export { default as useSideBarRightStore } from './Slices/RightTabsSlices/SideBarRightSlice';
export { default as useChatBoxStore } from './Slices/ChatBoxSlice';

View File

@@ -14,7 +14,6 @@
.chat-messages { .chat-messages {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
max-height: calc(95vh - 62px); /* 减去输入区域的高度 */
overflow-y: auto; overflow-y: auto;
padding: 20px; padding: 20px;
padding-top: 60px; padding-top: 60px;
@@ -101,6 +100,21 @@
margin-bottom: 10px; 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 { .message.user {
justify-content: flex-end; /* 用户消息靠右 */ justify-content: flex-end; /* 用户消息靠右 */
@@ -353,7 +367,7 @@
/* ==================== 输入区域 ==================== */ /* ==================== 输入区域 ==================== */
.chat-input-wrapper { .chat-input-wrapper {
background-color: #fff; background-color: #f9f9f9;
border-top: 1px solid #ddd; border-top: 1px solid #ddd;
padding: 10px 20px; padding: 10px 20px;
height: 62px; /* 明确设置高度 */ height: 62px; /* 明确设置高度 */
@@ -403,3 +417,167 @@
.send-button:hover { .send-button:hover {
background-color: #40a9ff; 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;
}

View File

@@ -1,167 +1,254 @@
import React, { useState, useRef } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import useChatBoxStore from '../../Store/Slices/ChatBoxSlice';
import './ChatBox.css'; import './ChatBox.css';
const ChatBox = ({ selectedRole, selectedChat }) => { const ChatBox = () => {
const [isHtmlRender, setIsHtmlRender] = useState(false);
const [isImageGen, setIsImageGen] = useState(false);
const [isDynamicTable, setIsDynamicTable] = useState(false);
const [editingId, setEditingId] = useState(null); const [editingId, setEditingId] = useState(null);
const [editContent, setEditContent] = useState(''); const [editContent, setEditContent] = useState('');
const messagesEndRef = useRef(null);
const [inputValue, setInputValue] = useState('');
const textareaRef = useRef(null); // 新增管理每条消息的当前显示的swipe版本
const [currentSwipeId, setCurrentSwipeId] = useState({});
// 自动调整 Textarea 高度 // 从 ChatBoxStore 获取状态和方法
const adjustHeight = () => { const {
const textarea = textareaRef.current; messages,
const wrapper = textarea?.closest('.chat-input-wrapper'); isLoading,
error,
userName,
characterName,
updateMessage,
isGenerating,
sendMessage,
stopGeneration
} = useChatBoxStore();
if (textarea && wrapper) { const [inputHeight, setInputHeight] = useState(42);
textarea.style.height = '42px';
wrapper.style.height = 'auto';
const newHeight = textarea.scrollHeight;
if (newHeight > 42) { // 添加输入框高度自适应处理
textarea.style.height = newHeight + 'px'; const handleInputHeight = (e) => {
} else { const textarea = e.target;
textarea.style.height = '42px'; const newHeight = Math.min(Math.max(textarea.scrollHeight, 42), 300);
} setInputHeight(newHeight);
};
// 处理发送或终止
const handleSendOrStop = () => {
if (isGenerating) {
stopGeneration();
} else {
sendMessage(inputValue);
setInputValue('');
setInputHeight(42);
} }
}; };
const handleInput = () => { // 处理键盘事件
adjustHeight(); const handleKeyDown = (e) => {
}; if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
// 开始编辑消息 handleSendOrStop();
const startEdit = (id, content) => {
setEditingId(id);
setEditContent(content);
};
// 保存编辑的消息
const saveEdit = (id) => {
// 这里可以添加向后端发送请求的代码
console.log(`保存消息 ${id} 的编辑内容: ${editContent}`);
// 更新前端显示
const messageIndex = messages.findIndex(msg => msg.id === id);
if (messageIndex !== -1) {
messages[messageIndex].content = editContent;
} }
};
// 退出编辑模式 // 自动滚动到底部
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); setEditingId(null);
setEditContent(''); setEditContent('');
}; };
// 取消编辑 // 取消编辑
const cancelEdit = () => { const handleCancelEdit = () => {
setEditingId(null); setEditingId(null);
setEditContent(''); setEditContent('');
}; };
// 生成示例数据 // 新增处理swipe切换
const generateMessages = () => { const handleSwipeChange = (messageId, direction) => {
const messages = []; const message = messages.find(m => m.floor === messageId);
for (let i = 1; i <= 150; i++) { if (message && message.swipes && message.swipes.length > 0) {
const isUser = i % 2 !== 0; const currentIndex = currentSwipeId[messageId] !== undefined
messages.push({ ? currentSwipeId[messageId]
id: i, : message.swipe_id;
role: isUser ? 'user' : 'ai', const newIndex = currentIndex + direction;
name: isUser ? '我' : 'AI助手',
content: isUser if (newIndex >= 0 && newIndex < message.swipes.length) {
? `这是第 ${i} 条用户消息。这是一段比较长的文本,用来测试气泡的换行效果以及滚动条的表现。` setCurrentSwipeId(prev => ({
: `这是第 ${i} 条 AI 回复。<b>包含 HTML 标签</b>的内容。如果渲染开关开启,这里应该显示粗体字。如果不开启,应该显示原始标签。` ...prev,
}); [messageId]: newIndex
}));
}
} }
return messages;
}; };
const messages = generateMessages(); // 渲染单条消息
const renderMessage = (message) => {
const isUser = message.is_user;
const isEditing = editingId === message.floor;
return ( // 判断是否为最新消息
<div className="chat-box"> const isLatestMessage = messages.length > 0 && message.floor === messages[messages.length - 1].floor;
{/* 上方:消息列表区域 */}
<div className="chat-messages">
{/* 消息列表 */}
{messages.map((msg) => (
<div key={msg.id} className={`message ${msg.role}`}>
<div className="message-container">
{/* 消息名称和工具栏在同一行 */}
<div className="message-header">
<div className="message-name">{msg.name}</div>
{/* 消息工具栏 */} // 根据消息类型设置显示名称
<div className="message-toolbar"> const displayName = isUser ? userName : characterName;
<span className="message-id">ID: {msg.id}</span>
<div className="toolbar-buttons">
<button
className="toolbar-button edit-button"
onClick={() => startEdit(msg.id, msg.content)}
>
编辑
</button>
<button className="toolbar-button expand-button">
</button>
</div>
</div>
</div>
{/* 消息内容 */} // 确定当前显示的消息内容
<div className="message-content"> let currentMes = message.mes;
<div className="bubble"> let hasSwipes = message.swipes && message.swipes.length > 0;
{editingId === msg.id ? ( let currentSwipeIndex = message.swipe_id;
<div className="edit-container">
<textarea if (hasSwipes) {
value={editContent} // 如果有swipes数组
onChange={(e) => setEditContent(e.target.value)} if (currentSwipeId[message.floor] !== undefined) {
className="edit-textarea" // 如果用户已经切换过版本,使用用户选择的版本
/> currentSwipeIndex = currentSwipeId[message.floor];
<div className="edit-buttons"> } else {
<button // 否则使用默认的swipe_id
className="save-button" currentSwipeIndex = message.swipe_id;
onClick={() => saveEdit(msg.id)} }
>
保存 if (currentSwipeIndex >= 0 && currentSwipeIndex < message.swipes.length) {
</button> currentMes = message.swipes[currentSwipeIndex];
<button }
className="cancel-button" }
onClick={cancelEdit}
> return (
取消 <div key={message.floor} className={`message ${isUser ? 'user' : 'ai'}`}>
</button> <div className="message-container">
</div> <div className="message-header">
</div> <span className="message-name">{displayName}</span>
) : ( <span className="message-id">#{message.floor}</span>
isHtmlRender ? ( <div className="message-toolbar">
<div dangerouslySetInnerHTML={{ __html: msg.content }} /> <div className="toolbar-buttons">
) : ( <button
<div>{msg.content}</div> className="toolbar-button"
) onClick={() => handleEdit(message)}
)} title="编辑"
</div> >
</button>
<button
className="toolbar-button"
title="更多"
>
</button>
</div> </div>
</div> </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> </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"> <div className="chat-input-wrapper">
<button className="options-button" title="展开选项">
</button>
<div className="chat-input-area"> <div className="chat-input-area">
<textarea <textarea
ref={textareaRef} value={inputValue}
onChange={(e) => {
setInputValue(e.target.value);
handleInputHeight(e);
}}
onKeyDown={handleKeyDown}
style={{ height: `${inputHeight}px` }}
placeholder="输入消息..." placeholder="输入消息..."
onInput={handleInput}
rows="1"
style={{ height: '42px' }}
/> />
</div> </div>
<button className="send-button">发送</button> <button
className={`send-button ${isGenerating ? 'stopping' : ''}`}
onClick={handleSendOrStop}
disabled={!inputValue.trim() && !isGenerating}
>
{isGenerating ? '■' : '➤'}
</button>
</div> </div>
</div> </div>
); );
}; };
export default ChatBox; export default ChatBox;

View File

@@ -1,11 +0,0 @@
import React from 'react';
const DicePanel = () => {
return (
<div className="dice-panel">
<div>骰子区</div>
</div>
);
};
export default DicePanel;

View File

@@ -1,11 +0,0 @@
import React from 'react';
const ImageDisplay = () => {
return (
<div className="image-display">
<div>图片展示区</div>
</div>
);
};
export default ImageDisplay;

View File

@@ -1,146 +1,146 @@
// npm install react react-dom react-markdown react-syntax-highlighter remark-math rehype-katex react-copy-to-clipboard mermaid katex // // 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 React, { useState, useCallback, useEffect } from "react";
import ReactMarkdown from "react-markdown"; // import ReactMarkdown from "react-markdown";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; // import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { materialLight } from "react-syntax-highlighter/dist/cjs/styles/prism"; // import { materialLight } from "react-syntax-highlighter/dist/cjs/styles/prism";
import remarkMath from "remark-math"; // import remarkMath from "remark-math";
import rehypeKatex from "rehype-katex"; // import rehypeKatex from "rehype-katex";
import { CopyToClipboard } from "react-copy-to-clipboard"; // import { CopyToClipboard } from "react-copy-to-clipboard";
import mermaid from "mermaid"; // import mermaid from "mermaid";
import "katex/dist/katex.min.css"; // import "katex/dist/katex.min.css";
//
// Mermaid 初始化配置 // // Mermaid 初始化配置
// Mermaid是一个画图的语言 // // Mermaid是一个画图的语言
mermaid.initialize({ // mermaid.initialize({
startOnLoad: false, // startOnLoad: false,
theme: "default", // theme: "default",
securityLevel: "loose", // securityLevel: "loose",
}); // });
//
const DownSvg = () => ( // const DownSvg = () => (
<svg width="12" height="12" viewBox="0 0 24 24"> // <svg width="12" height="12" viewBox="0 0 24 24">
<path d="M7 10l5 5 5-5z" fill="currentColor" /> // <path d="M7 10l5 5 5-5z" fill="currentColor" />
</svg> // </svg>
); // );
//
const CopySvg = () => ( // const CopySvg = () => (
<svg width="14" height="14" viewBox="0 0 24 24"> // <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" /> // <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> // </svg>
); // );
//
//
const MermaidChart= ({ code }) => { // const MermaidChart= ({ code }) => {
const [svg, setSvg] = useState(""); // const [svg, setSvg] = useState("");
const [id] = useState(() => `mermaid-${Math.random().toString(36).substr(2, 9)}`); // const [id] = useState(() => `mermaid-${Math.random().toString(36).substr(2, 9)}`);
//
useEffect(() => { // useEffect(() => {
try { // try {
mermaid.parse(code); // mermaid.parse(code);
mermaid.render(id, code).then(({ svg }) => setSvg(svg)); // mermaid.render(id, code).then(({ svg }) => setSvg(svg));
} catch (err) { // } catch (err) {
setSvg(`<pre>Error rendering mermaid: ${err}</pre>`); // setSvg(`<pre>Error rendering mermaid: ${err}</pre>`);
} // }
}, [code, id]); // }, [code, id]);
//
return <div dangerouslySetInnerHTML={{ __html: svg }} />; // return <div dangerouslySetInnerHTML={{ __html: svg }} />;
}; // };
//
const MarkdownToHTML= ({ markdownRAW, className }) => { // const MarkdownToHTML= ({ markdownRAW, className }) => {
const [isMermaidLoaded] = useState(true); // const [isMermaidLoaded] = useState(true);
// 渲染代码块的方法 // // 渲染代码块的方法
const CodeBlock = useCallback( // const CodeBlock = useCallback(
({ node, inline, className, children, ...props } ) => { // ({ node, inline, className, children, ...props } ) => {
const [isShowCode, setIsShowCode] = useState(true); // 添加展开与收起代码块状态 // const [isShowCode, setIsShowCode] = useState(true); // 添加展开与收起代码块状态
const [isShowCopy, setIsShowCopy] = useState(false);// 添加点击复制的状态 // const [isShowCopy, setIsShowCopy] = useState(false);// 添加点击复制的状态
const match = /language-(\w+)/.exec(className || ""); // 这是用来匹配代码块对应语言的方法 // const match = /language-(\w+)/.exec(className || ""); // 这是用来匹配代码块对应语言的方法
const codeContent = String(children).replace(/\n$/, ""); // const codeContent = String(children).replace(/\n$/, "");
// 处理复制成功提示 // // 处理复制成功提示
const handleCopy = () => { // const handleCopy = () => {
setIsShowCopy(true); // setIsShowCopy(true);
setTimeout(() => setIsShowCopy(false), 1500); // setTimeout(() => setIsShowCopy(false), 1500);
}; // };
//处理单行代码 // //处理单行代码
if (inline) { // if (inline) {
return <code className={className} {...props}>{children}</code>; // return <code className={className} {...props}>{children}</code>;
} // }
//
// 处理 Mermaid 图表代码 // // 处理 Mermaid 图表代码
if (match?.[1] === "mermaid" && isMermaidLoaded) { // if (match?.[1] === "mermaid" && isMermaidLoaded) {
return ( // return (
<div style={{ position: "relative", margin: "20px 0" }}> // <div style={{ position: "relative", margin: "20px 0" }}>
<div className="code-header"> // <div className="code-header">
<div // <div
style={{ cursor: "pointer", marginRight: "10px", transformOrigin: "8px" }} // style={{ cursor: "pointer", marginRight: "10px", transformOrigin: "8px" }}
className={isShowCode ? "code-rotate-down" : "code-rotate-right"} // className={isShowCode ? "code-rotate-down" : "code-rotate-right"}
onClick={() => setIsShowCode(!isShowCode)} // onClick={() => setIsShowCode(!isShowCode)}
> // >
<DownSvg /> // <DownSvg />
</div> // </div>
<div>{match[1]}</div> // <div>{match[1]}</div>
<CopyToClipboard text={codeContent} onCopy={handleCopy}> // <CopyToClipboard text={codeContent} onCopy={handleCopy}>
<div className="preview-code-copy" style={{ cursor: "pointer" }}> // <div className="preview-code-copy" style={{ cursor: "pointer" }}>
{isShowCopy && <span className="copy-success"> 复制成功</span>} // {isShowCopy && <span className="copy-success">✓ 复制成功</span>}
<CopySvg /> // <CopySvg />
</div> // </div>
</CopyToClipboard> // </CopyToClipboard>
</div> // </div>
{isShowCode && <MermaidChart code={codeContent} />} // {isShowCode && <MermaidChart code={codeContent} />}
</div> // </div>
); // );
} // }
//
return ( // return (
<div style={{ position: "relative", padding:"0"}}> // <div style={{ position: "relative", padding:"0"}}>
<div className="code-header"> // <div className="code-header">
<div // <div
style={{ cursor: "pointer", marginRight: "10px", transformOrigin: "8px" }} // style={{ cursor: "pointer", marginRight: "10px", transformOrigin: "8px" }}
className={isShowCode ? "code-rotate-down" : "code-rotate-right"} // className={isShowCode ? "code-rotate-down" : "code-rotate-right"}
onClick={() => setIsShowCode(!isShowCode)} // onClick={() => setIsShowCode(!isShowCode)}
> // >
<DownSvg style={{ // <DownSvg style={{
transform: isShowCode ? "rotate(0deg)" : "rotate(-90deg)", // transform: isShowCode ? "rotate(0deg)" : "rotate(-90deg)",
transition: "transform 0.2s" // transition: "transform 0.2s"
}} /> // }} />
</div> // </div>
<div>{match?.[1] || "code"}</div> // <div>{match?.[1] || "code"}</div>
<CopyToClipboard text={codeContent} onCopy={handleCopy}> // <CopyToClipboard text={codeContent} onCopy={handleCopy}>
<div className="preview-code-copy" style={{ cursor: "pointer" }}> // <div className="preview-code-copy" style={{ cursor: "pointer" }}>
{isShowCopy && <span className="copy-success"> 复制成功</span>} // {isShowCopy && <span className="copy-success">✓ 复制成功</span>}
<CopySvg /> // <CopySvg />
</div> // </div>
</CopyToClipboard> // </CopyToClipboard>
</div> // </div>
{isShowCode && ( // {isShowCode && (
<SyntaxHighlighter // <SyntaxHighlighter
style={materialLight} // style={materialLight}
language={match?.[1] || "text"} // language={match?.[1] || "text"}
PreTag="div" // PreTag="div"
showLineNumbers // showLineNumbers
{...props} // {...props}
> // >
{codeContent} // {codeContent}
</SyntaxHighlighter> // </SyntaxHighlighter>
)} // )}
</div> // </div>
); // );
}, // },
[isMermaidLoaded] // [isMermaidLoaded]
); // );
//
return ( // return (
<div // <div
className={className} > // className={className} >
<ReactMarkdown // <ReactMarkdown
remarkPlugins={[remarkMath]} // remarkPlugins={[remarkMath]}
rehypePlugins={[rehypeKatex]} // rehypePlugins={[rehypeKatex]}
components={{ code: CodeBlock }} // components={{ code: CodeBlock }}
> // >
{markdownRAW} // {markdownRAW}
</ReactMarkdown> // </ReactMarkdown>
//
</div> // </div>
); // );
}; // };
//
export default MarkdownToHTML; // export default MarkdownToHTML;

View File

@@ -1,57 +0,0 @@
/* ==================== 顶部工具栏区域 ==================== */
.toolbar {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 50px;
background-color: #fff;
border-bottom: 1px solid #ddd;
padding: 0;
display: flex;
align-items: center;
justify-content: flex-end;
z-index: 100;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
transition: height 0.3s ease;
}
.toolbar.expanded {
height: auto;
max-height: 300px;
overflow-y: auto;
padding-bottom: 10px;
}
.toolbar-content {
display: none;
width: 100%;
padding: 10px 20px;
}
.toolbar.expanded .toolbar-content {
display: block;
}
.toolbar-toggle-btn {
position: absolute;
right: 0;
top: 0;
height: 50px;
width: 50px;
background: none;
border: none;
border-left: 1px solid #ddd;
cursor: pointer;
font-size: 0.8rem;
color: #666;
display: flex;
align-items: center;
justify-content: center;
z-index: 101;
}
.toolbar-toggle-btn:hover {
background-color: #f5f5f5;
}

View File

@@ -1,30 +0,0 @@
import React from 'react';
import './PresetPanel.css';
const PresetPanel = () => {
return (
<div className="preset-panel">
{/* 顶部:预设选择与输入 */}
<div className="preset-header">
{/* 下拉框 */}
<select className="preset-select">
<option>选择预设...</option>
</select>
{/* 输入框组(待定) */}
<div className="preset-inputs">
{/* inputs here */}
</div>
</div>
{/* 下方:大槽位区域 */}
<div className="preset-slots">
{/* 槽位列表 */}
<div className="slot-item">槽位 1</div>
<div className="slot-item">槽位 2</div>
{/* ... */}
</div>
</div>
);
};
export default PresetPanel;

View File

@@ -145,7 +145,7 @@
flex-direction: column; flex-direction: column;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
overflow: hidden; overflow: hidden;
min-height: 140px; height: 200px; /* 设置固定高度 */
position: relative; position: relative;
/* 添加微妙的边框效果 */ /* 添加微妙的边框效果 */
border: 1px solid rgba(0, 0, 0, 0.05); border: 1px solid rgba(0, 0, 0, 0.05);
@@ -167,7 +167,8 @@
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
flex: 1; flex: 0 0 auto; /* 不再自动伸缩,使用固定高度 */
height: 60px; /* 设置固定高度 */
} }
.role-header .role-name { .role-header .role-name {
@@ -177,12 +178,16 @@
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
max-width: 80%; /* 限制名称最大宽度 */
} }
.role-item.active .role-header .role-name { .role-item.active .role-header .role-name {
color: #667eea; color: #667eea;
font-size: 16px; /* 激活状态时字体稍大 */
} }
.role-actions { .role-actions {
display: flex; display: flex;
gap: 4px; gap: 4px;
@@ -191,8 +196,10 @@
right: 12px; right: 12px;
opacity: 0; opacity: 0;
transition: opacity 0.2s ease; transition: opacity 0.2s ease;
z-index: 10; /* 确保操作按钮在最上层 */
} }
.role-item:hover .role-actions { .role-item:hover .role-actions {
opacity: 1; opacity: 1;
} }
@@ -227,13 +234,17 @@
border-top: 1px solid #f0f0f0; border-top: 1px solid #f0f0f0;
padding: 8px 0; padding: 8px 0;
background-color: #fafbfc; background-color: #fafbfc;
max-height: 140px; height: 120px; /* 设置固定高度 */
overflow-y: auto; overflow-y: auto;
/* 自定义滚动条样式 */ /* 自定义滚动条样式 */
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: #cbd5e1 transparent; scrollbar-color: #cbd5e1 transparent;
flex: 1; /* 让聊天列表占据剩余空间 */
display: flex;
flex-direction: column;
} }
/* Webkit浏览器滚动条样式 */ /* Webkit浏览器滚动条样式 */
.chat-list::-webkit-scrollbar { .chat-list::-webkit-scrollbar {
width: 4px; width: 4px;

View File

@@ -1,8 +1,10 @@
import React, { useEffect, useRef } from 'react'; import React, { useEffect, useRef } from 'react';
import useRoleSelectorStore from '../../store/Slices/RoleSelectorSlice'; import useRoleSelectorStore from '../../Store/Slices/RoleSelectorSlice';
import useChatBoxStore from '../../Store/Slices/ChatBoxSlice';
import './RoleSelector.css'; import './RoleSelector.css';
const RoleSelector = ({ onRoleChange, onChatChange }) => { const RoleSelector = () => {
const panelRef = useRef(null); const panelRef = useRef(null);
// 从 Zustand store 中获取状态和操作 // 从 Zustand store 中获取状态和操作
@@ -21,6 +23,7 @@ const RoleSelector = ({ onRoleChange, onChatChange }) => {
fetchRoleData, fetchRoleData,
setSelectedRole, setSelectedRole,
setSelectedChat, setSelectedChat,
setSelectedRoleAndChat,
setHoveredRole, setHoveredRole,
setClickedRole, setClickedRole,
setSearchTerm, setSearchTerm,
@@ -37,6 +40,12 @@ const RoleSelector = ({ onRoleChange, onChatChange }) => {
resetPanel resetPanel
} = useRoleSelectorStore(); } = useRoleSelectorStore();
// 从 ChatBoxStore 获取状态更新方法
const chatBoxStore = useChatBoxStore();
const { setCurrentRole, setCurrentChat } = chatBoxStore;
const setChatBoxRoleAndChat = chatBoxStore.setChatBoxRoleAndChat;
// 组件挂载时获取数据 // 组件挂载时获取数据
useEffect(() => { useEffect(() => {
fetchRoleData(); fetchRoleData();
@@ -58,37 +67,42 @@ const RoleSelector = ({ onRoleChange, onChatChange }) => {
// 处理角色选择 // 处理角色选择
const handleRoleSelect = (role) => { const handleRoleSelect = (role) => {
setSelectedRole(role);
if (onRoleChange) {
onRoleChange(role);
}
// 如果该角色有聊天记录,默认选择第一个 // 如果该角色有聊天记录,默认选择第一个
if (roleData[role] && roleData[role].length > 0) { if (roleData[role] && roleData[role].length > 0) {
const firstChat = roleData[role][0]; const firstChat = roleData[role][0];
setSelectedChat(firstChat); // 使用新的原子操作方法同时更新角色和聊天
if (onChatChange) { setSelectedRoleAndChat(role, firstChat);
onChatChange(role, firstChat); // 同步更新 ChatBoxStore 中的状态
} setChatBoxRoleAndChat(role, firstChat);
} else { } else {
setSelectedChat(null); // 清空角色和聊天
setSelectedRoleAndChat(null, null);
// 同步更新 ChatBoxStore 中的状态
setChatBoxRoleAndChat(null, null);
} }
}; };
// 处理聊天选择 // 处理聊天选择
const handleChatSelect = (chat) => { const handleChatSelect = (chat) => {
setSelectedChat(chat); // 获取当前展开的角色(聊天所属的角色)
const currentRole = hoveredRole || clickedRole;
// 使用新的原子操作方法同时更新角色和聊天
setSelectedRoleAndChat(currentRole, chat);
// 同步更新 ChatBoxStore 中的状态
setChatBoxRoleAndChat(currentRole, chat);
setHoveredRole(null); setHoveredRole(null);
setClickedRole(null); setClickedRole(null);
if (onChatChange) {
onChatChange(selectedRole, chat);
}
}; };
// 处理角色卡片点击 // 处理角色卡片点击
const handleRoleCardClick = (role) => { const handleRoleCardClick = (role) => {
if (clickedRole === role) { if (clickedRole === role) {
setClickedRole(null); setClickedRole(null);
// 取消选择角色时,更新 selectedRole 和 selectedChat
setSelectedRoleAndChat(null, null);
// 同步更新 ChatBoxStore 中的状态
setChatBoxRoleAndChat(null, null);
} else { } else {
setClickedRole(role); setClickedRole(role);
handleRoleSelect(role); handleRoleSelect(role);
@@ -112,9 +126,8 @@ const RoleSelector = ({ onRoleChange, onChatChange }) => {
const newName = e.target.value; const newName = e.target.value;
handleRenameRole(oldName, newName); handleRenameRole(oldName, newName);
if (selectedRole === oldName && newName && newName !== oldName) { if (selectedRole === oldName && newName && newName !== oldName) {
if (onRoleChange) { // 更新 ChatBoxStore 中的当前角色
onRoleChange(newName); setCurrentRole(newName);
}
} }
}; };
@@ -130,9 +143,8 @@ const RoleSelector = ({ onRoleChange, onChatChange }) => {
const newName = e.target.value; const newName = e.target.value;
handleRenameChat(oldName, newName); handleRenameChat(oldName, newName);
if (selectedChat === oldName && newName && newName !== oldName) { if (selectedChat === oldName && newName && newName !== oldName) {
if (onChatChange) { // 更新 ChatBoxStore 中的当前聊天
onChatChange(selectedRole, newName); setCurrentChat(selectedRole, newName);
}
} }
}; };
@@ -147,13 +159,11 @@ const RoleSelector = ({ onRoleChange, onChatChange }) => {
const confirmDeleteWrapper = () => { const confirmDeleteWrapper = () => {
confirmDelete(); confirmDelete();
if (deleteType === 'role' && selectedRole === showDeleteConfirm) { if (deleteType === 'role' && selectedRole === showDeleteConfirm) {
if (onRoleChange) { // 清除 ChatBoxStore 中的当前角色和聊天
onRoleChange(null); setChatBoxRoleAndChat(null, null);
}
} else if (deleteType === 'chat' && selectedChat === showDeleteConfirm) { } else if (deleteType === 'chat' && selectedChat === showDeleteConfirm) {
if (onChatChange) { // 清除 ChatBoxStore 中的当前聊天
onChatChange(selectedRole, null); setChatBoxRoleAndChat(selectedRole, null);
}
} }
}; };

View 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;
}

View 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;

View 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;

View 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;

View 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;

View 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;

View File

@@ -0,0 +1 @@
WorldBook.css

View 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;
}
}

View 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;
}

View 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;
}

View 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;

View File

@@ -0,0 +1,12 @@
import React from 'react';
const Debug = () => {
return (
<div className="debug-panel">
<h2>上下文调试</h2>
<p>这是上下文调试面板的占位页面</p>
</div>
);
};
export default Debug;

View File

@@ -0,0 +1,12 @@
import React from 'react';
const Dice = () => {
return (
<div className="dice-panel">
<h2>骰子面板</h2>
<p>这是骰子面板的占位页面</p>
</div>
);
};
export default Dice;

View File

@@ -0,0 +1,12 @@
import React from 'react';
const Macros = () => {
return (
<div className="macros-panel">
<h2>快捷宏</h2>
<p>这是快捷宏面板的占位页面</p>
</div>
);
};
export default Macros;

View File

@@ -0,0 +1,12 @@
import React from 'react';
const Table = () => {
return (
<div className="table-panel">
<h2>动态表格</h2>
<p>这是动态表格面板的占位页面</p>
</div>
);
};
export default Table;

View File

@@ -34,17 +34,18 @@
/* 工具栏图标 */ /* 工具栏图标 */
.toolbar-icon { .toolbar-icon {
width: 36px;
height: 36px; height: 36px;
padding: 0 12px;
border-radius: 6px; border-radius: 6px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; gap: 8px;
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: all 0.2s ease;
font-size: 18px; font-size: 18px;
color: #555; color: #555;
background-color: #f5f5f5; background-color: #f5f5f5;
white-space: nowrap;
} }
.toolbar-icon:hover { .toolbar-icon:hover {
@@ -59,6 +60,15 @@
color: #fff; color: #fff;
} }
/* 图标标签文本 */
.icon-label {
font-size: 14px;
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ==================== 弹出面板通用样式 ==================== */ /* ==================== 弹出面板通用样式 ==================== */
.close-panel-button { .close-panel-button {

View File

@@ -1,10 +1,14 @@
import React, { useState, useRef } from 'react'; // frontend-react/src/components/ToolBar/ToolBar.jsx
import React, { useState, useRef, useEffect } from 'react';
import RoleSelector from '../RoleSelector/RoleSelector'; import RoleSelector from '../RoleSelector/RoleSelector';
import useRoleSelectorStore from '../../Store/Slices/RoleSelectorSlice';
import './ToolBar.css'; import './ToolBar.css';
const Toolbar = () => { const Toolbar = () => {
const [activePanel, setActivePanel] = useState(null); const [activePanel, setActivePanel] = useState(null);
const panelRef = useRef(null); const panelRef = useRef(null);
const selectedRole = useRoleSelectorStore((state) => state.selectedRole);
const selectedChat = useRoleSelectorStore((state) => state.selectedChat);
// 点击外部关闭面板 // 点击外部关闭面板
React.useEffect(() => { React.useEffect(() => {
@@ -20,6 +24,13 @@ const Toolbar = () => {
}; };
}, []); }, []);
// 监听selectedRole和selectedChat的变化
useEffect(() => {
console.log('当前选中的角色:', selectedRole);
console.log('当前选中的聊天:', selectedChat);
// 这里可以添加其他需要响应角色变化的逻辑
}, [selectedRole, selectedChat]);
// 处理面板切换 // 处理面板切换
const handlePanelToggle = (panelName) => { const handlePanelToggle = (panelName) => {
if (activePanel === panelName) { if (activePanel === panelName) {
@@ -34,38 +45,73 @@ const Toolbar = () => {
setActivePanel(null); 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 ( return (
<> <>
<div className="toolbar"> <div className="toolbar">
{/* 左侧工具栏图标 */} {/* 左侧:当前角色 */}
<div className="toolbar-section"> <div className="toolbar-section">
<div className="toolbar-icons"> <div className="toolbar-icons">
{/* Logo图标 */}
<div <div
className="toolbar-icon" className="toolbar-icon"
title="首页" title="玩家角色"
onClick={() => handlePanelToggle(null)} onClick={() => handlePanelToggle('currentRole')}
>
🤖
</div>
</div>
</div>
{/* 中间操作图标 */}
<div className="toolbar-section">
<div className="toolbar-icons">
{/* 角色选择图标 */}
<div
className={`toolbar-icon ${activePanel === 'role' ? 'active' : ''}`}
title="角色管理"
onClick={() => handlePanelToggle('role')}
> >
👤 👤
<span className="icon-label">当前角色</span>
</div>
<div className="toolbar-display-box">
{truncateText(getDisplayText())}
</div> </div>
</div> </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-section">
<div className="toolbar-icons" style={{ justifyContent: 'flex-end' }}> <div className="toolbar-icons" style={{ justifyContent: 'flex-end' }}>
<div <div
@@ -77,10 +123,10 @@ const Toolbar = () => {
</div> </div>
<div <div
className="toolbar-icon" className="toolbar-icon"
title="帮助" title="拓展"
onClick={() => handlePanelToggle('help')} onClick={() => handlePanelToggle('extensions')}
> >
</div> </div>
</div> </div>
</div> </div>
@@ -91,7 +137,7 @@ const Toolbar = () => {
<div className="panel-overlay" ref={panelRef}> <div className="panel-overlay" ref={panelRef}>
<div className="panel-content"> <div className="panel-content">
<div className="panel-header"> <div className="panel-header">
<h3>角色管理</h3> <h3>用户角色管理</h3>
<button className="close-panel-button" onClick={handleClosePanel} title="关闭"> <button className="close-panel-button" onClick={handleClosePanel} title="关闭">
</button> </button>
@@ -103,33 +149,69 @@ const Toolbar = () => {
</div> </div>
)} )}
{activePanel === 'settings' && ( {/* 当前角色面板(暂时留空) */}
{activePanel === 'currentRole' && (
<div className="panel-overlay" ref={panelRef}> <div className="panel-overlay" ref={panelRef}>
<div className="panel-content"> <div className="panel-content">
<div className="panel-header"> <div className="panel-header">
<h3>设置</h3> <h3>当前ai角色</h3>
<button className="close-panel-button" onClick={handleClosePanel} title="关闭"> <button className="close-panel-button" onClick={handleClosePanel} title="关闭">
</button> </button>
</div> </div>
<div className="panel-body"> <div className="panel-body">
<p>设置内容...</p> <p>当前角色详情...</p>
</div> </div>
</div> </div>
</div> </div>
)} )}
{activePanel === 'help' && ( {/* 全局世界书面板 */}
{activePanel === 'worldBook' && (
<div className="panel-overlay" ref={panelRef}> <div className="panel-overlay" ref={panelRef}>
<div className="panel-content"> <div className="panel-content">
<div className="panel-header"> <div className="panel-header">
<h3>帮助</h3> <h3>全局世界书</h3>
<button className="close-panel-button" onClick={handleClosePanel} title="关闭"> <button className="close-panel-button" onClick={handleClosePanel} title="关闭">
</button> </button>
</div> </div>
<div className="panel-body"> <div className="panel-body">
<p>帮助内容...</p> <p>全局世界书内容...</p>
</div>
</div>
</div>
)}
{/* 设置面板 */}
{activePanel === 'settings' && (
<div className="panel-overlay" ref={panelRef}>
<div className="panel-content">
<div className="panel-header">
<h3>系统设置</h3>
<button className="close-panel-button" onClick={handleClosePanel} title="关闭">
</button>
</div>
<div className="panel-body">
<p>系统设置内容...</p>
</div>
</div>
</div>
)}
{/* 拓展面板 */}
{activePanel === 'extensions' && (
<div className="panel-overlay" ref={panelRef}>
<div className="panel-content">
<div className="panel-header">
<h3>功能拓展</h3>
<button className="close-panel-button" onClick={handleClosePanel} title="关闭">
</button>
</div>
<div className="panel-body">
<p>功能拓展内容...</p>
</div> </div>
</div> </div>
</div> </div>
@@ -138,4 +220,4 @@ const Toolbar = () => {
); );
}; };
export default Toolbar; export default Toolbar;

View File

@@ -1,61 +1,21 @@
/* 重置样式 */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body, .app {
height: 100%;
width: 100%;
overflow: hidden; /* 防止出现滚动条 */
}
.app {
display: flex;
flex-direction: column;
}
/* 主内容区域 */
.main-container {
flex: 1; /* 这会让主容器占据剩余的所有空间 */
display: flex;
overflow: hidden; /* 防止内容溢出 */
height: 100%; /* 确保高度填满 */
}
/* 左侧栏 */ /* 左侧栏 */
.sidebar-left { .sidebar-left {
width: 250px; /* 或者你想要的宽度 */ width: 22.5%; /* 修改为30% */
height: ; /* 确保高度填满 */ height: 100%;
overflow-y: auto; /* 内容过多时显示滚动条 */ overflow: hidden;
} }
/* 中间聊天区域 */ /* 中间聊天区域 */
.chat-area { .chat-area {
flex: 1; /* 占据剩余空间 */ flex: 55%; /* 修改为0.4 */
height: 100%; /* 确保高度填满 */ height: 100%;
display: flex; display: flex;
flex-direction: column; overflow: hidden;
overflow: hidden; /* 防止内容溢出 */
} }
/* 右侧栏 */ /* 右侧栏 */
.sidebar-right { .sidebar-right {
width: 300px; /* 或者你想要的宽度 */ width: 22.5%; /* 修改为30% */
height: 100%; /* 确保高度填满 */ height: 100%;
display: flex; overflow: hidden;
flex-direction: column;
}
/* 右侧栏顶部 */
.right-top {
flex: 1; /* 占据剩余空间 */
overflow-y: auto;
}
/* 右侧栏底部 */
.right-bottom {
height: 200px; /* 或者你想要的高度 */
overflow-y: auto;
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 497 KiB

After

Width:  |  Height:  |  Size: 0 B