重构路由架构
This commit is contained in:
@@ -1,68 +1,16 @@
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
from ..core.items import ChatRequest
|
from .routes import presetsRoute, chatsRoute, worldbooksRoute
|
||||||
from ..tools.get_all_role_and_chat import get_all_role_and_chat
|
|
||||||
from ..core.models.chat_history import ChatHistory # 修改导入语句
|
|
||||||
|
|
||||||
router = APIRouter()
|
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")
|
@router.get("/tool_bar/get_all_role_and_chat")
|
||||||
def get_all_role_and_chat_endpoint():
|
def get_all_role_and_chat_endpoint():
|
||||||
# 正确调用函数并返回结果
|
from ..tools.get_all_role_and_chat import get_all_role_and_chat
|
||||||
return get_all_role_and_chat()
|
return get_all_role_and_chat()
|
||||||
|
|
||||||
# 根据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
|
|
||||||
|
|
||||||
|
|||||||
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):
|
class PromptComponent(BaseModel):
|
||||||
@@ -12,4 +13,48 @@ class PromptComponent(BaseModel):
|
|||||||
system_prompt: bool = Field(False, description="是否强制作为系统提示词处理")
|
system_prompt: bool = Field(False, description="是否强制作为系统提示词处理")
|
||||||
marker: 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
|
from pydantic import BaseModel, Field, validator
|
||||||
import PromptComponent
|
from typing import List, Dict, Any, Optional
|
||||||
from typing import List, Dict, Any
|
from .PromptComponent import PromptComponent
|
||||||
|
|
||||||
|
|
||||||
class AIDesignSpec(BaseModel):
|
class AIDesignSpec(BaseModel):
|
||||||
@@ -50,3 +50,167 @@ class AIDesignSpec(BaseModel):
|
|||||||
default_factory=list,
|
default_factory=list,
|
||||||
description="组装说明书,定义构建最终提示词的顺序"
|
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
|
||||||
@@ -247,3 +247,30 @@ class ChatHistory(BaseModel):
|
|||||||
|
|
||||||
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 '../tabcss/WorldBook.css';
|
||||||
|
import useWorldBookStore from '../../../Store/Slices/LeftTabsSlices/WorldBookSlice';
|
||||||
|
|
||||||
const WorldBook = () => {
|
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 (
|
return (
|
||||||
<div className="worldbook-content">
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,646 +1,366 @@
|
|||||||
/* ==================== 预设页区域 ==================== */
|
.worldbook-content {
|
||||||
|
|
||||||
.preset-panel {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 12px;
|
|
||||||
background-color: #f9f9f9;
|
|
||||||
border-radius: 6px;
|
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
|
||||||
height: 100%;
|
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;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
padding-bottom: 12px;
|
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;
|
border-bottom: 1px solid #e0e0e0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dialog-header h3 {
|
.edit-panel-header h2 {
|
||||||
margin: 0;
|
font-size: 16px;
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #333;
|
color: #333;
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.close-btn {
|
.close-btn {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
font-size: 24px;
|
|
||||||
cursor: pointer;
|
|
||||||
color: #999;
|
color: #999;
|
||||||
width: 32px;
|
font-size: 20px;
|
||||||
height: 32px;
|
cursor: pointer;
|
||||||
display: flex;
|
padding: 4px;
|
||||||
align-items: center;
|
transition: color 0.15s ease;
|
||||||
justify-content: center;
|
|
||||||
border-radius: 50%;
|
|
||||||
transition: background-color 0.2s;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.close-btn:hover {
|
.close-btn:hover {
|
||||||
background-color: #f0f0f0;
|
|
||||||
color: #333;
|
color: #333;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dialog-content {
|
.form-group {
|
||||||
flex: 1;
|
margin-bottom: 12px;
|
||||||
overflow-y: auto;
|
|
||||||
padding: 16px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.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%;
|
width: 100%;
|
||||||
border: 1px solid #ddd;
|
padding: 8px 10px;
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
border-radius: 4px;
|
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;
|
color: #333;
|
||||||
|
font-size: 13px;
|
||||||
|
transition: all 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dialog-footer {
|
.form-input:focus,
|
||||||
padding: 12px 16px;
|
.form-textarea:focus,
|
||||||
border-top: 1px solid #e0e0e0;
|
.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;
|
display: flex;
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
|
||||||
|
|
||||||
.token-count {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #777;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dialog-buttons {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #555;
|
||||||
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dialog-buttons button {
|
.form-checkbox input {
|
||||||
padding: 6px 12px;
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 按钮样式 */
|
||||||
|
.btn {
|
||||||
|
padding: 8px 12px;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 14px;
|
font-size: 13px;
|
||||||
transition: background-color 0.2s;
|
transition: all 0.15s ease;
|
||||||
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dialog-buttons button:first-child {
|
.btn-primary {
|
||||||
background-color: #4a6cf7;
|
background: #4a6cf7;
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dialog-buttons button:first-child:hover {
|
.btn-primary:hover {
|
||||||
background-color: #3a5ce5;
|
background: #3a5ce5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dialog-buttons button:last-child {
|
.btn-danger {
|
||||||
background-color: #f0f0f0;
|
background: #e74c3c;
|
||||||
color: #333;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dialog-buttons button:last-child:hover {
|
.btn-danger:hover {
|
||||||
background-color: #e0e0e0;
|
background: #c0392b;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 拖拽相关样式 */
|
/* 加载和错误状态 */
|
||||||
.drag-indicator {
|
.loading,
|
||||||
height: 4px;
|
.error {
|
||||||
background-color: transparent;
|
text-align: center;
|
||||||
border-radius: 2px;
|
padding: 20px;
|
||||||
margin: 4px 0;
|
font-size: 13px;
|
||||||
transition: all 0.2s ease;
|
|
||||||
opacity: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.drag-indicator.visible {
|
.loading {
|
||||||
background-color: #4a90e2;
|
color: #777;
|
||||||
height: 4px;
|
font-weight: 500;
|
||||||
opacity: 1;
|
}
|
||||||
box-shadow: 0 0 5px rgba(74, 144, 226, 0.5);
|
|
||||||
|
.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