重构路由架构
This commit is contained in:
@@ -1,68 +1,16 @@
|
||||
from fastapi import APIRouter
|
||||
from ..core.items import ChatRequest
|
||||
from ..tools.get_all_role_and_chat import get_all_role_and_chat
|
||||
from ..core.models.chat_history import ChatHistory # 修改导入语句
|
||||
from .routes import presetsRoute, chatsRoute, worldbooksRoute
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# 从本地读取所有的data内容
|
||||
# 注册子路由
|
||||
router.include_router(presetsRoute.router)
|
||||
router.include_router(chatsRoute.router)
|
||||
router.include_router(worldbooksRoute.router)
|
||||
|
||||
|
||||
# 保留原有的其他路由
|
||||
@router.get("/tool_bar/get_all_role_and_chat")
|
||||
def get_all_role_and_chat_endpoint():
|
||||
# 正确调用函数并返回结果
|
||||
return get_all_role_and_chat()
|
||||
|
||||
# 根据rolename和chatname读取特定聊天记录
|
||||
@router.get("/chat_box/get_chat_history")
|
||||
async def get_chat_history_endpoint(role_name: str, chat_name: str):
|
||||
# 实例化工具类
|
||||
reader = ChatHistory.load_from_file(role_name, chat_name)
|
||||
|
||||
return reader.to_chatbox_format()
|
||||
|
||||
|
||||
# 从本地读取所有的预设列表内容
|
||||
@router.get("/presets/list")
|
||||
async def get_presets_list():
|
||||
"""
|
||||
获取所有可用的预设列表
|
||||
返回预设名称列表
|
||||
"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# 预设文件存储目录
|
||||
preset_dir = Path("data/preset")
|
||||
|
||||
# 确保目录存在
|
||||
if not preset_dir.exists():
|
||||
return {"presets": []}
|
||||
|
||||
# 获取所有.json文件
|
||||
preset_files = list(preset_dir.glob("*.json"))
|
||||
|
||||
# 提取文件名(不带扩展名)作为预设名称
|
||||
presets = [file.stem for file in preset_files]
|
||||
|
||||
return {"presets": presets}
|
||||
|
||||
|
||||
# 从本地读取特定预设
|
||||
@router.get("/presets/{preset_name}")
|
||||
async def get_preset_content(preset_name: str):
|
||||
"""
|
||||
获取特定预设的完整内容
|
||||
"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
import json
|
||||
|
||||
preset_path = Path("data/preset") / f"{preset_name}.json"
|
||||
|
||||
if not preset_path.exists():
|
||||
raise HTTPException(status_code=404, detail="Preset not found")
|
||||
|
||||
with open(preset_path, 'r', encoding='utf-8') as f:
|
||||
preset_data = json.load(f)
|
||||
|
||||
return preset_data
|
||||
|
||||
from ..tools.get_all_role_and_chat import get_all_role_and_chat
|
||||
return get_all_role_and_chat()
|
||||
|
||||
193
backend/api/routes/chatsRoute.py
Normal file
193
backend/api/routes/chatsRoute.py
Normal file
@@ -0,0 +1,193 @@
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from pathlib import Path
|
||||
import json
|
||||
from typing import List, Dict
|
||||
from backend.core.models.chat_history import ChatHistory, Message
|
||||
|
||||
router = APIRouter(prefix="/chats", tags=["chats"])
|
||||
|
||||
|
||||
# ========== 聊天历史基础路由 ==========
|
||||
|
||||
@router.get("", response_model=Dict[str, List[Dict]])
|
||||
async def list_all_chats():
|
||||
"""获取所有角色的所有聊天列表"""
|
||||
data_dir = Path("data")
|
||||
if not data_dir.exists():
|
||||
return {"chats": []}
|
||||
|
||||
chats = []
|
||||
for role_dir in data_dir.iterdir():
|
||||
if role_dir.is_dir():
|
||||
for chat_file in role_dir.glob("*.jsonl"):
|
||||
try:
|
||||
with open(chat_file, 'r', encoding='utf-8') as f:
|
||||
# 读取第一行获取元数据
|
||||
first_line = f.readline()
|
||||
metadata = json.loads(first_line)
|
||||
chats.append({
|
||||
"role_name": role_dir.name,
|
||||
"chat_name": chat_file.stem,
|
||||
"user_name": metadata.get("user_name", "User"),
|
||||
"character_name": metadata.get("character_name", "Assistant"),
|
||||
"last_modified": metadata.get("last_modified", ""),
|
||||
"message_count": sum(1 for _ in f) # 统计剩余行数(消息数)
|
||||
})
|
||||
except Exception as e:
|
||||
continue # 跳过损坏的聊天文件
|
||||
return {"chats": chats}
|
||||
|
||||
|
||||
@router.get("/{role_name}/{chat_name}")
|
||||
async def get_chat(role_name: str, chat_name: str):
|
||||
"""获取指定聊天的完整内容"""
|
||||
try:
|
||||
chat_history = ChatHistory.load_from_file(role_name, chat_name)
|
||||
return {
|
||||
"metadata": chat_history.chat_metadata.dict(),
|
||||
"messages": chat_history.to_chatbox_format()
|
||||
}
|
||||
except FileNotFoundError:
|
||||
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):
|
||||
"""创建新聊天"""
|
||||
role_dir = Path("data") / role_name
|
||||
role_dir.mkdir(parents=True, exist_ok=True)
|
||||
chat_path = role_dir / f"{chat_name}.jsonl"
|
||||
|
||||
if chat_path.exists():
|
||||
raise HTTPException(status_code=400, detail="Chat already exists")
|
||||
|
||||
# 创建聊天历史对象
|
||||
chat_history = ChatHistory(
|
||||
chat_metadata=metadata or {},
|
||||
messages=[]
|
||||
)
|
||||
|
||||
# 保存到文件
|
||||
chat_history.save_to_file(role_name, chat_name)
|
||||
return {"message": "Chat created successfully"}
|
||||
|
||||
|
||||
@router.put("/{role_name}/{chat_name}")
|
||||
async def update_chat(role_name: str, chat_name: str, update_data: Dict):
|
||||
"""更新聊天元数据"""
|
||||
try:
|
||||
chat_history = ChatHistory.load_from_file(role_name, chat_name)
|
||||
|
||||
# 更新元数据
|
||||
if "metadata" in update_data:
|
||||
for key, value in update_data["metadata"].items():
|
||||
if hasattr(chat_history.chat_metadata, key):
|
||||
setattr(chat_history.chat_metadata, key, value)
|
||||
|
||||
# 保存更改
|
||||
chat_history.save_to_file(role_name, chat_name)
|
||||
return {"message": "Chat metadata updated successfully"}
|
||||
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):
|
||||
"""删除指定聊天"""
|
||||
chat_path = Path("data") / role_name / f"{chat_name}.jsonl"
|
||||
if not chat_path.exists():
|
||||
raise HTTPException(status_code=404, detail="Chat not found")
|
||||
chat_path.unlink()
|
||||
return {"message": "Chat deleted successfully"}
|
||||
|
||||
|
||||
# ========== 聊天消息路由 ==========
|
||||
|
||||
@router.get("/{role_name}/{chat_name}/messages")
|
||||
async def list_messages(role_name: str, chat_name: str):
|
||||
"""获取聊天的所有消息"""
|
||||
try:
|
||||
chat_history = ChatHistory.load_from_file(role_name, chat_name)
|
||||
return {"messages": chat_history.to_chatbox_format()}
|
||||
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:
|
||||
chat_history = ChatHistory.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 HTTPException(status_code=404, detail="Message not found")
|
||||
|
||||
return message.dict()
|
||||
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:
|
||||
chat_history = ChatHistory.load_from_file(role_name, chat_name)
|
||||
|
||||
# 创建消息对象
|
||||
message = Message(**message_data)
|
||||
|
||||
# 检查楼层是否已存在
|
||||
if any(msg.floor == message.floor for msg in chat_history.messages):
|
||||
raise HTTPException(status_code=400, detail="Message floor already exists")
|
||||
|
||||
# 添加消息
|
||||
chat_history.messages.append(message)
|
||||
|
||||
# 保存更改
|
||||
chat_history.save_to_file(role_name, chat_name)
|
||||
return {"message": "Message added successfully", "floor": message.floor}
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Chat not found")
|
||||
|
||||
|
||||
@router.put("/{role_name}/{chat_name}/messages/{floor}")
|
||||
async def update_message(role_name: str, chat_name: str, floor: int, update_data: Dict):
|
||||
"""更新指定楼层的消息"""
|
||||
try:
|
||||
chat_history = ChatHistory.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 HTTPException(status_code=404, detail="Message not found")
|
||||
|
||||
# 更新消息字段
|
||||
for key, value in update_data.items():
|
||||
if hasattr(message, key):
|
||||
setattr(message, key, value)
|
||||
|
||||
# 保存更改
|
||||
chat_history.save_to_file(role_name, chat_name)
|
||||
return {"message": "Message updated successfully"}
|
||||
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:
|
||||
chat_history = ChatHistory.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 HTTPException(status_code=404, detail="Message not found")
|
||||
|
||||
# 保存更改
|
||||
chat_history.save_to_file(role_name, chat_name)
|
||||
return {"message": "Message deleted successfully"}
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Chat not found")
|
||||
264
backend/api/routes/presetsRoute.py
Normal file
264
backend/api/routes/presetsRoute.py
Normal file
@@ -0,0 +1,264 @@
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from pathlib import Path
|
||||
import json
|
||||
from typing import List, Dict
|
||||
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[str, List[Dict]])
|
||||
async def list_presets():
|
||||
"""获取所有预设列表及其基本信息"""
|
||||
preset_dir = Path("data/preset")
|
||||
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 as e:
|
||||
continue # 跳过损坏的预设文件
|
||||
return {"presets": presets}
|
||||
|
||||
|
||||
@router.get("/{preset_name}")
|
||||
async def get_preset(preset_name: str):
|
||||
"""获取指定预设的完整内容"""
|
||||
preset_path = Path("data/preset") / f"{preset_name}.json"
|
||||
if not preset_path.exists():
|
||||
raise HTTPException(status_code=404, detail="Preset not found")
|
||||
|
||||
try:
|
||||
with open(preset_path, 'r', encoding='utf-8') as f:
|
||||
preset_data = json.load(f)
|
||||
|
||||
# 转换为AIDesignSpec对象进行验证
|
||||
ai_design_spec = AIDesignSpec(**preset_data)
|
||||
return ai_design_spec.dict()
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to load preset: {str(e)}")
|
||||
|
||||
|
||||
@router.post("", status_code=status.HTTP_201_CREATED)
|
||||
async def create_preset(preset_name: str, preset_data: Dict):
|
||||
"""创建新预设"""
|
||||
preset_dir = Path("data/preset")
|
||||
preset_dir.mkdir(parents=True, exist_ok=True)
|
||||
preset_path = preset_dir / f"{preset_name}.json"
|
||||
|
||||
if preset_path.exists():
|
||||
raise HTTPException(status_code=400, detail="Preset already exists")
|
||||
|
||||
try:
|
||||
# 验证并转换为AIDesignSpec对象
|
||||
ai_design_spec = AIDesignSpec(**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 HTTPException(status_code=500, detail=f"Failed to create preset: {str(e)}")
|
||||
|
||||
|
||||
@router.put("/{preset_name}")
|
||||
async def update_preset(preset_name: str, update_data: Dict):
|
||||
"""更新预设配置"""
|
||||
preset_path = Path("data/preset") / f"{preset_name}.json"
|
||||
if not preset_path.exists():
|
||||
raise HTTPException(status_code=404, detail="Preset not found")
|
||||
|
||||
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 = AIDesignSpec(**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 HTTPException(status_code=500, detail=f"Failed to update preset: {str(e)}")
|
||||
|
||||
|
||||
@router.delete("/{preset_name}")
|
||||
async def delete_preset(preset_name: str):
|
||||
"""删除指定预设"""
|
||||
preset_path = Path("data/preset") / f"{preset_name}.json"
|
||||
if not preset_path.exists():
|
||||
raise HTTPException(status_code=404, detail="Preset not found")
|
||||
|
||||
try:
|
||||
preset_path.unlink()
|
||||
return {"message": "Preset deleted successfully"}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to delete preset: {str(e)}")
|
||||
|
||||
|
||||
# ========== 预设组件路由 ==========
|
||||
|
||||
@router.get("/{preset_name}/components")
|
||||
async def list_preset_components(preset_name: str):
|
||||
"""获取预设中的所有组件"""
|
||||
preset_path = Path("data/preset") / f"{preset_name}.json"
|
||||
if not preset_path.exists():
|
||||
raise HTTPException(status_code=404, detail="Preset not found")
|
||||
|
||||
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 HTTPException(status_code=500, detail=f"Failed to load components: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/{preset_name}/components/{component_id}")
|
||||
async def get_preset_component(preset_name: str, component_id: str):
|
||||
"""获取指定组件的详情"""
|
||||
preset_path = Path("data/preset") / f"{preset_name}.json"
|
||||
if not preset_path.exists():
|
||||
raise HTTPException(status_code=404, detail="Preset not found")
|
||||
|
||||
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 HTTPException(status_code=404, detail="Component not found")
|
||||
|
||||
return component
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to load component: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/{preset_name}/components", status_code=status.HTTP_201_CREATED)
|
||||
async def add_preset_component(preset_name: str, component_data: Dict):
|
||||
"""向预设添加新组件"""
|
||||
preset_path = Path("data/preset") / f"{preset_name}.json"
|
||||
if not preset_path.exists():
|
||||
raise HTTPException(status_code=404, detail="Preset not found")
|
||||
|
||||
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 HTTPException(status_code=400, detail="Component identifier already exists")
|
||||
|
||||
# 添加组件
|
||||
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 HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to add component: {str(e)}")
|
||||
|
||||
|
||||
@router.put("/{preset_name}/components/{component_id}")
|
||||
async def update_preset_component(preset_name: str, component_id: str, update_data: Dict):
|
||||
"""更新指定组件"""
|
||||
preset_path = Path("data/preset") / f"{preset_name}.json"
|
||||
if not preset_path.exists():
|
||||
raise HTTPException(status_code=404, detail="Preset not found")
|
||||
|
||||
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 HTTPException(status_code=404, detail="Component not found")
|
||||
|
||||
# 更新组件字段
|
||||
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 HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to update component: {str(e)}")
|
||||
|
||||
|
||||
@router.delete("/{preset_name}/components/{component_id}")
|
||||
async def delete_preset_component(preset_name: str, component_id: str):
|
||||
"""从预设中删除指定组件"""
|
||||
preset_path = Path("data/preset") / f"{preset_name}.json"
|
||||
if not preset_path.exists():
|
||||
raise HTTPException(status_code=404, detail="Preset not found")
|
||||
|
||||
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 HTTPException(status_code=404, detail="Component not found")
|
||||
|
||||
# 更新预设数据
|
||||
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 HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to delete component: {str(e)}")
|
||||
170
backend/api/routes/worldbooksRoute.py
Normal file
170
backend/api/routes/worldbooksRoute.py
Normal file
@@ -0,0 +1,170 @@
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from pathlib import Path
|
||||
from typing import Dict, List
|
||||
from backend.core.models.WorldBook import WorldBook
|
||||
from backend.core.models.WorldItem import WorldInfoEntry
|
||||
|
||||
router = APIRouter(prefix="/worldbooks", tags=["worldbooks"])
|
||||
|
||||
|
||||
# ========== 世界书基础路由 ==========
|
||||
|
||||
@router.get("", response_model=Dict[str, List[Dict]])
|
||||
async def list_worldbooks():
|
||||
"""获取所有世界书列表"""
|
||||
worldbook_dir = Path("data/worldbooks")
|
||||
if not worldbook_dir.exists():
|
||||
return {"worldbooks": []}
|
||||
|
||||
worldbooks = []
|
||||
for wb_file in worldbook_dir.glob("*.json"):
|
||||
try:
|
||||
worldbook = WorldBook.from_sillytavern_json(str(wb_file))
|
||||
worldbooks.append(worldbook.to_summary_dict())
|
||||
except Exception as e:
|
||||
print(f"警告: 跳过文件 {wb_file.name},加载失败: {e}")
|
||||
continue
|
||||
|
||||
return {"worldbooks": worldbooks}
|
||||
|
||||
|
||||
@router.get("/{worldbook_uid}")
|
||||
async def get_worldbook(worldbook_uid: str):
|
||||
"""获取指定世界书完整内容"""
|
||||
worldbook_path = Path("data/worldbooks") / f"{worldbook_uid}.json"
|
||||
if not worldbook_path.exists():
|
||||
raise HTTPException(status_code=404, detail="WorldBook not found")
|
||||
|
||||
try:
|
||||
worldbook = WorldBook.from_sillytavern_json(str(worldbook_path))
|
||||
return worldbook.to_dict()
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to load worldbook: {str(e)}")
|
||||
|
||||
|
||||
@router.post("", status_code=status.HTTP_201_CREATED)
|
||||
async def create_worldbook(worldbook_data: Dict):
|
||||
"""创建新世界书"""
|
||||
worldbook_dir = Path("data/worldbooks")
|
||||
worldbook_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
worldbook = WorldBook.from_dict(worldbook_data)
|
||||
worldbook_path = worldbook_dir / f"{worldbook.uid}.json"
|
||||
|
||||
if worldbook_path.exists():
|
||||
raise HTTPException(status_code=400, detail="WorldBook already exists")
|
||||
|
||||
worldbook.to_sillytavern_json(str(worldbook_path))
|
||||
return {"message": "WorldBook created successfully", "uid": worldbook.uid}
|
||||
|
||||
|
||||
@router.put("/{worldbook_uid}")
|
||||
async def update_worldbook(worldbook_uid: str, update_data: Dict):
|
||||
"""更新世界书基本信息"""
|
||||
worldbook_path = Path("data/worldbooks") / f"{worldbook_uid}.json"
|
||||
if not worldbook_path.exists():
|
||||
raise HTTPException(status_code=404, detail="WorldBook not found")
|
||||
|
||||
try:
|
||||
worldbook = WorldBook.from_sillytavern_json(str(worldbook_path))
|
||||
for key, value in update_data.items():
|
||||
if hasattr(worldbook, key):
|
||||
setattr(worldbook, key, value)
|
||||
worldbook.to_sillytavern_json(str(worldbook_path))
|
||||
return {"message": "WorldBook updated successfully"}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to update worldbook: {str(e)}")
|
||||
|
||||
|
||||
@router.delete("/{worldbook_uid}")
|
||||
async def delete_worldbook(worldbook_uid: str):
|
||||
"""删除世界书"""
|
||||
worldbook_path = Path("data/worldbooks") / f"{worldbook_uid}.json"
|
||||
if not worldbook_path.exists():
|
||||
raise HTTPException(status_code=404, detail="WorldBook not found")
|
||||
worldbook_path.unlink()
|
||||
return {"message": "WorldBook deleted successfully"}
|
||||
|
||||
|
||||
# ========== 世界书条目路由 ==========
|
||||
|
||||
@router.get("/{worldbook_uid}/entries")
|
||||
async def list_worldbook_entries(worldbook_uid: str):
|
||||
"""获取世界书所有条目列表"""
|
||||
worldbook_path = Path("data/worldbooks") / f"{worldbook_uid}.json"
|
||||
if not worldbook_path.exists():
|
||||
raise HTTPException(status_code=404, detail="WorldBook not found")
|
||||
|
||||
try:
|
||||
worldbook = WorldBook.from_sillytavern_json(str(worldbook_path))
|
||||
return {"entries": [entry.dict() for entry in worldbook.entries]}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to load entries: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/{worldbook_uid}/entries/{entry_uid}")
|
||||
async def get_worldbook_entry(worldbook_uid: str, entry_uid: str):
|
||||
"""获取指定条目详情"""
|
||||
worldbook_path = Path("data/worldbooks") / f"{worldbook_uid}.json"
|
||||
if not worldbook_path.exists():
|
||||
raise HTTPException(status_code=404, detail="WorldBook not found")
|
||||
|
||||
try:
|
||||
worldbook = WorldBook.from_sillytavern_json(str(worldbook_path))
|
||||
entry = worldbook.get_entry(entry_uid)
|
||||
if not entry:
|
||||
raise HTTPException(status_code=404, detail="Entry not found")
|
||||
return entry.dict()
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to load entry: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/{worldbook_uid}/entries", status_code=status.HTTP_201_CREATED)
|
||||
async def add_worldbook_entry(worldbook_uid: str, entry_data: Dict):
|
||||
"""向世界书添加新条目"""
|
||||
worldbook_path = Path("data/worldbooks") / f"{worldbook_uid}.json"
|
||||
if not worldbook_path.exists():
|
||||
raise HTTPException(status_code=404, detail="WorldBook not found")
|
||||
|
||||
try:
|
||||
worldbook = WorldBook.from_sillytavern_json(str(worldbook_path))
|
||||
entry = WorldInfoEntry(**entry_data)
|
||||
worldbook.add_entry(entry)
|
||||
worldbook.to_sillytavern_json(str(worldbook_path))
|
||||
return {"message": "Entry added successfully", "entry_uid": entry.uid}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to add entry: {str(e)}")
|
||||
|
||||
|
||||
@router.put("/{worldbook_uid}/entries/{entry_uid}")
|
||||
async def update_worldbook_entry(worldbook_uid: str, entry_uid: str, update_data: Dict):
|
||||
"""更新指定条目"""
|
||||
worldbook_path = Path("data/worldbooks") / f"{worldbook_uid}.json"
|
||||
if not worldbook_path.exists():
|
||||
raise HTTPException(status_code=404, detail="WorldBook not found")
|
||||
|
||||
try:
|
||||
worldbook = WorldBook.from_sillytavern_json(str(worldbook_path))
|
||||
if not worldbook.update_entry(entry_uid, **update_data):
|
||||
raise HTTPException(status_code=404, detail="Entry not found")
|
||||
worldbook.to_sillytavern_json(str(worldbook_path))
|
||||
return {"message": "Entry updated successfully"}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to update entry: {str(e)}")
|
||||
|
||||
|
||||
@router.delete("/{worldbook_uid}/entries/{entry_uid}")
|
||||
async def delete_worldbook_entry(worldbook_uid: str, entry_uid: str):
|
||||
"""从世界书删除指定条目"""
|
||||
worldbook_path = Path("data/worldbooks") / f"{worldbook_uid}.json"
|
||||
if not worldbook_path.exists():
|
||||
raise HTTPException(status_code=404, detail="WorldBook not found")
|
||||
|
||||
try:
|
||||
worldbook = WorldBook.from_sillytavern_json(str(worldbook_path))
|
||||
if not worldbook.remove_entry(entry_uid):
|
||||
raise HTTPException(status_code=404, detail="Entry not found")
|
||||
worldbook.to_sillytavern_json(str(worldbook_path))
|
||||
return {"message": "Entry deleted successfully"}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to delete entry: {str(e)}")
|
||||
@@ -1,4 +1,5 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, validator
|
||||
from typing import Dict, Any
|
||||
|
||||
|
||||
class PromptComponent(BaseModel):
|
||||
@@ -12,4 +13,48 @@ class PromptComponent(BaseModel):
|
||||
system_prompt: bool = Field(False, description="是否强制作为系统提示词处理")
|
||||
marker: bool = Field(False, description="是否为动态插入点占位符")
|
||||
|
||||
@validator('role')
|
||||
def validate_role(cls, v):
|
||||
"""验证角色值是否在有效范围内"""
|
||||
if 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':
|
||||
"""
|
||||
从字典创建组件实例
|
||||
|
||||
参数:
|
||||
data: 包含组件数据的字典
|
||||
|
||||
返回:
|
||||
PromptComponent: 组件实例
|
||||
"""
|
||||
return cls(**data)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from pydantic import BaseModel, Field
|
||||
import PromptComponent
|
||||
from typing import List, Dict, Any
|
||||
from pydantic import BaseModel, Field, validator
|
||||
from typing import List, Dict, Any, Optional
|
||||
from .PromptComponent import PromptComponent
|
||||
|
||||
|
||||
class AIDesignSpec(BaseModel):
|
||||
@@ -50,3 +50,167 @@ class AIDesignSpec(BaseModel):
|
||||
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
|
||||
|
||||
# ========== 组件管理方法 ==========
|
||||
|
||||
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)
|
||||
|
||||
391
backend/core/models/WorldBook.py
Normal file
391
backend/core/models/WorldBook.py
Normal file
@@ -0,0 +1,391 @@
|
||||
import json
|
||||
import os
|
||||
from typing import Dict, List, Optional, Any
|
||||
from pydantic import BaseModel, Field, validator
|
||||
from .WorldItem import WorldInfoEntry, TriggerStrategy, PositionMode
|
||||
|
||||
|
||||
class WorldBook(BaseModel):
|
||||
"""
|
||||
世界书集合模型
|
||||
管理多个世界书条目,支持导入导出 SillyTavern 格式
|
||||
"""
|
||||
# 世界书基本信息
|
||||
uid: str = Field(..., description="世界书唯一标识符")
|
||||
name: str = Field(..., description="世界书名称")
|
||||
description: str = Field("", description="世界书描述")
|
||||
|
||||
# 条目集合
|
||||
entries: List[WorldInfoEntry] = Field(
|
||||
default_factory=list,
|
||||
description="世界书条目列表"
|
||||
)
|
||||
|
||||
# 全局配置
|
||||
enabled: bool = Field(True, description="是否启用")
|
||||
scan_depth: int = Field(4, description="默认扫描深度")
|
||||
rag_threshold: float = Field(0.75, description="默认RAG相似度阈值")
|
||||
|
||||
@validator('entries')
|
||||
def validate_entries_unique_uid(cls, v):
|
||||
uids = [entry.uid for entry in v]
|
||||
if len(uids) != len(set(uids)):
|
||||
raise ValueError("条目 UID 必须唯一")
|
||||
return v
|
||||
|
||||
def add_entry(self, entry: WorldInfoEntry) -> None:
|
||||
"""
|
||||
添加世界书条目
|
||||
|
||||
Args:
|
||||
entry: 世界书条目对象
|
||||
"""
|
||||
if any(e.uid == entry.uid for e in self.entries):
|
||||
raise ValueError(f"条目 UID {entry.uid} 已存在")
|
||||
self.entries.append(entry)
|
||||
|
||||
def remove_entry(self, uid: str) -> bool:
|
||||
"""
|
||||
移除世界书条目
|
||||
|
||||
Args:
|
||||
uid: 条目 UID
|
||||
|
||||
Returns:
|
||||
bool: 是否成功移除
|
||||
"""
|
||||
original_length = len(self.entries)
|
||||
self.entries = [e for e in self.entries if e.uid != uid]
|
||||
return len(self.entries) < original_length
|
||||
|
||||
def get_entry(self, uid: str) -> Optional[WorldInfoEntry]:
|
||||
"""
|
||||
获取指定 UID 的世界书条目
|
||||
|
||||
Args:
|
||||
uid: 条目 UID
|
||||
|
||||
Returns:
|
||||
Optional[WorldInfoEntry]: 找到的条目,未找到返回 None
|
||||
"""
|
||||
for entry in self.entries:
|
||||
if entry.uid == uid:
|
||||
return entry
|
||||
return None
|
||||
|
||||
def update_entry(self, uid: str, **kwargs) -> bool:
|
||||
"""
|
||||
更新世界书条目
|
||||
|
||||
Args:
|
||||
uid: 条目 UID
|
||||
**kwargs: 要更新的字段
|
||||
|
||||
Returns:
|
||||
bool: 是否成功更新
|
||||
"""
|
||||
entry = self.get_entry(uid)
|
||||
if entry is None:
|
||||
return False
|
||||
|
||||
for key, value in kwargs.items():
|
||||
if hasattr(entry, key):
|
||||
setattr(entry, key, value)
|
||||
return True
|
||||
|
||||
def filter_by_position(self, position_mode: PositionMode, position_value: int) -> List[WorldInfoEntry]:
|
||||
"""
|
||||
根据位置筛选条目
|
||||
|
||||
Args:
|
||||
position_mode: 位置模式
|
||||
position_value: 位置值
|
||||
|
||||
Returns:
|
||||
List[WorldInfoEntry]: 筛选后的条目列表
|
||||
"""
|
||||
return [
|
||||
entry for entry in self.entries
|
||||
if entry.position_mode == position_mode and
|
||||
(entry.position_anchor == position_value or
|
||||
(position_mode == PositionMode.ABSOLUTE_DEPTH and entry.position_dx == position_value))
|
||||
]
|
||||
|
||||
def get_summary(self) -> Dict[str, Any]:
|
||||
"""
|
||||
获取世界书概要信息
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 概要信息字典
|
||||
"""
|
||||
return {
|
||||
"uid": self.uid,
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"entry_count": len(self.entries),
|
||||
"enabled": self.enabled,
|
||||
"scan_depth": self.scan_depth,
|
||||
"rag_threshold": self.rag_threshold,
|
||||
"trigger_strategies": {
|
||||
strategy.value: sum(1 for e in self.entries if e.trigger_strategy == strategy)
|
||||
for strategy in TriggerStrategy
|
||||
}
|
||||
}
|
||||
|
||||
def to_summary_dict(self) -> Dict[str, Any]:
|
||||
"""
|
||||
生成世界书摘要信息,用于列表显示
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 包含基本信息的字典
|
||||
"""
|
||||
return {
|
||||
"uid": self.uid,
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"entry_count": len(self.entries),
|
||||
"enabled": self.enabled
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict) -> 'WorldBook':
|
||||
"""
|
||||
从字典创建 WorldBook 对象,支持多种数据格式
|
||||
|
||||
Args:
|
||||
data: 包含世界书数据的字典
|
||||
|
||||
Returns:
|
||||
WorldBook: 世界书对象
|
||||
|
||||
Raises:
|
||||
ValueError: 数据格式无效
|
||||
"""
|
||||
# 处理包含 originalData 的格式
|
||||
if 'originalData' in data:
|
||||
original_data = data['originalData']
|
||||
return cls(
|
||||
uid=data.get('uid') or original_data.get('name', 'unknown'),
|
||||
name=original_data.get('name', 'Unknown'),
|
||||
description=original_data.get('description', ''),
|
||||
enabled=data.get('enabled', True),
|
||||
entries=[WorldInfoEntry(**entry) for entry in data.get('entries', {}).values()]
|
||||
)
|
||||
|
||||
# 处理标准格式
|
||||
return cls(**data)
|
||||
|
||||
def to_dict(self) -> Dict:
|
||||
"""
|
||||
将 WorldBook 转换为字典,兼容多种格式
|
||||
|
||||
Returns:
|
||||
Dict: 世界书数据字典
|
||||
"""
|
||||
return {
|
||||
'uid': self.uid,
|
||||
'name': self.name,
|
||||
'description': self.description,
|
||||
'enabled': self.enabled,
|
||||
'entries': {entry.uid: entry.dict() for entry in self.entries}
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_sillytavern_json(cls, file_path: str) -> 'WorldBook':
|
||||
"""
|
||||
从 SillyTavern 格式的 JSON 文件加载世界书
|
||||
|
||||
Args:
|
||||
file_path: JSON 文件路径
|
||||
|
||||
Returns:
|
||||
WorldBook: 世界书对象
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: 文件不存在
|
||||
ValueError: 格式不符合 SillyTavern 标准
|
||||
json.JSONDecodeError: JSON 解析错误
|
||||
"""
|
||||
if not os.path.exists(file_path):
|
||||
raise FileNotFoundError(f"世界书文件未找到: {file_path}")
|
||||
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
raw_data = json.load(f)
|
||||
|
||||
# 创建世界书对象
|
||||
world_name = raw_data.get("name", "")
|
||||
if not world_name:
|
||||
# 如果 name 字段不存在或为空,从文件名中提取
|
||||
file_name = os.path.splitext(os.path.basename(file_path))[0]
|
||||
world_name = file_name
|
||||
|
||||
# 检查是否有 originalData 字段
|
||||
if "originalData" in raw_data:
|
||||
# 使用 originalData 格式
|
||||
original_data = raw_data["originalData"]
|
||||
world_book = cls(
|
||||
uid=f"st_{os.path.basename(file_path)}_{int(os.path.getmtime(file_path))}",
|
||||
name=world_name,
|
||||
description=f"从 SillyTavern 导入: {file_path}"
|
||||
)
|
||||
|
||||
# 转换 originalData 中的条目
|
||||
for entry_data in original_data.get("entries", []):
|
||||
try:
|
||||
world_entry = WorldInfoEntry(
|
||||
uid=str(entry_data.get("id", "")),
|
||||
key=entry_data.get("keys", []),
|
||||
keysecondary=entry_data.get("secondary_keys", []),
|
||||
comment=entry_data.get("comment", ""),
|
||||
content=entry_data.get("content", ""),
|
||||
trigger_strategy=TriggerStrategy.KEYWORD,
|
||||
position_mode=PositionMode.ANCHOR if entry_data.get(
|
||||
"position") == "after_char" else PositionMode.ABSOLUTE_DEPTH,
|
||||
position_anchor=0,
|
||||
position_dx=None,
|
||||
constant=entry_data.get("constant", False),
|
||||
selective=entry_data.get("selective", True),
|
||||
order=entry_data.get("insertion_order", 100),
|
||||
probability=entry_data.get("extensions", {}).get("probability", 100),
|
||||
useProbability=entry_data.get("extensions", {}).get("useProbability", False),
|
||||
group=entry_data.get("extensions", {}).get("group", None),
|
||||
match_whole_words=entry_data.get("use_regex", False),
|
||||
use_regex=entry_data.get("use_regex", False),
|
||||
vectorized=False
|
||||
)
|
||||
world_book.add_entry(world_entry)
|
||||
except Exception as e:
|
||||
print(f"警告: 跳过条目,解析失败: {e}")
|
||||
else:
|
||||
# 使用标准 SillyTavern 格式
|
||||
entries_dict = raw_data.get("entries", {})
|
||||
if not isinstance(entries_dict, dict):
|
||||
raise ValueError("无效的世界书格式:'entries' 字段必须是一个字典。")
|
||||
|
||||
if not any(key.isdigit() for key in entries_dict.keys()):
|
||||
raise ValueError("无效的世界书格式:'entries' 中未找到条目。请确认是 SillyTavern 导出的格式。")
|
||||
|
||||
world_book = cls(
|
||||
uid=f"st_{os.path.basename(file_path)}_{int(os.path.getmtime(file_path))}",
|
||||
name=world_name,
|
||||
description=f"从 SillyTavern 导入: {file_path}"
|
||||
)
|
||||
|
||||
# 转换标准格式的条目
|
||||
for uid, entry_data in entries_dict.items():
|
||||
try:
|
||||
position = entry_data.get("position", 0)
|
||||
position_mode = PositionMode.ANCHOR if position < 6 else PositionMode.ABSOLUTE_DEPTH
|
||||
|
||||
world_entry = WorldInfoEntry(
|
||||
uid=uid,
|
||||
key=entry_data.get("key", []),
|
||||
keysecondary=entry_data.get("keysecondary", []),
|
||||
comment=entry_data.get("comment", ""),
|
||||
content=entry_data.get("content", ""),
|
||||
trigger_strategy=TriggerStrategy.KEYWORD,
|
||||
position_mode=position_mode,
|
||||
position_anchor=position if position < 6 else 0,
|
||||
position_dx=position if position >= 6 else None,
|
||||
constant=entry_data.get("constant", False),
|
||||
selective=entry_data.get("selective", True),
|
||||
order=entry_data.get("order", 100),
|
||||
probability=entry_data.get("probability", 100),
|
||||
useProbability=entry_data.get("useProbability", False),
|
||||
group=entry_data.get("group", None),
|
||||
match_whole_words=entry_data.get("matchWholeWords", False),
|
||||
use_regex=entry_data.get("useRegex", False),
|
||||
vectorized=False
|
||||
)
|
||||
world_book.add_entry(world_entry)
|
||||
except Exception as e:
|
||||
print(f"警告: 跳过条目 {uid},解析失败: {e}")
|
||||
|
||||
return world_book
|
||||
|
||||
def to_sillytavern_json(self, file_path: str) -> None:
|
||||
"""
|
||||
导出为 SillyTavern 格式的 JSON 文件
|
||||
|
||||
Args:
|
||||
file_path: 要保存的文件路径
|
||||
"""
|
||||
entries_dict = {}
|
||||
|
||||
for entry in self.entries:
|
||||
# 映射 WorldInfoEntry 到 SillyTavern 格式
|
||||
position = entry.position_anchor if entry.position_mode == PositionMode.ANCHOR else entry.position_dx
|
||||
|
||||
entries_dict[entry.uid] = {
|
||||
"uid": entry.uid,
|
||||
"key": entry.key,
|
||||
"keysecondary": entry.keysecondary,
|
||||
"comment": entry.comment,
|
||||
"content": entry.content,
|
||||
"constant": entry.constant,
|
||||
"selective": entry.selective,
|
||||
"position": position,
|
||||
"order": entry.order,
|
||||
"probability": entry.probability,
|
||||
"useProbability": entry.useProbability,
|
||||
"group": entry.group,
|
||||
"matchWholeWords": entry.match_whole_words,
|
||||
"useRegex": entry.use_regex,
|
||||
"preventRecursion": True,
|
||||
"excludeRecursion": True
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
def list_triggers_and_content(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
提取所有条目的触发关键词和内容,用于快速构建向量数据库或索引
|
||||
|
||||
Returns:
|
||||
List[Dict[str, Any]]: 包含 trigger (key) 和 content 的列表
|
||||
"""
|
||||
result = []
|
||||
for entry in self.entries:
|
||||
result.append({
|
||||
"uid": entry.uid,
|
||||
"comment": entry.comment,
|
||||
"triggers": entry.key,
|
||||
"content": entry.content,
|
||||
"position": entry.position_anchor if entry.position_mode == PositionMode.ANCHOR else entry.position_dx,
|
||||
"constant": entry.constant,
|
||||
"trigger_strategy": entry.trigger_strategy.value
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
# --- 使用示例 ---
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
# 从 SillyTavern 格式导入
|
||||
world_book = WorldBook.from_sillytavern_json('entries.json')
|
||||
|
||||
# 打印概要
|
||||
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}")
|
||||
|
||||
# 导出为 SillyTavern 格式
|
||||
world_book.to_sillytavern_json('exported_world.json')
|
||||
print("\n✅ 世界书已导出为 exported_world.json")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 错误: {e}")
|
||||
70
backend/core/models/WorldItem.py
Normal file
70
backend/core/models/WorldItem.py
Normal file
@@ -0,0 +1,70 @@
|
||||
from enum import Enum
|
||||
from typing import List, Optional
|
||||
from pydantic import BaseModel, Field, validator
|
||||
|
||||
|
||||
class TriggerStrategy(str, Enum):
|
||||
"""
|
||||
触发策略枚举
|
||||
"""
|
||||
ALL = "all" # 全部触发
|
||||
KEYWORD = "keyword" # 传统关键词匹配
|
||||
RAG = "rag" # 向量检索触发
|
||||
CONDITION = "condition" # 逻辑条件触发
|
||||
|
||||
|
||||
class PositionMode(str, Enum):
|
||||
"""
|
||||
位置模式枚举
|
||||
"""
|
||||
ANCHOR = "anchor" # 锚点模式 (0-5)
|
||||
ABSOLUTE_DEPTH = "dx" # 绝对深度模式 (Dx)
|
||||
|
||||
|
||||
class WorldInfoEntry(BaseModel):
|
||||
"""
|
||||
世界书条目模型 v2.0
|
||||
支持 RAG、条件触发及 Dx 绝对位置
|
||||
兼容 SillyTavern 格式
|
||||
"""
|
||||
# --- 核心身份 ---
|
||||
uid: str
|
||||
key: List[str] = []
|
||||
keysecondary: List[str] = []
|
||||
comment: str = ""
|
||||
content: str
|
||||
|
||||
# --- 策略配置 ---
|
||||
trigger_strategy: TriggerStrategy = TriggerStrategy.KEYWORD
|
||||
condition_expr: Optional[str] = None # 条件表达式
|
||||
rag_threshold: float = 0.75 # RAG 相似度阈值
|
||||
|
||||
# --- 位置与扫描 ---
|
||||
position_mode: PositionMode = PositionMode.ANCHOR
|
||||
position_anchor: int = 0 # 锚点值 (0-5)
|
||||
position_dx: Optional[int] = None # 绝对深度值
|
||||
scan_depth: int = 4 # 向前扫描 N 条消息
|
||||
|
||||
# --- 其他配置 ---
|
||||
constant: bool = False
|
||||
selective: bool = True
|
||||
order: int = 100
|
||||
probability: int = 100
|
||||
useProbability: bool = False
|
||||
|
||||
# 高级过滤
|
||||
group: Optional[str] = None
|
||||
match_whole_words: bool = False
|
||||
use_regex: bool = False
|
||||
vectorized: bool = False # 是否已生成向量 embedding
|
||||
|
||||
# SillyTavern 特定字段
|
||||
enabled: bool = True
|
||||
position: str = "after_char" # SillyTavern 位置字符串
|
||||
insertion_order: int = 100 # SillyTavern 插入顺序
|
||||
|
||||
@validator('position_dx')
|
||||
def validate_dx(cls, v, values):
|
||||
if values.get('position_mode') == PositionMode.ABSOLUTE_DEPTH and v is None:
|
||||
raise ValueError("当 position_mode 为 ABSOLUTE_DEPTH 时,必须指定 position_dx")
|
||||
return v
|
||||
@@ -6,211 +6,211 @@ import json
|
||||
|
||||
|
||||
class Message(BaseModel):
|
||||
"""消息类,代表JSONL文件中的一行消息内容"""
|
||||
name: str = Field(..., description="发送者名称")
|
||||
is_user: bool = Field(..., description="是否为用户消息")
|
||||
is_system: bool = Field(False, description="是否为系统消息")
|
||||
send_date: str = Field(
|
||||
default_factory=lambda: str(int(datetime.now().timestamp() * 1000)),
|
||||
description="消息发送时间戳"
|
||||
)
|
||||
floor: int = Field(0, description="对话楼层数")
|
||||
swipes: List[str] = Field(
|
||||
default_factory=list,
|
||||
description="历史版本列表。用户消息:存编辑过的不同版本。AI消息:存重roll生成的不同版本"
|
||||
)
|
||||
swipe_id: int = Field(
|
||||
0,
|
||||
description="当前指针。指示当前显示的是 swipes 数组中的第几个(从 0 开始)"
|
||||
)
|
||||
mes: str = Field(..., description="消息内容文本")
|
||||
extra: Dict[str, Any] = Field(
|
||||
default_factory=dict,
|
||||
description="额外信息,包含推理内容、API、模型等"
|
||||
)
|
||||
force_avatar: Optional[str] = Field(None, description="强制头像URL")
|
||||
variables: List[Any] = Field(default_factory=list, description="消息变量列表")
|
||||
variables_initialized: List[bool] = Field(default_factory=list, description="变量初始化状态数组")
|
||||
is_ejs_processed: List[bool] = Field(default_factory=list, description="EJS处理状态数组")
|
||||
"""消息类,代表JSONL文件中的一行消息内容"""
|
||||
name: str = Field(..., description="发送者名称")
|
||||
is_user: bool = Field(..., description="是否为用户消息")
|
||||
is_system: bool = Field(False, description="是否为系统消息")
|
||||
send_date: str = Field(
|
||||
default_factory=lambda: str(int(datetime.now().timestamp() * 1000)),
|
||||
description="消息发送时间戳"
|
||||
)
|
||||
floor: int = Field(0, description="对话楼层数")
|
||||
swipes: List[str] = Field(
|
||||
default_factory=list,
|
||||
description="历史版本列表。用户消息:存编辑过的不同版本。AI消息:存重roll生成的不同版本"
|
||||
)
|
||||
swipe_id: int = Field(
|
||||
0,
|
||||
description="当前指针。指示当前显示的是 swipes 数组中的第几个(从 0 开始)"
|
||||
)
|
||||
mes: str = Field(..., description="消息内容文本")
|
||||
extra: Dict[str, Any] = Field(
|
||||
default_factory=dict,
|
||||
description="额外信息,包含推理内容、API、模型等"
|
||||
)
|
||||
force_avatar: Optional[str] = Field(None, description="强制头像URL")
|
||||
variables: List[Any] = Field(default_factory=list, description="消息变量列表")
|
||||
variables_initialized: List[bool] = Field(default_factory=list, description="变量初始化状态数组")
|
||||
is_ejs_processed: List[bool] = Field(default_factory=list, description="EJS处理状态数组")
|
||||
|
||||
# 以下属性仅在is_user为False时有值
|
||||
api: Optional[str] = Field(None, description="使用的API提供商")
|
||||
model: Optional[str] = Field(None, description="使用的AI模型")
|
||||
reasoning: Optional[str] = Field(None, description="推理内容")
|
||||
reasoning_duration: Optional[float] = Field(None, description="推理耗时")
|
||||
reasoning_signature: Optional[str] = Field(None, description="推理签名")
|
||||
time_to_first_token: Optional[float] = Field(None, description="首Token响应时间")
|
||||
bias: Optional[float] = Field(None, description="偏差值")
|
||||
# 以下属性仅在is_user为False时有值
|
||||
api: Optional[str] = Field(None, description="使用的API提供商")
|
||||
model: Optional[str] = Field(None, description="使用的AI模型")
|
||||
reasoning: Optional[str] = Field(None, description="推理内容")
|
||||
reasoning_duration: Optional[float] = Field(None, description="推理耗时")
|
||||
reasoning_signature: Optional[str] = Field(None, description="推理签名")
|
||||
time_to_first_token: Optional[float] = Field(None, description="首Token响应时间")
|
||||
bias: Optional[float] = Field(None, description="偏差值")
|
||||
|
||||
|
||||
class ChatMetadata(BaseModel):
|
||||
"""聊天元数据类,包含整个聊天的共享属性"""
|
||||
user_name: str = Field("User", description="用户名称")
|
||||
character_name: str = Field("Assistant", description="角色名称")
|
||||
"""聊天元数据类,包含整个聊天的共享属性"""
|
||||
user_name: str = Field("User", description="用户名称")
|
||||
character_name: str = Field("Assistant", description="角色名称")
|
||||
|
||||
# 完整性校验相关
|
||||
integrity: str = Field("", description="完整性校验值")
|
||||
chat_id_hash: str = Field("", description="聊天ID哈希值")
|
||||
# 完整性校验相关
|
||||
integrity: str = Field("", description="完整性校验值")
|
||||
chat_id_hash: str = Field("", description="聊天ID哈希值")
|
||||
|
||||
# 笔记相关
|
||||
note_prompt: str = Field("", description="作者笔记提示词")
|
||||
note_interval: int = Field(0, description="笔记插入间隔数")
|
||||
note_position: int = Field(0, description="笔记插入位置")
|
||||
note_depth: int = Field(0, description="笔记插入深度")
|
||||
# 0:System,1:User,2:Assistant
|
||||
note_role: int = Field("", description="笔记使用角色类型")
|
||||
# 笔记相关
|
||||
note_prompt: str = Field("", description="作者笔记提示词")
|
||||
note_interval: int = Field(0, description="笔记插入间隔数")
|
||||
note_position: int = Field(0, description="笔记插入位置")
|
||||
note_depth: int = Field(0, description="笔记插入深度")
|
||||
# 0:System,1:User,2:Assistant
|
||||
note_role: int = Field("", description="笔记使用角色类型")
|
||||
|
||||
# 扩展信息
|
||||
extensions: Dict[str, Any] = Field(
|
||||
default_factory=dict,
|
||||
description="扩展信息,如LittleWhiteBox等"
|
||||
)
|
||||
# 世界信息
|
||||
timedWorldInfo: Dict[str, Any] = Field(
|
||||
default_factory=dict,
|
||||
description="定时世界信息"
|
||||
)
|
||||
# 变量
|
||||
variables: Dict[str, Any] = Field(
|
||||
default_factory=dict,
|
||||
description="变量字典"
|
||||
)
|
||||
# 状态标记
|
||||
tainted: bool = Field(False, description="是否被修改标记")
|
||||
lastInContextMessageId: int = Field(-1, description="最后上下文消息ID")
|
||||
# 扩展信息
|
||||
extensions: Dict[str, Any] = Field(
|
||||
default_factory=dict,
|
||||
description="扩展信息,如LittleWhiteBox等"
|
||||
)
|
||||
# 世界信息
|
||||
timedWorldInfo: Dict[str, Any] = Field(
|
||||
default_factory=dict,
|
||||
description="定时世界信息"
|
||||
)
|
||||
# 变量
|
||||
variables: Dict[str, Any] = Field(
|
||||
default_factory=dict,
|
||||
description="变量字典"
|
||||
)
|
||||
# 状态标记
|
||||
tainted: bool = Field(False, description="是否被修改标记")
|
||||
lastInContextMessageId: int = Field(-1, description="最后上下文消息ID")
|
||||
|
||||
|
||||
class ChatHistory(BaseModel):
|
||||
"""聊天文件类,包含完整的聊天记录"""
|
||||
chat_metadata: ChatMetadata = Field(..., description="聊天元数据,包含基本信息和配置")
|
||||
messages: List[Message] = Field(default_factory=list, description="消息列表")
|
||||
"""聊天文件类,包含完整的聊天记录"""
|
||||
chat_metadata: ChatMetadata = Field(..., description="聊天元数据,包含基本信息和配置")
|
||||
messages: List[Message] = Field(default_factory=list, description="消息列表")
|
||||
|
||||
class Config:
|
||||
arbitrary_types_allowed = True
|
||||
class Config:
|
||||
arbitrary_types_allowed = True
|
||||
|
||||
@classmethod # 类方法装饰器,表示这是一个类方法,可以通过类名直接调用
|
||||
def load_from_file(cls, role_name: str, chat_name: str, base_path: Path = None) -> 'ChatHistory':
|
||||
"""
|
||||
从JSONL文件加载聊天历史
|
||||
@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
|
||||
参数:
|
||||
role_name: 角色名称(文件夹名)
|
||||
chat_name: 聊天名称(文件名,不含扩展名)
|
||||
base_path: 基础路径,默认为配置中的DATA_PATH/chat
|
||||
|
||||
返回:
|
||||
ChatHistory: 加载的聊天历史对象
|
||||
返回:
|
||||
ChatHistory: 加载的聊天历史对象
|
||||
|
||||
异常:
|
||||
FileNotFoundError: 当文件不存在时抛出
|
||||
json.JSONDecodeError: 当JSON解析失败时抛出
|
||||
"""
|
||||
# 设置默认基础路径 - 如果未提供base_path,则从配置中获取默认路径
|
||||
if base_path is None:
|
||||
from backend.core.config import settings # 延迟导入配置模块
|
||||
base_path = settings.DATA_PATH / "chat" # 构建默认路径
|
||||
异常:
|
||||
FileNotFoundError: 当文件不存在时抛出
|
||||
json.JSONDecodeError: 当JSON解析失败时抛出
|
||||
"""
|
||||
# 设置默认基础路径 - 如果未提供base_path,则从配置中获取默认路径
|
||||
if base_path is None:
|
||||
from backend.core.config import settings # 延迟导入配置模块
|
||||
base_path = settings.DATA_PATH / "chat" # 构建默认路径
|
||||
|
||||
# 构建文件路径
|
||||
file_path = base_path / role_name / f"{chat_name}.jsonl"
|
||||
# 构建文件路径
|
||||
file_path = base_path / role_name / f"{chat_name}.jsonl"
|
||||
|
||||
# 检查文件是否存在
|
||||
if not file_path.exists():
|
||||
raise FileNotFoundError(f"聊天文件不存在: {file_path}")
|
||||
# 检查文件是否存在
|
||||
if not file_path.exists():
|
||||
raise FileNotFoundError(f"聊天文件不存在: {file_path}")
|
||||
|
||||
# 初始化结果数据
|
||||
messages = []
|
||||
metadata = None
|
||||
# 初始化结果数据
|
||||
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())
|
||||
# 读取文件内容
|
||||
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
|
||||
# 第一行是元数据
|
||||
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
|
||||
)
|
||||
# 创建并返回ChatHistory对象
|
||||
return cls(
|
||||
chat_metadata=metadata or ChatMetadata(),
|
||||
messages=messages
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def load_from_jsonl(cls, file_path: Path) -> 'ChatHistory':
|
||||
"""
|
||||
从JSONL文件加载聊天历史
|
||||
@classmethod
|
||||
def load_from_jsonl(cls, file_path: Path) -> 'ChatHistory':
|
||||
"""
|
||||
从JSONL文件加载聊天历史
|
||||
|
||||
参数:
|
||||
file_path: JSONL文件路径
|
||||
参数:
|
||||
file_path: JSONL文件路径
|
||||
|
||||
返回:
|
||||
ChatHistory: 加载的聊天历史对象
|
||||
返回:
|
||||
ChatHistory: 加载的聊天历史对象
|
||||
|
||||
异常:
|
||||
FileNotFoundError: 当文件不存在时抛出
|
||||
json.JSONDecodeError: 当JSON解析失败时抛出
|
||||
"""
|
||||
# 检查文件是否存在
|
||||
if not file_path.exists():
|
||||
raise FileNotFoundError(f"聊天文件不存在: {file_path}")
|
||||
异常:
|
||||
FileNotFoundError: 当文件不存在时抛出
|
||||
json.JSONDecodeError: 当JSON解析失败时抛出
|
||||
"""
|
||||
# 检查文件是否存在
|
||||
if not file_path.exists():
|
||||
raise FileNotFoundError(f"聊天文件不存在: {file_path}")
|
||||
|
||||
# 初始化结果数据
|
||||
messages = []
|
||||
metadata = None
|
||||
# 初始化结果数据
|
||||
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())
|
||||
# 读取文件内容
|
||||
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', {})
|
||||
# 第一行是元数据
|
||||
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)
|
||||
# 如果是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)
|
||||
# 创建Message实例
|
||||
message = Message(**line_data)
|
||||
# 将剩余的extra数据保存回extra字段
|
||||
message.extra = extra_data
|
||||
messages.append(message)
|
||||
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
# 创建并返回ChatHistory对象
|
||||
return cls(
|
||||
chat_metadata=metadata or ChatMetadata(),
|
||||
messages=messages
|
||||
)
|
||||
# 创建并返回ChatHistory对象
|
||||
return cls(
|
||||
chat_metadata=metadata or ChatMetadata(),
|
||||
messages=messages
|
||||
)
|
||||
|
||||
def to_chatbox_format(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
def to_chatbox_format(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
将聊天历史转换为适合前端chatbox显示的格式
|
||||
|
||||
返回:
|
||||
@@ -224,26 +224,53 @@ class ChatHistory(BaseModel):
|
||||
"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]
|
||||
# 创建消息字典列表
|
||||
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)
|
||||
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"])
|
||||
# 按floor排序
|
||||
messages_list.sort(key=lambda x: x["floor"])
|
||||
|
||||
return messages_list
|
||||
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 = Path("data")
|
||||
|
||||
# 构建文件路径
|
||||
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')
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
const useWorldBookStore = create((set, get) => ({
|
||||
// 世界书列表
|
||||
worldBooks: [],
|
||||
// 当前选中的世界书(用于编辑)
|
||||
selectedWorldBook: null,
|
||||
// 全局激活的世界书列表(在槽位中显示)
|
||||
globalWorldBooks: [],
|
||||
// 是否显示编辑面板
|
||||
showEditPanel: false,
|
||||
// 当前编辑的条目
|
||||
editingEntry: null,
|
||||
// 加载状态
|
||||
isLoading: false,
|
||||
// 选中世界书的加载状态
|
||||
isSelecting: false,
|
||||
// 错误信息
|
||||
error: null,
|
||||
// 是否显示世界书下拉框
|
||||
showWorldBookDropdown: false,
|
||||
// 是否显示添加世界书下拉框
|
||||
showAddWorldBookDropdown: false,
|
||||
|
||||
// 从后端获取世界书列表
|
||||
fetchWorldBooks: async () => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const response = await fetch('/api/worldbooks');
|
||||
if (!response.ok) {
|
||||
throw new Error('获取世界书列表失败');
|
||||
}
|
||||
const data = await response.json();
|
||||
set({
|
||||
worldBooks: data.worldbooks,
|
||||
globalWorldBooks: data.worldbooks.filter(book => book.enabled),
|
||||
isLoading: false
|
||||
});
|
||||
} catch (error) {
|
||||
set({ error: error.message, isLoading: false });
|
||||
console.error('获取世界书列表失败:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// 切换世界书选择下拉框显示
|
||||
toggleWorldBookDropdown: () => set((state) => ({
|
||||
showWorldBookDropdown: !state.showWorldBookDropdown
|
||||
})),
|
||||
|
||||
// 切换添加世界书下拉框显示
|
||||
toggleAddWorldBookDropdown: () => set((state) => ({
|
||||
showAddWorldBookDropdown: !state.showAddWorldBookDropdown
|
||||
})),
|
||||
|
||||
// 添加世界书到全局槽位
|
||||
addGlobalWorldBook: async (uid) => {
|
||||
try {
|
||||
const response = await fetch(`/api/worldbooks/${uid}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
enabled: true
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('添加全局世界书失败');
|
||||
}
|
||||
|
||||
// 更新本地状态
|
||||
set((state) => ({
|
||||
worldBooks: state.worldBooks.map(b =>
|
||||
b.uid === uid ? { ...b, enabled: true } : b
|
||||
),
|
||||
globalWorldBooks: [...state.globalWorldBooks, state.worldBooks.find(b => b.uid === uid)]
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('添加全局世界书失败:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// 从全局槽位移除世界书
|
||||
removeGlobalWorldBook: async (uid) => {
|
||||
try {
|
||||
const response = await fetch(`/api/worldbooks/${uid}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
enabled: false
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('移除全局世界书失败');
|
||||
}
|
||||
|
||||
// 更新本地状态
|
||||
set((state) => ({
|
||||
worldBooks: state.worldBooks.map(b =>
|
||||
b.uid === uid ? { ...b, enabled: false } : b
|
||||
),
|
||||
globalWorldBooks: state.globalWorldBooks.filter(b => b.uid !== uid)
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('移除全局世界书失败:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// 创建新世界书
|
||||
createWorldBook: async (name, description) => {
|
||||
try {
|
||||
const response = await fetch('/api/worldbooks', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
description: description || '',
|
||||
enabled: false // 新建的世界书默认不全局激活
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('创建世界书失败');
|
||||
}
|
||||
|
||||
// 重新获取世界书列表
|
||||
await get().fetchWorldBooks();
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('创建世界书失败:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// 删除世界书
|
||||
deleteWorldBook: async (id) => {
|
||||
try {
|
||||
const response = await fetch(`/api/worldbooks/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('删除世界书失败');
|
||||
}
|
||||
|
||||
// 更新状态
|
||||
set((state) => ({
|
||||
worldBooks: state.worldBooks.filter(book => book.uid !== id),
|
||||
selectedWorldBook: state.selectedWorldBook?.uid === id ? null : state.selectedWorldBook,
|
||||
globalWorldBooks: state.globalWorldBooks.filter(book => book.uid !== id)
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('删除世界书失败:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// 选择世界书(用于编辑)
|
||||
selectWorldBook: async (id) => {
|
||||
set({ isSelecting: true, error: null });
|
||||
try {
|
||||
const response = await fetch(`/api/worldbooks/${id}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('获取世界书详情失败');
|
||||
}
|
||||
const data = await response.json();
|
||||
set({
|
||||
selectedWorldBook: data,
|
||||
isSelecting: false
|
||||
});
|
||||
} catch (error) {
|
||||
set({ error: error.message, isSelecting: false });
|
||||
console.error('获取世界书详情失败:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// 添加条目
|
||||
addEntry: async (entry) => {
|
||||
const state = get();
|
||||
if (!state.selectedWorldBook) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/worldbooks/${state.selectedWorldBook.uid}/entries`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(entry),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('添加条目失败');
|
||||
}
|
||||
|
||||
// 重新获取选中的世界书
|
||||
await get().selectWorldBook(state.selectedWorldBook.uid);
|
||||
} catch (error) {
|
||||
console.error('添加条目失败:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// 删除条目
|
||||
deleteEntry: async (entryId) => {
|
||||
const state = get();
|
||||
if (!state.selectedWorldBook) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/worldbooks/${state.selectedWorldBook.uid}/entries/${entryId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('删除条目失败');
|
||||
}
|
||||
|
||||
// 重新获取选中的世界书
|
||||
await get().selectWorldBook(state.selectedWorldBook.uid);
|
||||
} catch (error) {
|
||||
console.error('删除条目失败:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// 更新条目
|
||||
updateEntry: async (entryId, updatedEntry) => {
|
||||
const state = get();
|
||||
if (!state.selectedWorldBook) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/worldbooks/${state.selectedWorldBook.uid}/entries/${entryId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(updatedEntry),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('更新条目失败');
|
||||
}
|
||||
|
||||
// 重新获取选中的世界书
|
||||
await get().selectWorldBook(state.selectedWorldBook.uid);
|
||||
} catch (error) {
|
||||
console.error('更新条目失败:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// 切换编辑面板显示
|
||||
toggleEditPanel: (show, entry = null) => set({
|
||||
showEditPanel: show !== undefined ? show : !get().showEditPanel,
|
||||
editingEntry: entry
|
||||
})
|
||||
}));
|
||||
|
||||
export default useWorldBookStore;
|
||||
@@ -1,11 +1,291 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import '../tabcss/WorldBook.css';
|
||||
import useWorldBookStore from '../../../Store/Slices/LeftTabsSlices/WorldBookSlice';
|
||||
|
||||
const WorldBook = () => {
|
||||
const {
|
||||
worldBooks,
|
||||
selectedWorldBook,
|
||||
showEditPanel,
|
||||
editingEntry,
|
||||
isLoading,
|
||||
isSelecting,
|
||||
error,
|
||||
showWorldBookDropdown,
|
||||
showAddWorldBookDropdown,
|
||||
globalWorldBooks,
|
||||
fetchWorldBooks,
|
||||
toggleWorldBookDropdown,
|
||||
toggleAddWorldBookDropdown,
|
||||
addGlobalWorldBook,
|
||||
removeGlobalWorldBook,
|
||||
createWorldBook,
|
||||
deleteWorldBook,
|
||||
selectWorldBook,
|
||||
addEntry,
|
||||
deleteEntry,
|
||||
updateEntry,
|
||||
toggleEditPanel,
|
||||
} = useWorldBookStore();
|
||||
|
||||
const [newEntry, setNewEntry] = useState({
|
||||
name: '',
|
||||
content: '',
|
||||
enabled: true,
|
||||
triggerStrategy: 'keyword',
|
||||
insertPosition: 'after',
|
||||
order: 0,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchWorldBooks();
|
||||
}, [fetchWorldBooks]);
|
||||
|
||||
const handleCreateWorldBook = async () => {
|
||||
const name = prompt('请输入世界书名称:');
|
||||
if (name) {
|
||||
await createWorldBook(name);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddEntry = async () => {
|
||||
if (!selectedWorldBook) return;
|
||||
await addEntry(newEntry);
|
||||
setNewEntry({
|
||||
name: '',
|
||||
content: '',
|
||||
enabled: true,
|
||||
triggerStrategy: 'keyword',
|
||||
insertPosition: 'after',
|
||||
order: 0,
|
||||
});
|
||||
};
|
||||
|
||||
const handleEntryClick = (entry) => {
|
||||
toggleEditPanel(true, entry);
|
||||
};
|
||||
|
||||
const handleEntryUpdate = async (field, value) => {
|
||||
if (!editingEntry) return;
|
||||
const updatedEntry = { ...editingEntry, [field]: value };
|
||||
await updateEntry(editingEntry.uid, updatedEntry);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="worldbook-content">
|
||||
<h2>世界书</h2>
|
||||
{/* 在这里实现世界书的具体内容 */}
|
||||
{/* 全局世界书槽位 */}
|
||||
<div className="global-worldbooks-slot">
|
||||
<div className="global-books-header">
|
||||
<span>全局世界书</span>
|
||||
<div className="dropdown">
|
||||
<button className="add-global-book-btn" onClick={toggleAddWorldBookDropdown}>
|
||||
+ 添加
|
||||
</button>
|
||||
{showAddWorldBookDropdown && (
|
||||
<div className="dropdown-menu">
|
||||
<div className="dropdown-item" onClick={handleCreateWorldBook}>
|
||||
新建世界书
|
||||
</div>
|
||||
{worldBooks
|
||||
.filter(book => !globalWorldBooks.find(gb => gb.uid === book.uid))
|
||||
.map(book => (
|
||||
<div
|
||||
key={book.uid}
|
||||
className="dropdown-item"
|
||||
onClick={() => {
|
||||
addGlobalWorldBook(book.uid);
|
||||
toggleAddWorldBookDropdown(false);
|
||||
}}
|
||||
>
|
||||
{book.name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="global-books-list">
|
||||
{globalWorldBooks.map(book => (
|
||||
<div
|
||||
key={book.uid}
|
||||
className={`global-book-tag ${selectedWorldBook?.uid === book.uid ? 'active' : ''}`}
|
||||
onClick={() => selectWorldBook(book.uid)}
|
||||
>
|
||||
{book.name}
|
||||
{selectedWorldBook?.uid === book.uid && (
|
||||
<span
|
||||
className="remove-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeGlobalWorldBook(book.uid);
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 世界书选择区域 */}
|
||||
<div className="worldbook-selector">
|
||||
<div className="dropdown" style={{ flex: 1 }}>
|
||||
<button className="dropdown-btn" onClick={toggleWorldBookDropdown}>
|
||||
{selectedWorldBook ? selectedWorldBook.name : '选择世界书'}
|
||||
<span>▼</span>
|
||||
</button>
|
||||
{showWorldBookDropdown && (
|
||||
<div className="dropdown-menu">
|
||||
{worldBooks.map(book => (
|
||||
<div
|
||||
key={book.uid}
|
||||
className={`dropdown-item ${selectedWorldBook?.uid === book.uid ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
selectWorldBook(book.uid);
|
||||
toggleWorldBookDropdown(false);
|
||||
}}
|
||||
>
|
||||
{book.name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{selectedWorldBook && (
|
||||
<button
|
||||
className="btn btn-danger"
|
||||
onClick={() => deleteWorldBook(selectedWorldBook.uid)}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 条目列表区域 */}
|
||||
{isLoading ? (
|
||||
<div className="loading">加载中...</div>
|
||||
) : error ? (
|
||||
<div className="error">{error}</div>
|
||||
) : selectedWorldBook ? (
|
||||
<div className="entries-container">
|
||||
{isSelecting ? (
|
||||
<div className="loading">加载条目中...</div>
|
||||
) : selectedWorldBook.entries && selectedWorldBook.entries.length > 0 ? (
|
||||
selectedWorldBook.entries.map(entry => (
|
||||
<div
|
||||
key={entry.uid}
|
||||
className={`entry-item ${editingEntry?.uid === entry.uid ? 'active' : ''}`}
|
||||
onClick={() => handleEntryClick(entry)}
|
||||
>
|
||||
<div className="entry-header">
|
||||
<span className="entry-name">{entry.name}</span>
|
||||
<span className={`entry-status ${entry.enabled ? 'enabled' : ''}`}>
|
||||
{entry.enabled ? '启用' : '禁用'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="entry-meta">
|
||||
<span>策略: {entry.triggerStrategy}</span>
|
||||
<span>位置: {entry.insertPosition}</span>
|
||||
<span>顺序: {entry.order}</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="loading">暂无条目</div>
|
||||
)}
|
||||
<button className="btn btn-primary" onClick={handleAddEntry}>
|
||||
+ 添加条目
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="loading">请选择一个世界书</div>
|
||||
)}
|
||||
|
||||
{/* 编辑面板 */}
|
||||
{showEditPanel && editingEntry && (
|
||||
<div className={`edit-panel ${showEditPanel ? 'open' : ''}`}>
|
||||
<div className="edit-panel-header">
|
||||
<h2>编辑条目</h2>
|
||||
<button className="close-btn" onClick={() => toggleEditPanel(false)}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">名称</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
value={editingEntry.name}
|
||||
onChange={(e) => handleEntryUpdate('name', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">内容</label>
|
||||
<textarea
|
||||
className="form-textarea"
|
||||
value={editingEntry.content}
|
||||
onChange={(e) => handleEntryUpdate('content', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editingEntry.enabled}
|
||||
onChange={(e) => handleEntryUpdate('enabled', e.target.checked)}
|
||||
/>
|
||||
启用此条目
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">触发策略</label>
|
||||
<select
|
||||
className="form-select"
|
||||
value={editingEntry.triggerStrategy}
|
||||
onChange={(e) => handleEntryUpdate('triggerStrategy', e.target.value)}
|
||||
>
|
||||
<option value="persistent">持久</option>
|
||||
<option value="keyword">关键词</option>
|
||||
<option value="rag">RAG</option>
|
||||
<option value="calculation">运算</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">插入位置</label>
|
||||
<select
|
||||
className="form-select"
|
||||
value={editingEntry.insertPosition}
|
||||
onChange={(e) => handleEntryUpdate('insertPosition', e.target.value)}
|
||||
>
|
||||
<option value="before">之前</option>
|
||||
<option value="after">之后</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">顺序</label>
|
||||
<input
|
||||
type="number"
|
||||
className="form-input"
|
||||
value={editingEntry.order}
|
||||
onChange={(e) => handleEntryUpdate('order', parseInt(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="btn btn-danger"
|
||||
onClick={() => deleteEntry(editingEntry.uid)}
|
||||
>
|
||||
删除条目
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,646 +1,366 @@
|
||||
/* ==================== 预设页区域 ==================== */
|
||||
|
||||
.preset-panel {
|
||||
.worldbook-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 12px;
|
||||
background-color: #f9f9f9;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
gap: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.preset-header {
|
||||
/* 全局世界书槽位 */
|
||||
.global-worldbooks-slot {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.global-books-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
margin-bottom: 4px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.global-books-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
min-height: 28px;
|
||||
}
|
||||
|
||||
.global-book-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
background: rgba(74, 108, 247, 0.15);
|
||||
border: 1px solid rgba(74, 108, 247, 0.3);
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
color: #333;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.global-book-tag:hover {
|
||||
background: rgba(74, 108, 247, 0.25);
|
||||
border-color: rgba(74, 108, 247, 0.5);
|
||||
}
|
||||
|
||||
.global-book-tag.active {
|
||||
background: rgba(74, 108, 247, 0.3);
|
||||
border-color: rgba(74, 108, 247, 0.6);
|
||||
box-shadow: 0 0 8px rgba(74, 108, 247, 0.2);
|
||||
}
|
||||
|
||||
.global-book-tag .remove-btn {
|
||||
opacity: 0.8;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s ease;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.global-book-tag .remove-btn:hover {
|
||||
opacity: 1;
|
||||
color: #e74c3c;
|
||||
}
|
||||
|
||||
.add-global-book-btn {
|
||||
padding: 4px 8px;
|
||||
background: rgba(74, 108, 247, 0.2);
|
||||
border: 1px solid rgba(74, 108, 247, 0.3);
|
||||
border-radius: 4px;
|
||||
color: #333;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
transition: all 0.15s ease;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.add-global-book-btn:hover {
|
||||
background: rgba(74, 108, 247, 0.3);
|
||||
border-color: rgba(74, 108, 247, 0.5);
|
||||
}
|
||||
|
||||
/* 下拉菜单 */
|
||||
.dropdown {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.dropdown-btn {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 4px;
|
||||
color: #333;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
transition: all 0.15s ease;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dropdown-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: white;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 4px;
|
||||
margin-top: 4px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
z-index: 1000;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
padding: 8px 10px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dropdown-item:hover {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.dropdown-item.active {
|
||||
background: rgba(74, 108, 247, 0.15);
|
||||
color: #4a6cf7;
|
||||
}
|
||||
|
||||
/* 世界书选择区域 */
|
||||
.worldbook-selector {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* 条目列表区域 */
|
||||
.entries-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.entry-item {
|
||||
padding: 10px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.entry-item:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.entry-item.active {
|
||||
background: rgba(74, 108, 247, 0.1);
|
||||
border-color: rgba(74, 108, 247, 0.3);
|
||||
}
|
||||
|
||||
.entry-name {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.entry-status {
|
||||
font-size: 11px;
|
||||
color: #777;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.entry-status.enabled {
|
||||
color: #4a90e2;
|
||||
background: rgba(74, 144, 226, 0.1);
|
||||
}
|
||||
|
||||
.entry-meta {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
font-size: 11px;
|
||||
color: #777;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 编辑面板 */
|
||||
.edit-panel {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 300px;
|
||||
background: white;
|
||||
z-index: 1000;
|
||||
padding: 16px;
|
||||
overflow-y: auto;
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.2s ease-out;
|
||||
border-left: 1px solid #e0e0e0;
|
||||
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: 16px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid #eaeaea;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.preset-select-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.preset-label {
|
||||
margin-right: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.preset-select {
|
||||
flex: 1;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 4px;
|
||||
background-color: white;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.preset-select:focus {
|
||||
outline: none;
|
||||
border-color: #4a6cf7;
|
||||
}
|
||||
|
||||
.preset-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.preset-action-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
padding: 6px;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.preset-action-btn:hover {
|
||||
background-color: #e8e8e8;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.preset-save-dialog,
|
||||
.preset-edit-dialog,
|
||||
.preset-import-dialog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 12px;
|
||||
background-color: white;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
margin-bottom: 16px;
|
||||
z-index: 100;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.preset-save-dialog input,
|
||||
.preset-edit-dialog input {
|
||||
padding: 8px;
|
||||
margin-bottom: 10px;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.preset-save-dialog input:focus,
|
||||
.preset-edit-dialog input:focus {
|
||||
outline: none;
|
||||
border-color: #4a6cf7;
|
||||
}
|
||||
|
||||
.preset-import-dialog textarea {
|
||||
padding: 8px;
|
||||
margin-bottom: 10px;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 4px;
|
||||
resize: vertical;
|
||||
font-family: monospace;
|
||||
font-size: 13px;
|
||||
min-height: 100px;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.preset-import-dialog textarea:focus {
|
||||
outline: none;
|
||||
border-color: #4a6cf7;
|
||||
}
|
||||
|
||||
.dialog-buttons {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.dialog-buttons button {
|
||||
margin-left: 8px;
|
||||
padding: 6px 12px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.dialog-buttons button:first-child {
|
||||
background-color: #4a6cf7;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.dialog-buttons button:first-child:hover {
|
||||
background-color: #3a5ce5;
|
||||
}
|
||||
|
||||
.dialog-buttons button:last-child {
|
||||
background-color: #f0f0f0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.dialog-buttons button:last-child:hover {
|
||||
background-color: #e0e0e0;
|
||||
}
|
||||
|
||||
.preset-parameters-container {
|
||||
border: 1px solid #eaeaea;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.parameters-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
background-color: #f5f5f5;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.parameters-header:hover {
|
||||
background-color: #eaeaea;
|
||||
}
|
||||
|
||||
.expand-icon {
|
||||
transition: transform 0.3s ease;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.expand-icon.expanded {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.preset-parameters {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
animation: fadeIn 0.3s ease-in;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.parameter-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.parameter-label {
|
||||
width: 120px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #555;
|
||||
cursor: help;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.parameter-slider {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: #e0e0e0;
|
||||
outline: none;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
.parameter-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: #4a6cf7;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.parameter-slider::-moz-range-thumb {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: #4a6cf7;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.parameter-number {
|
||||
width: 60px;
|
||||
padding: 4px 6px;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
transition: border-color 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.parameter-number:focus {
|
||||
outline: none;
|
||||
border-color: #4a6cf7;
|
||||
}
|
||||
|
||||
.parameter-input {
|
||||
flex: 1;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.parameter-input:focus {
|
||||
outline: none;
|
||||
border-color: #4a6cf7;
|
||||
}
|
||||
|
||||
.parameter-toggles {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.toggle-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.toggle-label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #555;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.toggle-checkbox {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: pointer;
|
||||
accent-color: #4a6cf7;
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
position: fixed;
|
||||
padding: 6px 10px;
|
||||
background-color: #333;
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
max-width: 250px;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* 预设组件列表样式 */
|
||||
.preset-components-section {
|
||||
margin-top: 20px;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
padding-top: 20px;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.component-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0; /* 移除底部边距 */
|
||||
}
|
||||
|
||||
.component-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.token-count {
|
||||
font-size: 11px;
|
||||
color: #777;
|
||||
margin: 0 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.add-component-btn {
|
||||
background-color: #4a90e2;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 5px 10px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.components-list {
|
||||
width: 100%;
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
.prompt-component-item {
|
||||
background-color: #f9f9f9;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 8px; /* 减小间距 */
|
||||
padding: 6px 8px; /* 减小内边距 */
|
||||
transition: background-color 0.2s;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.prompt-component-item.dragging {
|
||||
background-color: #e9e9e9;
|
||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.prompt-component-item.disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.prompt-component-item.marker {
|
||||
border-left: 3px solid #4a90e2;
|
||||
}
|
||||
|
||||
.component-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 4px; /* 减小底部边距 */
|
||||
}
|
||||
|
||||
.component-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px; /* 减小间距 */
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
cursor: grab;
|
||||
color: #999;
|
||||
font-size: 14px; /* 减小字体 */
|
||||
padding: 0 2px; /* 减小内边距 */
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.drag-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
width: 18px; /* 减小尺寸 */
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid #ccc;
|
||||
background-color: white;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 10px; /* 减小字体 */
|
||||
color: #4a90e2;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toggle-btn.enabled {
|
||||
background-color: #4a90e2;
|
||||
color: white;
|
||||
border-color: #4a90e2;
|
||||
}
|
||||
|
||||
.component-name {
|
||||
font-weight: 500;
|
||||
font-size: 13px; /* 减小字体 */
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.component-marker-badge {
|
||||
background-color: #4a90e2;
|
||||
color: white;
|
||||
font-size: 10px; /* 减小字体 */
|
||||
padding: 1px 4px; /* 减小内边距 */
|
||||
border-radius: 8px;
|
||||
margin-left: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.component-actions {
|
||||
display: flex;
|
||||
gap: 4px; /* 减小间距 */
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.view-btn, .edit-btn, .delete-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 11px; /* 减小字体 */
|
||||
padding: 2px 4px; /* 减小内边距 */
|
||||
border-radius: 3px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.view-btn {
|
||||
color: #5c6bc0;
|
||||
}
|
||||
|
||||
.view-btn:hover {
|
||||
background-color: rgba(92, 107, 192, 0.1);
|
||||
}
|
||||
|
||||
.edit-btn {
|
||||
color: #4a90e2;
|
||||
}
|
||||
|
||||
.edit-btn:hover {
|
||||
background-color: rgba(74, 144, 226, 0.1);
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
color: #e74c3c;
|
||||
}
|
||||
|
||||
.delete-btn:hover {
|
||||
background-color: rgba(231, 76, 60, 0.1);
|
||||
}
|
||||
|
||||
.component-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 2px; /* 减小顶部边距 */
|
||||
font-size: 11px; /* 减小字体 */
|
||||
color: #777;
|
||||
}
|
||||
|
||||
/* 组件编辑/查看对话框 */
|
||||
.component-edit-dialog {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 80%;
|
||||
max-width: 800px;
|
||||
max-height: 80vh;
|
||||
background-color: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.dialog-header h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
.edit-panel-header h2 {
|
||||
font-size: 16px;
|
||||
color: #333;
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
color: #999;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
transition: background-color 0.2s;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background-color: #f0f0f0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.dialog-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
.form-group {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.component-textarea {
|
||||
.form-label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-size: 13px;
|
||||
color: #555;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-input,
|
||||
.form-textarea,
|
||||
.form-select {
|
||||
width: 100%;
|
||||
border: 1px solid #ddd;
|
||||
padding: 8px 10px;
|
||||
background: white;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 4px;
|
||||
padding: 12px;
|
||||
font-family: monospace;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
resize: none;
|
||||
box-sizing: border-box;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.component-textarea:read-only {
|
||||
background-color: #f9f9f9;
|
||||
color: #333;
|
||||
font-size: 13px;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
.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: 100px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.form-checkbox {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.token-count {
|
||||
font-size: 12px;
|
||||
color: #777;
|
||||
}
|
||||
|
||||
.dialog-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: #555;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dialog-buttons button {
|
||||
padding: 6px 12px;
|
||||
.form-checkbox input {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 按钮样式 */
|
||||
.btn {
|
||||
padding: 8px 12px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: background-color 0.2s;
|
||||
font-size: 13px;
|
||||
transition: all 0.15s ease;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dialog-buttons button:first-child {
|
||||
background-color: #4a6cf7;
|
||||
.btn-primary {
|
||||
background: #4a6cf7;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.dialog-buttons button:first-child:hover {
|
||||
background-color: #3a5ce5;
|
||||
.btn-primary:hover {
|
||||
background: #3a5ce5;
|
||||
}
|
||||
|
||||
.dialog-buttons button:last-child {
|
||||
background-color: #f0f0f0;
|
||||
color: #333;
|
||||
.btn-danger {
|
||||
background: #e74c3c;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.dialog-buttons button:last-child:hover {
|
||||
background-color: #e0e0e0;
|
||||
.btn-danger:hover {
|
||||
background: #c0392b;
|
||||
}
|
||||
|
||||
/* 拖拽相关样式 */
|
||||
.drag-indicator {
|
||||
height: 4px;
|
||||
background-color: transparent;
|
||||
border-radius: 2px;
|
||||
margin: 4px 0;
|
||||
transition: all 0.2s ease;
|
||||
opacity: 0;
|
||||
/* 加载和错误状态 */
|
||||
.loading,
|
||||
.error {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.drag-indicator.visible {
|
||||
background-color: #4a90e2;
|
||||
height: 4px;
|
||||
opacity: 1;
|
||||
box-shadow: 0 0 5px rgba(74, 144, 226, 0.5);
|
||||
.loading {
|
||||
color: #777;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #e74c3c;
|
||||
background: rgba(231, 76, 60, 0.1);
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,407 @@
|
||||
.worldbook-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: 12px;
|
||||
gap: 12px;
|
||||
overflow: hidden;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* 全局世界书槽位 */
|
||||
.global-worldbooks-slot {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.global-books-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
margin-bottom: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.global-books-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
min-height: 28px;
|
||||
}
|
||||
|
||||
.global-book-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
background: rgba(100, 149, 237, 0.25);
|
||||
border: 1px solid rgba(100, 149, 237, 0.5);
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 1);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.global-book-tag:hover {
|
||||
background: rgba(100, 149, 237, 0.4);
|
||||
border-color: rgba(100, 149, 237, 0.7);
|
||||
}
|
||||
|
||||
.global-book-tag.active {
|
||||
background: rgba(100, 149, 237, 0.5);
|
||||
border-color: rgba(100, 149, 237, 0.8);
|
||||
box-shadow: 0 0 8px rgba(100, 149, 237, 0.3);
|
||||
}
|
||||
|
||||
.global-book-tag .remove-btn {
|
||||
opacity: 0.9;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s ease;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.global-book-tag .remove-btn:hover {
|
||||
opacity: 1;
|
||||
color: rgba(255, 107, 107, 1);
|
||||
}
|
||||
|
||||
.add-global-book-btn {
|
||||
padding: 4px 8px;
|
||||
background: rgba(100, 149, 237, 0.3);
|
||||
border: 1px solid rgba(100, 149, 237, 0.5);
|
||||
border-radius: 4px;
|
||||
color: rgba(255, 255, 255, 1);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
transition: all 0.15s ease;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.add-global-book-btn:hover {
|
||||
background: rgba(100, 149, 237, 0.5);
|
||||
border-color: rgba(100, 149, 237, 0.8);
|
||||
}
|
||||
|
||||
/* 下拉菜单 */
|
||||
.dropdown {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.dropdown-btn {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 4px;
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
transition: all 0.15s ease;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dropdown-btn:hover {
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: rgba(10, 10, 10, 0.98);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 4px;
|
||||
margin-top: 4px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
z-index: 1000;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
padding: 8px 10px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dropdown-item:hover {
|
||||
background: rgba(100, 149, 237, 0.3);
|
||||
}
|
||||
|
||||
.dropdown-item.active {
|
||||
background: rgba(100, 149, 237, 0.4);
|
||||
color: rgba(255, 255, 255, 1);
|
||||
}
|
||||
|
||||
/* 世界书选择区域 */
|
||||
.worldbook-selector {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* 条目列表区域 */
|
||||
.entries-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.entry-item {
|
||||
padding: 10px;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.entry-item:hover {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-color: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.entry-item.active {
|
||||
background: rgba(100, 149, 237, 0.2);
|
||||
border-color: rgba(100, 149, 237, 0.5);
|
||||
}
|
||||
|
||||
.entry-name {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
.entry-status {
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.entry-status.enabled {
|
||||
color: rgba(100, 255, 149, 1);
|
||||
background: rgba(100, 255, 149, 0.2);
|
||||
}
|
||||
|
||||
.entry-meta {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 编辑面板 */
|
||||
.edit-panel {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 300px;
|
||||
background: rgba(20, 20, 20, 0.98);
|
||||
z-index: 1000;
|
||||
padding: 16px;
|
||||
overflow-y: auto;
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.2s ease-out;
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.edit-panel.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.edit-panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.edit-panel-header h2 {
|
||||
font-size: 16px;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.form-input,
|
||||
.form-textarea,
|
||||
.form-select {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 4px;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
font-size: 13px;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.form-input:focus,
|
||||
.form-textarea:focus,
|
||||
.form-select:focus {
|
||||
outline: none;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(100, 149, 237, 0.5);
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
min-height: 100px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.form-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.form-checkbox input {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 按钮样式 */
|
||||
.btn {
|
||||
padding: 8px 12px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: rgba(100, 149, 237, 0.3);
|
||||
border: 1px solid rgba(100, 149, 237, 0.5);
|
||||
color: rgba(255, 255, 255, 1);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: rgba(100, 149, 237, 0.5);
|
||||
border-color: rgba(100, 149, 237, 0.8);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: rgba(255, 107, 107, 0.3);
|
||||
border: 1px solid rgba(255, 107, 107, 0.5);
|
||||
color: rgba(255, 255, 255, 1);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: rgba(255, 107, 107, 0.5);
|
||||
border-color: rgba(255, 107, 107, 0.8);
|
||||
}
|
||||
|
||||
/* 加载和错误状态 */
|
||||
.loading,
|
||||
.error {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: rgba(255, 107, 107, 1);
|
||||
background: rgba(255, 107, 107, 0.2);
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-input,
|
||||
.form-textarea,
|
||||
.form-select {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 4px;
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
font-size: 13px;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.form-input:focus,
|
||||
.form-textarea:focus,
|
||||
.form-select:focus {
|
||||
outline: none;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
border-color: rgba(100, 149, 237, 0.6);
|
||||
}
|
||||
|
||||
.form-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user