重构路由架构

This commit is contained in:
2026-04-05 12:04:18 +08:00
parent 01ca2bd0f9
commit 7a62139683
13 changed files with 2798 additions and 851 deletions

View File

@@ -1,68 +1,16 @@
from fastapi import APIRouter
from ..core.items import ChatRequest
from ..tools.get_all_role_and_chat import get_all_role_and_chat
from ..core.models.chat_history import ChatHistory # 修改导入语句
from .routes import presetsRoute, chatsRoute, worldbooksRoute
router = APIRouter()
# 从本地读取所有的data内容
# 注册子路由
router.include_router(presetsRoute.router)
router.include_router(chatsRoute.router)
router.include_router(worldbooksRoute.router)
# 保留原有的其他路由
@router.get("/tool_bar/get_all_role_and_chat")
def get_all_role_and_chat_endpoint():
# 正确调用函数并返回结果
return get_all_role_and_chat()
# 根据rolename和chatname读取特定聊天记录
@router.get("/chat_box/get_chat_history")
async def get_chat_history_endpoint(role_name: str, chat_name: str):
# 实例化工具类
reader = ChatHistory.load_from_file(role_name, chat_name)
return reader.to_chatbox_format()
# 从本地读取所有的预设列表内容
@router.get("/presets/list")
async def get_presets_list():
"""
获取所有可用的预设列表
返回预设名称列表
"""
import os
from pathlib import Path
# 预设文件存储目录
preset_dir = Path("data/preset")
# 确保目录存在
if not preset_dir.exists():
return {"presets": []}
# 获取所有.json文件
preset_files = list(preset_dir.glob("*.json"))
# 提取文件名(不带扩展名)作为预设名称
presets = [file.stem for file in preset_files]
return {"presets": presets}
# 从本地读取特定预设
@router.get("/presets/{preset_name}")
async def get_preset_content(preset_name: str):
"""
获取特定预设的完整内容
"""
import os
from pathlib import Path
import json
preset_path = Path("data/preset") / f"{preset_name}.json"
if not preset_path.exists():
raise HTTPException(status_code=404, detail="Preset not found")
with open(preset_path, 'r', encoding='utf-8') as f:
preset_data = json.load(f)
return preset_data
from ..tools.get_all_role_and_chat import get_all_role_and_chat
return get_all_role_and_chat()

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

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

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

View File

@@ -1,4 +1,5 @@
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, validator
from typing import Dict, Any
class PromptComponent(BaseModel):
@@ -12,4 +13,48 @@ class PromptComponent(BaseModel):
system_prompt: bool = Field(False, description="是否强制作为系统提示词处理")
marker: bool = Field(False, description="是否为动态插入点占位符")
@validator('role')
def validate_role(cls, v):
"""验证角色值是否在有效范围内"""
if v not in [0, 1, 2]:
raise ValueError("角色值必须是0(System)、1(User)或2(Assistant)")
return v
def update(self, **kwargs) -> None:
"""
更新组件属性
参数:
**kwargs: 要更新的字段和值
异常:
ValueError: 当尝试更新identifier时抛出
"""
if 'identifier' in kwargs:
raise ValueError("组件标识符不可修改")
for key, value in kwargs.items():
if hasattr(self, key):
setattr(self, key, value)
def to_dict(self) -> Dict[str, Any]:
"""
将组件转换为字典
返回:
Dict[str, Any]: 组件的字典表示
"""
return self.dict()
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'PromptComponent':
"""
从字典创建组件实例
参数:
data: 包含组件数据的字典
返回:
PromptComponent: 组件实例
"""
return cls(**data)

View File

@@ -1,6 +1,6 @@
from pydantic import BaseModel, Field
import PromptComponent
from typing import List, Dict, Any
from pydantic import BaseModel, Field, validator
from typing import List, Dict, Any, Optional
from .PromptComponent import PromptComponent
class AIDesignSpec(BaseModel):
@@ -50,3 +50,167 @@ class AIDesignSpec(BaseModel):
default_factory=list,
description="组装说明书,定义构建最终提示词的顺序"
)
@validator('prompts')
def validate_prompts_unique_identifier(cls, v):
"""验证组件标识符唯一性"""
identifiers = [comp.identifier for comp in v]
if len(identifiers) != len(set(identifiers)):
raise ValueError("组件标识符必须唯一")
return v
@validator('prompt_order')
def validate_prompt_order_exists(cls, v, values):
"""验证prompt_order中的组件ID是否存在于prompts中"""
if 'prompts' in values:
prompt_ids = {comp.identifier for comp in values['prompts']}
invalid_ids = set(v) - prompt_ids
if invalid_ids:
raise ValueError(f"prompt_order中包含不存在的组件ID: {invalid_ids}")
return v
# ========== 组件管理方法 ==========
def add_component(self, component: PromptComponent) -> None:
"""
添加新组件
参数:
component: 要添加的组件
异常:
ValueError: 当组件标识符已存在时抛出
"""
if any(c.identifier == component.identifier for c in self.prompts):
raise ValueError(f"组件标识符 {component.identifier} 已存在")
self.prompts.append(component)
def remove_component(self, identifier: str) -> bool:
"""
移除指定组件
参数:
identifier: 组件标识符
返回:
bool: 是否成功移除
"""
original_length = len(self.prompts)
self.prompts = [c for c in self.prompts if c.identifier != identifier]
# 同时从prompt_order中移除
self.prompt_order = [id for id in self.prompt_order if id != identifier]
return len(self.prompts) < original_length
def get_component(self, identifier: str) -> Optional[PromptComponent]:
"""
获取指定组件
参数:
identifier: 组件标识符
返回:
Optional[PromptComponent]: 找到的组件未找到返回None
"""
for component in self.prompts:
if component.identifier == identifier:
return component
return None
def update_component(self, identifier: str, **kwargs) -> bool:
"""
更新指定组件
参数:
identifier: 组件标识符
**kwargs: 要更新的字段
返回:
bool: 是否成功更新
"""
component = self.get_component(identifier)
if component is None:
return False
component.update(**kwargs)
return True
def list_components(self) -> List[Dict[str, Any]]:
"""
列出所有组件
返回:
List[Dict[str, Any]]: 组件字典列表
"""
return [component.to_dict() for component in self.prompts]
def reorder_components(self, new_order: List[str]) -> None:
"""
重新排序组件
参数:
new_order: 新的组件标识符顺序
异常:
ValueError: 当包含不存在的组件ID时抛出
"""
# 验证所有ID都存在
existing_ids = {c.identifier for c in self.prompts}
invalid_ids = set(new_order) - existing_ids
if invalid_ids:
raise ValueError(f"包含不存在的组件ID: {invalid_ids}")
self.prompt_order = new_order
def get_ordered_components(self) -> List[PromptComponent]:
"""
获取按prompt_order排序的组件列表
返回:
List[PromptComponent]: 排序后的组件列表
"""
component_map = {c.identifier: c for c in self.prompts}
ordered_components = []
for identifier in self.prompt_order:
if identifier in component_map:
ordered_components.append(component_map[identifier])
# 添加未在prompt_order中的组件
ordered_components.extend([
c for c in self.prompts
if c.identifier not in self.prompt_order
])
return ordered_components
def to_dict(self) -> Dict[str, Any]:
"""
将设计规范转换为字典
返回:
Dict[str, Any]: 设计规范的字典表示
"""
return self.dict()
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'AIDesignSpec':
"""
从字典创建设计规范实例
参数:
data: 包含设计规范数据的字典
返回:
AIDesignSpec: 设计规范实例
"""
# 处理prompts字段
if 'prompts' in data:
data['prompts'] = [
PromptComponent.from_dict(comp) if isinstance(comp, dict) else comp
for comp in data['prompts']
]
return cls(**data)

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

View 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

View File

@@ -6,211 +6,211 @@ import json
class Message(BaseModel):
"""消息类代表JSONL文件中的一行消息内容"""
name: str = Field(..., description="发送者名称")
is_user: bool = Field(..., description="是否为用户消息")
is_system: bool = Field(False, description="是否为系统消息")
send_date: str = Field(
default_factory=lambda: str(int(datetime.now().timestamp() * 1000)),
description="消息发送时间戳"
)
floor: int = Field(0, description="对话楼层数")
swipes: List[str] = Field(
default_factory=list,
description="历史版本列表。用户消息存编辑过的不同版本。AI消息存重roll生成的不同版本"
)
swipe_id: int = Field(
0,
description="当前指针。指示当前显示的是 swipes 数组中的第几个(从 0 开始)"
)
mes: str = Field(..., description="消息内容文本")
extra: Dict[str, Any] = Field(
default_factory=dict,
description="额外信息包含推理内容、API、模型等"
)
force_avatar: Optional[str] = Field(None, description="强制头像URL")
variables: List[Any] = Field(default_factory=list, description="消息变量列表")
variables_initialized: List[bool] = Field(default_factory=list, description="变量初始化状态数组")
is_ejs_processed: List[bool] = Field(default_factory=list, description="EJS处理状态数组")
"""消息类代表JSONL文件中的一行消息内容"""
name: str = Field(..., description="发送者名称")
is_user: bool = Field(..., description="是否为用户消息")
is_system: bool = Field(False, description="是否为系统消息")
send_date: str = Field(
default_factory=lambda: str(int(datetime.now().timestamp() * 1000)),
description="消息发送时间戳"
)
floor: int = Field(0, description="对话楼层数")
swipes: List[str] = Field(
default_factory=list,
description="历史版本列表。用户消息存编辑过的不同版本。AI消息存重roll生成的不同版本"
)
swipe_id: int = Field(
0,
description="当前指针。指示当前显示的是 swipes 数组中的第几个(从 0 开始)"
)
mes: str = Field(..., description="消息内容文本")
extra: Dict[str, Any] = Field(
default_factory=dict,
description="额外信息包含推理内容、API、模型等"
)
force_avatar: Optional[str] = Field(None, description="强制头像URL")
variables: List[Any] = Field(default_factory=list, description="消息变量列表")
variables_initialized: List[bool] = Field(default_factory=list, description="变量初始化状态数组")
is_ejs_processed: List[bool] = Field(default_factory=list, description="EJS处理状态数组")
# 以下属性仅在is_user为False时有值
api: Optional[str] = Field(None, description="使用的API提供商")
model: Optional[str] = Field(None, description="使用的AI模型")
reasoning: Optional[str] = Field(None, description="推理内容")
reasoning_duration: Optional[float] = Field(None, description="推理耗时")
reasoning_signature: Optional[str] = Field(None, description="推理签名")
time_to_first_token: Optional[float] = Field(None, description="首Token响应时间")
bias: Optional[float] = Field(None, description="偏差值")
# 以下属性仅在is_user为False时有值
api: Optional[str] = Field(None, description="使用的API提供商")
model: Optional[str] = Field(None, description="使用的AI模型")
reasoning: Optional[str] = Field(None, description="推理内容")
reasoning_duration: Optional[float] = Field(None, description="推理耗时")
reasoning_signature: Optional[str] = Field(None, description="推理签名")
time_to_first_token: Optional[float] = Field(None, description="首Token响应时间")
bias: Optional[float] = Field(None, description="偏差值")
class ChatMetadata(BaseModel):
"""聊天元数据类,包含整个聊天的共享属性"""
user_name: str = Field("User", description="用户名称")
character_name: str = Field("Assistant", description="角色名称")
"""聊天元数据类,包含整个聊天的共享属性"""
user_name: str = Field("User", description="用户名称")
character_name: str = Field("Assistant", description="角色名称")
# 完整性校验相关
integrity: str = Field("", description="完整性校验值")
chat_id_hash: str = Field("", description="聊天ID哈希值")
# 完整性校验相关
integrity: str = Field("", description="完整性校验值")
chat_id_hash: str = Field("", description="聊天ID哈希值")
# 笔记相关
note_prompt: str = Field("", description="作者笔记提示词")
note_interval: int = Field(0, description="笔记插入间隔数")
note_position: int = Field(0, description="笔记插入位置")
note_depth: int = Field(0, description="笔记插入深度")
# 0System1User2Assistant
note_role: int = Field("", description="笔记使用角色类型")
# 笔记相关
note_prompt: str = Field("", description="作者笔记提示词")
note_interval: int = Field(0, description="笔记插入间隔数")
note_position: int = Field(0, description="笔记插入位置")
note_depth: int = Field(0, description="笔记插入深度")
# 0System1User2Assistant
note_role: int = Field("", description="笔记使用角色类型")
# 扩展信息
extensions: Dict[str, Any] = Field(
default_factory=dict,
description="扩展信息如LittleWhiteBox等"
)
# 世界信息
timedWorldInfo: Dict[str, Any] = Field(
default_factory=dict,
description="定时世界信息"
)
# 变量
variables: Dict[str, Any] = Field(
default_factory=dict,
description="变量字典"
)
# 状态标记
tainted: bool = Field(False, description="是否被修改标记")
lastInContextMessageId: int = Field(-1, description="最后上下文消息ID")
# 扩展信息
extensions: Dict[str, Any] = Field(
default_factory=dict,
description="扩展信息如LittleWhiteBox等"
)
# 世界信息
timedWorldInfo: Dict[str, Any] = Field(
default_factory=dict,
description="定时世界信息"
)
# 变量
variables: Dict[str, Any] = Field(
default_factory=dict,
description="变量字典"
)
# 状态标记
tainted: bool = Field(False, description="是否被修改标记")
lastInContextMessageId: int = Field(-1, description="最后上下文消息ID")
class ChatHistory(BaseModel):
"""聊天文件类,包含完整的聊天记录"""
chat_metadata: ChatMetadata = Field(..., description="聊天元数据,包含基本信息和配置")
messages: List[Message] = Field(default_factory=list, description="消息列表")
"""聊天文件类,包含完整的聊天记录"""
chat_metadata: ChatMetadata = Field(..., description="聊天元数据,包含基本信息和配置")
messages: List[Message] = Field(default_factory=list, description="消息列表")
class Config:
arbitrary_types_allowed = True
class Config:
arbitrary_types_allowed = True
@classmethod # 类方法装饰器,表示这是一个类方法,可以通过类名直接调用
def load_from_file(cls, role_name: str, chat_name: str, base_path: Path = None) -> 'ChatHistory':
"""
从JSONL文件加载聊天历史
@classmethod # 类方法装饰器,表示这是一个类方法,可以通过类名直接调用
def load_from_file(cls, role_name: str, chat_name: str, base_path: Path = None) -> 'ChatHistory':
"""
从JSONL文件加载聊天历史
参数:
role_name: 角色名称(文件夹名)
chat_name: 聊天名称(文件名,不含扩展名)
base_path: 基础路径默认为配置中的DATA_PATH/chat
参数:
role_name: 角色名称(文件夹名)
chat_name: 聊天名称(文件名,不含扩展名)
base_path: 基础路径默认为配置中的DATA_PATH/chat
返回:
ChatHistory: 加载的聊天历史对象
返回:
ChatHistory: 加载的聊天历史对象
异常:
FileNotFoundError: 当文件不存在时抛出
json.JSONDecodeError: 当JSON解析失败时抛出
"""
# 设置默认基础路径 - 如果未提供base_path则从配置中获取默认路径
if base_path is None:
from backend.core.config import settings # 延迟导入配置模块
base_path = settings.DATA_PATH / "chat" # 构建默认路径
异常:
FileNotFoundError: 当文件不存在时抛出
json.JSONDecodeError: 当JSON解析失败时抛出
"""
# 设置默认基础路径 - 如果未提供base_path则从配置中获取默认路径
if base_path is None:
from backend.core.config import settings # 延迟导入配置模块
base_path = settings.DATA_PATH / "chat" # 构建默认路径
# 构建文件路径
file_path = base_path / role_name / f"{chat_name}.jsonl"
# 构建文件路径
file_path = base_path / role_name / f"{chat_name}.jsonl"
# 检查文件是否存在
if not file_path.exists():
raise FileNotFoundError(f"聊天文件不存在: {file_path}")
# 检查文件是否存在
if not file_path.exists():
raise FileNotFoundError(f"聊天文件不存在: {file_path}")
# 初始化结果数据
messages = []
metadata = None
# 初始化结果数据
messages = []
metadata = None
# 读取文件内容
with open(file_path, 'r', encoding='utf-8') as f:
for line_num, line in enumerate(f):
try:
line_data = json.loads(line.strip())
# 读取文件内容
with open(file_path, 'r', encoding='utf-8') as f:
for line_num, line in enumerate(f):
try:
line_data = json.loads(line.strip())
# 第一行是元数据
if line_num == 0:
metadata = ChatMetadata(**line_data)
else:
# 后续行是消息
messages.append(Message(**line_data))
except json.JSONDecodeError:
continue
# 第一行是元数据
if line_num == 0:
metadata = ChatMetadata(**line_data)
else:
# 后续行是消息
messages.append(Message(**line_data))
except json.JSONDecodeError:
continue
# 创建并返回ChatHistory对象
return cls(
chat_metadata=metadata or ChatMetadata(),
messages=messages
)
# 创建并返回ChatHistory对象
return cls(
chat_metadata=metadata or ChatMetadata(),
messages=messages
)
@classmethod
def load_from_jsonl(cls, file_path: Path) -> 'ChatHistory':
"""
从JSONL文件加载聊天历史
@classmethod
def load_from_jsonl(cls, file_path: Path) -> 'ChatHistory':
"""
从JSONL文件加载聊天历史
参数:
file_path: JSONL文件路径
参数:
file_path: JSONL文件路径
返回:
ChatHistory: 加载的聊天历史对象
返回:
ChatHistory: 加载的聊天历史对象
异常:
FileNotFoundError: 当文件不存在时抛出
json.JSONDecodeError: 当JSON解析失败时抛出
"""
# 检查文件是否存在
if not file_path.exists():
raise FileNotFoundError(f"聊天文件不存在: {file_path}")
异常:
FileNotFoundError: 当文件不存在时抛出
json.JSONDecodeError: 当JSON解析失败时抛出
"""
# 检查文件是否存在
if not file_path.exists():
raise FileNotFoundError(f"聊天文件不存在: {file_path}")
# 初始化结果数据
messages = []
metadata = None
# 初始化结果数据
messages = []
metadata = None
# 读取文件内容
with open(file_path, 'r', encoding='utf-8') as f:
for line_num, line in enumerate(f):
try:
line_data = json.loads(line.strip())
# 读取文件内容
with open(file_path, 'r', encoding='utf-8') as f:
for line_num, line in enumerate(f):
try:
line_data = json.loads(line.strip())
# 第一行是元数据
if line_num == 0:
# 处理元数据中的嵌套结构
if 'chat_metadata' in line_data:
metadata_dict = line_data['chat_metadata']
# 合并顶层字段和chat_metadata中的字段
metadata_dict.update(line_data)
metadata = ChatMetadata(**metadata_dict)
else:
metadata = ChatMetadata(**line_data)
else:
# 后续行是消息
# 处理extra字段中的内容
extra_data = line_data.get('extra', {})
# 第一行是元数据
if line_num == 0:
# 处理元数据中的嵌套结构
if 'chat_metadata' in line_data:
metadata_dict = line_data['chat_metadata']
# 合并顶层字段和chat_metadata中的字段
metadata_dict.update(line_data)
metadata = ChatMetadata(**metadata_dict)
else:
metadata = ChatMetadata(**line_data)
else:
# 后续行是消息
# 处理extra字段中的内容
extra_data = line_data.get('extra', {})
# 如果是AI消息(is_user=False)将extra中的某些字段提升到顶层
if not line_data.get('is_user', True):
ai_fields = ['api', 'model', 'reasoning', 'reasoning_duration',
'reasoning_signature', 'time_to_first_token', 'bias']
for field in ai_fields:
if field in extra_data:
line_data[field] = extra_data.pop(field)
# 如果是AI消息(is_user=False)将extra中的某些字段提升到顶层
if not line_data.get('is_user', True):
ai_fields = ['api', 'model', 'reasoning', 'reasoning_duration',
'reasoning_signature', 'time_to_first_token', 'bias']
for field in ai_fields:
if field in extra_data:
line_data[field] = extra_data.pop(field)
# 创建Message实例
message = Message(**line_data)
# 将剩余的extra数据保存回extra字段
message.extra = extra_data
messages.append(message)
# 创建Message实例
message = Message(**line_data)
# 将剩余的extra数据保存回extra字段
message.extra = extra_data
messages.append(message)
except json.JSONDecodeError:
continue
except json.JSONDecodeError:
continue
# 创建并返回ChatHistory对象
return cls(
chat_metadata=metadata or ChatMetadata(),
messages=messages
)
# 创建并返回ChatHistory对象
return cls(
chat_metadata=metadata or ChatMetadata(),
messages=messages
)
def to_chatbox_format(self) -> List[Dict[str, Any]]:
"""
def to_chatbox_format(self) -> List[Dict[str, Any]]:
"""
将聊天历史转换为适合前端chatbox显示的格式
返回:
@@ -224,26 +224,53 @@ class ChatHistory(BaseModel):
"swipe_id": int
}
"""
# 创建消息字典列表
messages_list = []
for msg in self.messages:
# 获取当前消息内容优先从swipes数组中获取如果不存在则使用mes
current_mes = msg.mes
if msg.swipes and 0 <= msg.swipe_id < len(msg.swipes):
current_mes = msg.swipes[msg.swipe_id]
# 创建消息字典列表
messages_list = []
for msg in self.messages:
# 获取当前消息内容优先从swipes数组中获取如果不存在则使用mes
current_mes = msg.mes
if msg.swipes and 0 <= msg.swipe_id < len(msg.swipes):
current_mes = msg.swipes[msg.swipe_id]
msg_dict = {
"name": msg.name,
"is_user": msg.is_user,
"floor": msg.floor,
"mes": current_mes,
"swipes": msg.swipes,
"swipe_id": msg.swipe_id
}
messages_list.append(msg_dict)
msg_dict = {
"name": msg.name,
"is_user": msg.is_user,
"floor": msg.floor,
"mes": current_mes,
"swipes": msg.swipes,
"swipe_id": msg.swipe_id
}
messages_list.append(msg_dict)
# 按floor排序
messages_list.sort(key=lambda x: x["floor"])
# 按floor排序
messages_list.sort(key=lambda x: x["floor"])
return messages_list
return messages_list
def save_to_file(self, role_name: str, chat_name: str, base_path: Path = None) -> None:
"""
将聊天历史保存到JSONL文件
参数:
role_name: 角色名称(文件夹名)
chat_name: 聊天名称(文件名,不含扩展名)
base_path: 基础路径默认为data/chat
"""
# 设置默认基础路径
if base_path is None:
base_path = Path("data")
# 构建文件路径
file_path = base_path / role_name / f"{chat_name}.jsonl"
# 确保目录存在
file_path.parent.mkdir(parents=True, exist_ok=True)
# 写入文件
with open(file_path, 'w', encoding='utf-8') as f:
# 写入元数据
f.write(json.dumps(self.chat_metadata.dict(), ensure_ascii=False) + '\n')
# 写入消息
for message in self.messages:
f.write(json.dumps(message.dict(), ensure_ascii=False) + '\n')

View File

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

View File

@@ -1,11 +1,291 @@
import React from 'react';
import React, { useEffect, useState } from 'react';
import '../tabcss/WorldBook.css';
import useWorldBookStore from '../../../Store/Slices/LeftTabsSlices/WorldBookSlice';
const WorldBook = () => {
const {
worldBooks,
selectedWorldBook,
showEditPanel,
editingEntry,
isLoading,
isSelecting,
error,
showWorldBookDropdown,
showAddWorldBookDropdown,
globalWorldBooks,
fetchWorldBooks,
toggleWorldBookDropdown,
toggleAddWorldBookDropdown,
addGlobalWorldBook,
removeGlobalWorldBook,
createWorldBook,
deleteWorldBook,
selectWorldBook,
addEntry,
deleteEntry,
updateEntry,
toggleEditPanel,
} = useWorldBookStore();
const [newEntry, setNewEntry] = useState({
name: '',
content: '',
enabled: true,
triggerStrategy: 'keyword',
insertPosition: 'after',
order: 0,
});
useEffect(() => {
fetchWorldBooks();
}, [fetchWorldBooks]);
const handleCreateWorldBook = async () => {
const name = prompt('请输入世界书名称:');
if (name) {
await createWorldBook(name);
}
};
const handleAddEntry = async () => {
if (!selectedWorldBook) return;
await addEntry(newEntry);
setNewEntry({
name: '',
content: '',
enabled: true,
triggerStrategy: 'keyword',
insertPosition: 'after',
order: 0,
});
};
const handleEntryClick = (entry) => {
toggleEditPanel(true, entry);
};
const handleEntryUpdate = async (field, value) => {
if (!editingEntry) return;
const updatedEntry = { ...editingEntry, [field]: value };
await updateEntry(editingEntry.uid, updatedEntry);
};
return (
<div className="worldbook-content">
<h2>世界书</h2>
{/* 在这里实现世界书的具体内容 */}
{/* 全局世界书槽位 */}
<div className="global-worldbooks-slot">
<div className="global-books-header">
<span>全局世界书</span>
<div className="dropdown">
<button className="add-global-book-btn" onClick={toggleAddWorldBookDropdown}>
+ 添加
</button>
{showAddWorldBookDropdown && (
<div className="dropdown-menu">
<div className="dropdown-item" onClick={handleCreateWorldBook}>
新建世界书
</div>
{worldBooks
.filter(book => !globalWorldBooks.find(gb => gb.uid === book.uid))
.map(book => (
<div
key={book.uid}
className="dropdown-item"
onClick={() => {
addGlobalWorldBook(book.uid);
toggleAddWorldBookDropdown(false);
}}
>
{book.name}
</div>
))}
</div>
)}
</div>
</div>
<div className="global-books-list">
{globalWorldBooks.map(book => (
<div
key={book.uid}
className={`global-book-tag ${selectedWorldBook?.uid === book.uid ? 'active' : ''}`}
onClick={() => selectWorldBook(book.uid)}
>
{book.name}
{selectedWorldBook?.uid === book.uid && (
<span
className="remove-btn"
onClick={(e) => {
e.stopPropagation();
removeGlobalWorldBook(book.uid);
}}
>
</span>
)}
</div>
))}
</div>
</div>
{/* 世界书选择区域 */}
<div className="worldbook-selector">
<div className="dropdown" style={{ flex: 1 }}>
<button className="dropdown-btn" onClick={toggleWorldBookDropdown}>
{selectedWorldBook ? selectedWorldBook.name : '选择世界书'}
<span></span>
</button>
{showWorldBookDropdown && (
<div className="dropdown-menu">
{worldBooks.map(book => (
<div
key={book.uid}
className={`dropdown-item ${selectedWorldBook?.uid === book.uid ? 'active' : ''}`}
onClick={() => {
selectWorldBook(book.uid);
toggleWorldBookDropdown(false);
}}
>
{book.name}
</div>
))}
</div>
)}
</div>
{selectedWorldBook && (
<button
className="btn btn-danger"
onClick={() => deleteWorldBook(selectedWorldBook.uid)}
>
删除
</button>
)}
</div>
{/* 条目列表区域 */}
{isLoading ? (
<div className="loading">加载中...</div>
) : error ? (
<div className="error">{error}</div>
) : selectedWorldBook ? (
<div className="entries-container">
{isSelecting ? (
<div className="loading">加载条目中...</div>
) : selectedWorldBook.entries && selectedWorldBook.entries.length > 0 ? (
selectedWorldBook.entries.map(entry => (
<div
key={entry.uid}
className={`entry-item ${editingEntry?.uid === entry.uid ? 'active' : ''}`}
onClick={() => handleEntryClick(entry)}
>
<div className="entry-header">
<span className="entry-name">{entry.name}</span>
<span className={`entry-status ${entry.enabled ? 'enabled' : ''}`}>
{entry.enabled ? '启用' : '禁用'}
</span>
</div>
<div className="entry-meta">
<span>策略: {entry.triggerStrategy}</span>
<span>位置: {entry.insertPosition}</span>
<span>顺序: {entry.order}</span>
</div>
</div>
))
) : (
<div className="loading">暂无条目</div>
)}
<button className="btn btn-primary" onClick={handleAddEntry}>
+ 添加条目
</button>
</div>
) : (
<div className="loading">请选择一个世界书</div>
)}
{/* 编辑面板 */}
{showEditPanel && editingEntry && (
<div className={`edit-panel ${showEditPanel ? 'open' : ''}`}>
<div className="edit-panel-header">
<h2>编辑条目</h2>
<button className="close-btn" onClick={() => toggleEditPanel(false)}>
</button>
</div>
<div className="form-group">
<label className="form-label">名称</label>
<input
type="text"
className="form-input"
value={editingEntry.name}
onChange={(e) => handleEntryUpdate('name', e.target.value)}
/>
</div>
<div className="form-group">
<label className="form-label">内容</label>
<textarea
className="form-textarea"
value={editingEntry.content}
onChange={(e) => handleEntryUpdate('content', e.target.value)}
/>
</div>
<div className="form-group">
<label className="form-label">
<input
type="checkbox"
checked={editingEntry.enabled}
onChange={(e) => handleEntryUpdate('enabled', e.target.checked)}
/>
启用此条目
</label>
</div>
<div className="form-group">
<label className="form-label">触发策略</label>
<select
className="form-select"
value={editingEntry.triggerStrategy}
onChange={(e) => handleEntryUpdate('triggerStrategy', e.target.value)}
>
<option value="persistent">持久</option>
<option value="keyword">关键词</option>
<option value="rag">RAG</option>
<option value="calculation">运算</option>
</select>
</div>
<div className="form-group">
<label className="form-label">插入位置</label>
<select
className="form-select"
value={editingEntry.insertPosition}
onChange={(e) => handleEntryUpdate('insertPosition', e.target.value)}
>
<option value="before">之前</option>
<option value="after">之后</option>
</select>
</div>
<div className="form-group">
<label className="form-label">顺序</label>
<input
type="number"
className="form-input"
value={editingEntry.order}
onChange={(e) => handleEntryUpdate('order', parseInt(e.target.value))}
/>
</div>
<button
className="btn btn-danger"
onClick={() => deleteEntry(editingEntry.uid)}
>
删除条目
</button>
</div>
)}
</div>
);
};

View File

@@ -1,646 +1,366 @@
/* ==================== 预设页区域 ==================== */
.preset-panel {
.worldbook-content {
display: flex;
flex-direction: column;
padding: 12px;
background-color: #f9f9f9;
border-radius: 6px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
height: 100%;
overflow-y: auto;
padding: 12px;
gap: 12px;
overflow: hidden;
}
.preset-header {
/* 全局世界书槽位 */
.global-worldbooks-slot {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px;
background: rgba(255, 255, 255, 0.05);
border-radius: 6px;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.global-books-header {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 13px;
color: #333;
margin-bottom: 4px;
font-weight: 600;
}
.global-books-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
min-height: 28px;
}
.global-book-tag {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
background: rgba(74, 108, 247, 0.15);
border: 1px solid rgba(74, 108, 247, 0.3);
border-radius: 4px;
font-size: 12px;
color: #333;
cursor: pointer;
transition: all 0.15s ease;
font-weight: 500;
}
.global-book-tag:hover {
background: rgba(74, 108, 247, 0.25);
border-color: rgba(74, 108, 247, 0.5);
}
.global-book-tag.active {
background: rgba(74, 108, 247, 0.3);
border-color: rgba(74, 108, 247, 0.6);
box-shadow: 0 0 8px rgba(74, 108, 247, 0.2);
}
.global-book-tag .remove-btn {
opacity: 0.8;
cursor: pointer;
transition: opacity 0.15s ease;
font-weight: bold;
}
.global-book-tag .remove-btn:hover {
opacity: 1;
color: #e74c3c;
}
.add-global-book-btn {
padding: 4px 8px;
background: rgba(74, 108, 247, 0.2);
border: 1px solid rgba(74, 108, 247, 0.3);
border-radius: 4px;
color: #333;
cursor: pointer;
font-size: 12px;
transition: all 0.15s ease;
font-weight: 500;
}
.add-global-book-btn:hover {
background: rgba(74, 108, 247, 0.3);
border-color: rgba(74, 108, 247, 0.5);
}
/* 下拉菜单 */
.dropdown {
position: relative;
}
.dropdown-btn {
width: 100%;
padding: 8px 10px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 4px;
color: #333;
cursor: pointer;
text-align: left;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 13px;
transition: all 0.15s ease;
font-weight: 500;
}
.dropdown-btn:hover {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.15);
}
.dropdown-menu {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: white;
border: 1px solid #e0e0e0;
border-radius: 4px;
margin-top: 4px;
max-height: 200px;
overflow-y: auto;
z-index: 1000;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.dropdown-item {
padding: 8px 10px;
cursor: pointer;
transition: background 0.15s ease;
font-size: 13px;
color: #333;
font-weight: 500;
}
.dropdown-item:hover {
background: #f5f5f5;
}
.dropdown-item.active {
background: rgba(74, 108, 247, 0.15);
color: #4a6cf7;
}
/* 世界书选择区域 */
.worldbook-selector {
display: flex;
gap: 8px;
align-items: center;
}
/* 条目列表区域 */
.entries-container {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 6px;
}
.entry-item {
padding: 10px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 4px;
cursor: pointer;
transition: all 0.15s ease;
}
.entry-item:hover {
background: rgba(255, 255, 255, 0.05);
border-color: rgba(255, 255, 255, 0.1);
}
.entry-item.active {
background: rgba(74, 108, 247, 0.1);
border-color: rgba(74, 108, 247, 0.3);
}
.entry-name {
font-weight: 600;
font-size: 14px;
color: #333;
}
.entry-status {
font-size: 11px;
color: #777;
padding: 2px 6px;
border-radius: 3px;
background: rgba(255, 255, 255, 0.05);
font-weight: 500;
}
.entry-status.enabled {
color: #4a90e2;
background: rgba(74, 144, 226, 0.1);
}
.entry-meta {
display: flex;
gap: 12px;
font-size: 11px;
color: #777;
font-weight: 500;
}
/* 编辑面板 */
.edit-panel {
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 300px;
background: white;
z-index: 1000;
padding: 16px;
overflow-y: auto;
transform: translateX(100%);
transition: transform 0.2s ease-out;
border-left: 1px solid #e0e0e0;
box-shadow: -2px 0 8px rgba(0, 0, 0, 0.1);
}
.edit-panel.open {
transform: translateX(0);
}
.edit-panel-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid #eaeaea;
flex-shrink: 0;
}
.preset-select-container {
display: flex;
align-items: center;
flex: 1;
margin-right: 8px;
}
.preset-label {
margin-right: 8px;
font-size: 14px;
font-weight: 600;
color: #333;
cursor: help;
}
.preset-select {
flex: 1;
padding: 6px 10px;
border: 1px solid #e0e0e0;
border-radius: 4px;
background-color: white;
font-size: 14px;
color: #333;
transition: border-color 0.2s;
}
.preset-select:focus {
outline: none;
border-color: #4a6cf7;
}
.preset-actions {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.preset-action-btn {
background: none;
border: none;
font-size: 16px;
cursor: pointer;
padding: 6px;
border-radius: 4px;
transition: background-color 0.2s;
color: #555;
}
.preset-action-btn:hover {
background-color: #e8e8e8;
color: #333;
}
.preset-save-dialog,
.preset-edit-dialog,
.preset-import-dialog {
display: flex;
flex-direction: column;
padding: 12px;
background-color: white;
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
margin-bottom: 16px;
z-index: 100;
position: relative;
}
.preset-save-dialog input,
.preset-edit-dialog input {
padding: 8px;
margin-bottom: 10px;
border: 1px solid #e0e0e0;
border-radius: 4px;
font-size: 14px;
transition: border-color 0.2s;
}
.preset-save-dialog input:focus,
.preset-edit-dialog input:focus {
outline: none;
border-color: #4a6cf7;
}
.preset-import-dialog textarea {
padding: 8px;
margin-bottom: 10px;
border: 1px solid #e0e0e0;
border-radius: 4px;
resize: vertical;
font-family: monospace;
font-size: 13px;
min-height: 100px;
transition: border-color 0.2s;
}
.preset-import-dialog textarea:focus {
outline: none;
border-color: #4a6cf7;
}
.dialog-buttons {
display: flex;
justify-content: flex-end;
}
.dialog-buttons button {
margin-left: 8px;
padding: 6px 12px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: background-color 0.2s;
}
.dialog-buttons button:first-child {
background-color: #4a6cf7;
color: white;
}
.dialog-buttons button:first-child:hover {
background-color: #3a5ce5;
}
.dialog-buttons button:last-child {
background-color: #f0f0f0;
color: #333;
}
.dialog-buttons button:last-child:hover {
background-color: #e0e0e0;
}
.preset-parameters-container {
border: 1px solid #eaeaea;
border-radius: 6px;
overflow: hidden;
margin-bottom: 16px;
flex-shrink: 0;
}
.parameters-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
background-color: #f5f5f5;
cursor: pointer;
font-size: 14px;
font-weight: 600;
color: #333;
transition: background-color 0.2s;
}
.parameters-header:hover {
background-color: #eaeaea;
}
.expand-icon {
transition: transform 0.3s ease;
font-size: 12px;
}
.expand-icon.expanded {
transform: rotate(180deg);
}
.preset-parameters {
display: flex;
flex-direction: column;
gap: 10px;
padding: 12px;
animation: fadeIn 0.3s ease-in;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.parameter-row {
display: flex;
align-items: center;
gap: 8px;
}
.parameter-label {
width: 120px;
font-size: 13px;
font-weight: 500;
color: #555;
cursor: help;
flex-shrink: 0;
}
.parameter-slider {
flex: 1;
height: 4px;
border-radius: 2px;
background: #e0e0e0;
outline: none;
-webkit-appearance: none;
}
.parameter-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 14px;
height: 14px;
border-radius: 50%;
background: #4a6cf7;
cursor: pointer;
}
.parameter-slider::-moz-range-thumb {
width: 14px;
height: 14px;
border-radius: 50%;
background: #4a6cf7;
cursor: pointer;
border: none;
}
.parameter-number {
width: 60px;
padding: 4px 6px;
border: 1px solid #e0e0e0;
border-radius: 4px;
text-align: center;
font-size: 13px;
transition: border-color 0.2s;
flex-shrink: 0;
}
.parameter-number:focus {
outline: none;
border-color: #4a6cf7;
}
.parameter-input {
flex: 1;
padding: 6px 8px;
border: 1px solid #e0e0e0;
border-radius: 4px;
font-size: 13px;
transition: border-color 0.2s;
}
.parameter-input:focus {
outline: none;
border-color: #4a6cf7;
}
.parameter-toggles {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 8px;
}
.toggle-row {
display: flex;
justify-content: space-between;
align-items: center;
}
.toggle-label {
font-size: 13px;
font-weight: 500;
color: #555;
cursor: help;
}
.toggle-checkbox {
width: 16px;
height: 16px;
cursor: pointer;
accent-color: #4a6cf7;
}
.tooltip {
position: fixed;
padding: 6px 10px;
background-color: #333;
color: white;
border-radius: 4px;
font-size: 12px;
max-width: 250px;
z-index: 1000;
pointer-events: none;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
line-height: 1.4;
}
/* 预设组件列表样式 */
.preset-components-section {
margin-top: 20px;
border-top: 1px solid #e0e0e0;
padding-top: 20px;
flex: 1;
overflow-y: auto;
min-height: 0;
}
.component-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0; /* 移除底部边距 */
}
.component-actions {
display: flex;
gap: 4px;
flex-shrink: 0;
align-items: center;
}
.token-count {
font-size: 11px;
color: #777;
margin: 0 4px;
white-space: nowrap;
}
.add-component-btn {
background-color: #4a90e2;
color: white;
border: none;
border-radius: 4px;
padding: 5px 10px;
cursor: pointer;
font-size: 14px;
}
.components-list {
width: 100%;
min-height: 100px;
}
.prompt-component-item {
background-color: #f9f9f9;
border: 1px solid #e0e0e0;
border-radius: 4px;
margin-bottom: 8px; /* 减小间距 */
padding: 6px 8px; /* 减小内边距 */
transition: background-color 0.2s;
width: 100%;
box-sizing: border-box;
}
.prompt-component-item.dragging {
background-color: #e9e9e9;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
opacity: 0.5;
}
.prompt-component-item.disabled {
opacity: 0.6;
}
.prompt-component-item.marker {
border-left: 3px solid #4a90e2;
}
.component-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px; /* 减小底部边距 */
}
.component-controls {
display: flex;
align-items: center;
gap: 6px; /* 减小间距 */
flex: 1;
overflow: hidden;
}
.drag-handle {
cursor: grab;
color: #999;
font-size: 14px; /* 减小字体 */
padding: 0 2px; /* 减小内边距 */
flex-shrink: 0;
}
.drag-handle:active {
cursor: grabbing;
}
.toggle-btn {
width: 18px; /* 减小尺寸 */
height: 18px;
border-radius: 50%;
border: 1px solid #ccc;
background-color: white;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px; /* 减小字体 */
color: #4a90e2;
flex-shrink: 0;
}
.toggle-btn.enabled {
background-color: #4a90e2;
color: white;
border-color: #4a90e2;
}
.component-name {
font-weight: 500;
font-size: 13px; /* 减小字体 */
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
.component-marker-badge {
background-color: #4a90e2;
color: white;
font-size: 10px; /* 减小字体 */
padding: 1px 4px; /* 减小内边距 */
border-radius: 8px;
margin-left: 6px;
flex-shrink: 0;
}
.component-actions {
display: flex;
gap: 4px; /* 减小间距 */
flex-shrink: 0;
}
.view-btn, .edit-btn, .delete-btn {
background: none;
border: none;
cursor: pointer;
font-size: 11px; /* 减小字体 */
padding: 2px 4px; /* 减小内边距 */
border-radius: 3px;
white-space: nowrap;
}
.view-btn {
color: #5c6bc0;
}
.view-btn:hover {
background-color: rgba(92, 107, 192, 0.1);
}
.edit-btn {
color: #4a90e2;
}
.edit-btn:hover {
background-color: rgba(74, 144, 226, 0.1);
}
.delete-btn {
color: #e74c3c;
}
.delete-btn:hover {
background-color: rgba(231, 76, 60, 0.1);
}
.component-footer {
display: flex;
justify-content: flex-end;
margin-top: 2px; /* 减小顶部边距 */
font-size: 11px; /* 减小字体 */
color: #777;
}
/* 组件编辑/查看对话框 */
.component-edit-dialog {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 80%;
max-width: 800px;
max-height: 80vh;
background-color: white;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 1000;
display: flex;
flex-direction: column;
}
.dialog-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
border-bottom: 1px solid #e0e0e0;
}
.dialog-header h3 {
margin: 0;
font-size: 18px;
font-weight: 600;
.edit-panel-header h2 {
font-size: 16px;
color: #333;
margin: 0;
font-weight: 600;
}
.close-btn {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: #999;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: background-color 0.2s;
font-size: 20px;
cursor: pointer;
padding: 4px;
transition: color 0.15s ease;
}
.close-btn:hover {
background-color: #f0f0f0;
color: #333;
}
.dialog-content {
flex: 1;
overflow-y: auto;
padding: 16px;
.form-group {
margin-bottom: 12px;
}
.component-textarea {
.form-label {
display: block;
margin-bottom: 6px;
font-size: 13px;
color: #555;
font-weight: 500;
}
.form-input,
.form-textarea,
.form-select {
width: 100%;
border: 1px solid #ddd;
padding: 8px 10px;
background: white;
border: 1px solid #e0e0e0;
border-radius: 4px;
padding: 12px;
font-family: monospace;
font-size: 14px;
line-height: 1.5;
resize: none;
box-sizing: border-box;
min-height: 400px;
}
.component-textarea:read-only {
background-color: #f9f9f9;
color: #333;
font-size: 13px;
transition: all 0.15s ease;
}
.dialog-footer {
padding: 12px 16px;
border-top: 1px solid #e0e0e0;
.form-input:focus,
.form-textarea:focus,
.form-select:focus {
outline: none;
border-color: #4a6cf7;
box-shadow: 0 0 0 2px rgba(74, 108, 247, 0.1);
}
.form-textarea {
min-height: 100px;
resize: vertical;
}
.form-checkbox {
display: flex;
justify-content: space-between;
align-items: center;
}
.token-count {
font-size: 12px;
color: #777;
}
.dialog-buttons {
display: flex;
gap: 8px;
cursor: pointer;
font-size: 13px;
color: #555;
font-weight: 500;
}
.dialog-buttons button {
padding: 6px 12px;
.form-checkbox input {
cursor: pointer;
}
/* 按钮样式 */
.btn {
padding: 8px 12px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: background-color 0.2s;
font-size: 13px;
transition: all 0.15s ease;
font-weight: 500;
}
.dialog-buttons button:first-child {
background-color: #4a6cf7;
.btn-primary {
background: #4a6cf7;
color: white;
}
.dialog-buttons button:first-child:hover {
background-color: #3a5ce5;
.btn-primary:hover {
background: #3a5ce5;
}
.dialog-buttons button:last-child {
background-color: #f0f0f0;
color: #333;
.btn-danger {
background: #e74c3c;
color: white;
}
.dialog-buttons button:last-child:hover {
background-color: #e0e0e0;
.btn-danger:hover {
background: #c0392b;
}
/* 拖拽相关样式 */
.drag-indicator {
height: 4px;
background-color: transparent;
border-radius: 2px;
margin: 4px 0;
transition: all 0.2s ease;
opacity: 0;
/* 加载和错误状态 */
.loading,
.error {
text-align: center;
padding: 20px;
font-size: 13px;
}
.drag-indicator.visible {
background-color: #4a90e2;
height: 4px;
opacity: 1;
box-shadow: 0 0 5px rgba(74, 144, 226, 0.5);
.loading {
color: #777;
font-weight: 500;
}
.error {
color: #e74c3c;
background: rgba(231, 76, 60, 0.1);
border-radius: 4px;
font-weight: 500;
}

View File

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