完成世界书、骰子、apiconfig页面处理

This commit is contained in:
2026-04-30 01:35:10 +08:00
parent a3e3711b2b
commit ba9b925c32
4602 changed files with 785225 additions and 23 deletions

View File

@@ -0,0 +1,389 @@
from fastapi import APIRouter, HTTPException, UploadFile, File
from pydantic import BaseModel, Field
from typing import Dict, Optional, List, Any
import json
import os
from pathlib import Path
from core.config import settings
from cryptography.fernet import Fernet
import base64
from services.comfyui_workflow_manager import workflow_manager
from services.llm_model_service import LLMModelService
router = APIRouter(prefix="/api-config", tags=["API Configuration"])
# 加密密钥(实际项目中应该从环境变量读取)
ENCRYPTION_KEY = os.getenv('API_ENCRYPTION_KEY', Fernet.generate_key().decode())
fernet = Fernet(ENCRYPTION_KEY.encode() if isinstance(ENCRYPTION_KEY, str) else ENCRYPTION_KEY)
# 配置文件路径
CONFIG_DIR = Path(settings.DATA_PATH) / "apiconfig"
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
class ApiConfigItem(BaseModel):
"""单个 API 配置项"""
id: Optional[str] = None
name: Optional[str] = ""
category: Optional[str] = None # mainLLM, imageModel, secondaryLLM, ragEmbedding
apiUrl: Optional[str] = ""
apiKey: Optional[str] = None # 前端传入的可能是明文或空
model: Optional[str] = ""
# 生图模型的特殊字段
mode: Optional[str] = None # 'local' | 'cloud'
local: Optional[dict] = None
cloud: Optional[dict] = None
class ProfileSaveRequest(BaseModel):
"""保存配置文件的请求"""
profileId: str
name: Optional[str] = None
apis: Dict[str, ApiConfigItem] # key 是 categoryvalue 是配置
class ProfileResponse(BaseModel):
"""配置文件响应(不包含明文 API Key"""
id: str
name: str
apis: Dict[str, dict] # apiKey 字段会被移除或脱敏
def encrypt_api_key(api_key: str) -> str:
"""加密 API Key"""
if not api_key:
return ""
encrypted = fernet.encrypt(api_key.encode())
return base64.urlsafe_b64encode(encrypted).decode()
def decrypt_api_key(encrypted_key: str) -> str:
"""解密 API Key仅在后端内部使用"""
if not encrypted_key:
return ""
try:
decoded = base64.urlsafe_b64decode(encrypted_key.encode())
decrypted = fernet.decrypt(decoded)
return decrypted.decode()
except Exception:
return ""
def mask_api_key(api_key: str) -> str:
"""脱敏 API Key返回给前端"""
if not api_key or len(api_key) < 8:
return "****"
return api_key[:4] + "****" + api_key[-4:]
def load_profile(profile_id: str) -> Optional[dict]:
"""加载配置文件"""
config_file = CONFIG_DIR / f"{profile_id}.json"
if not config_file.exists():
return None
with open(config_file, 'r', encoding='utf-8') as f:
return json.load(f)
def save_profile(profile_id: str, profile_data: dict):
"""保存配置文件"""
config_file = CONFIG_DIR / f"{profile_id}.json"
with open(config_file, 'w', encoding='utf-8') as f:
json.dump(profile_data, f, ensure_ascii=False, indent=2)
def list_profiles() -> List[dict]:
"""列出所有配置文件"""
profiles = []
for config_file in CONFIG_DIR.glob("*.json"):
try:
with open(config_file, 'r', encoding='utf-8') as f:
profile = json.load(f)
profiles.append({
"id": profile.get("id", config_file.stem),
"name": profile.get("name", config_file.stem),
"createdAt": profile.get("createdAt", "")
})
except Exception:
continue
return profiles
@router.get("/profiles", response_model=List[dict])
def get_all_profiles():
"""获取所有配置文件列表"""
return list_profiles()
@router.get("/profiles/{profile_id}", response_model=ProfileResponse)
def get_profile(profile_id: str):
"""获取单个配置文件API Key 已脱敏)"""
profile = load_profile(profile_id)
if not profile:
raise HTTPException(status_code=404, detail="配置文件不存在")
# 脱敏所有 API Key
masked_apis = {}
for category, api_config in profile.get("apis", {}).items():
masked_config = api_config.copy()
if "apiKey" in masked_config and masked_config["apiKey"]:
masked_config["apiKey"] = mask_api_key(masked_config["apiKey"])
masked_apis[category] = masked_config
return {
"id": profile.get("id", profile_id),
"name": profile.get("name", profile_id),
"apis": masked_apis
}
@router.post("/profiles", response_model=ProfileResponse)
def create_or_update_profile(request: ProfileSaveRequest):
"""创建或更新配置文件(增量更新)"""
# 加载现有配置
existing_profile = load_profile(request.profileId)
if existing_profile:
# 更新现有配置:只更新提供的 API 配置
for category, api_config in request.apis.items():
api_config_dict = api_config.dict(exclude_none=True)
# 处理 API Key 加密
if api_config.apiKey and api_config.apiKey != "****":
# 如果是新的明文 key加密它
api_config_dict["apiKey"] = encrypt_api_key(api_config.apiKey)
elif api_config.apiKey == "****":
# 如果是脱敏的 key保留原有的加密 key
if category in existing_profile.get("apis", {}):
api_config_dict["apiKey"] = existing_profile["apis"][category].get("apiKey", "")
else:
api_config_dict.pop("apiKey", None)
# 更新配置
if "apis" not in existing_profile:
existing_profile["apis"] = {}
existing_profile["apis"][category] = api_config_dict
profile_data = existing_profile
else:
# 新建配置文件
from datetime import datetime
profile_data = {
"id": request.profileId,
"name": request.name or request.profileId,
"createdAt": datetime.now().isoformat(),
"apis": {}
}
# 添加所有 API 配置
for category, api_config in request.apis.items():
api_config_dict = api_config.dict(exclude_none=True)
if api_config_dict.get("apiKey"):
api_config_dict["apiKey"] = encrypt_api_key(api_config_dict["apiKey"])
profile_data["apis"][category] = api_config_dict
# 保存配置文件
save_profile(request.profileId, profile_data)
# 返回脱敏后的数据
masked_apis = {}
for category, api_config in profile_data.get("apis", {}).items():
masked_config = api_config.copy()
if "apiKey" in masked_config and masked_config["apiKey"]:
masked_config["apiKey"] = mask_api_key(masked_config["apiKey"])
masked_apis[category] = masked_config
return {
"id": profile_data.get("id", request.profileId),
"name": profile_data.get("name", request.profileId),
"apis": masked_apis
}
@router.delete("/profiles/{profile_id}")
def delete_profile(profile_id: str):
"""删除配置文件"""
config_file = CONFIG_DIR / f"{profile_id}.json"
if not config_file.exists():
raise HTTPException(status_code=404, detail="配置文件不存在")
config_file.unlink()
return {"message": "配置文件已删除"}
@router.post("/test-connection")
def test_connection(api_config: ApiConfigItem):
"""测试 API 连接并获取模型列表"""
try:
# 检测提供商类型
provider = LLMModelService.detect_provider(api_config.apiUrl)
# 获取模型列表
models = LLMModelService.get_models_by_provider(
provider=provider,
api_key=api_config.apiKey or "",
api_url=api_config.apiUrl
)
return {
"success": True,
"models": models,
"provider": provider,
"message": f"成功获取 {len(models)} 个模型"
}
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"获取模型列表失败: {str(e)}"
)
# ==================== ComfyUI Workflow Management ====================
@router.get("/comfyui/workflows", response_model=List[Dict[str, Any]])
def get_comfyui_workflows():
"""获取所有可用的 ComfyUI 工作流列表"""
return workflow_manager.list_workflows()
@router.post("/comfyui/workflows/upload")
async def upload_comfyui_workflow(file: UploadFile = File(...)):
"""上传 ComfyUI 工作流 JSON 文件"""
return await workflow_manager.upload_workflow(file)
@router.delete("/comfyui/workflows/{filename}")
def delete_comfyui_workflow(filename: str):
"""删除 ComfyUI 工作流文件"""
return workflow_manager.delete_workflow(filename)
@router.get("/comfyui/workflows/{filename}")
def get_comfyui_workflow(filename: str):
"""获取指定工作流的详细内容"""
return workflow_manager.load_workflow(filename)
# ==================== Connection Testing ====================
@router.post("/test-comfyui-connection")
def test_comfyui_connection(request: dict):
"""测试 ComfyUI 连接"""
import requests as req
api_url = request.get("apiUrl", "http://comfyui:8188")
try:
# 测试基本连通性
response = req.get(f"{api_url}/system_stats", timeout=5)
if response.status_code != 200:
return {
"success": False,
"message": f"HTTP {response.status_code}"
}
stats = response.json()
return {
"success": True,
"message": "连接成功",
"stats": {
"vram_total": stats.get("vram_total", 0),
"vram_free": stats.get("vram_free", 0),
"torch_version": stats.get("torch_version", ""),
"device": stats.get("device", "")
}
}
except req.exceptions.ConnectionError:
return {
"success": False,
"message": "无法连接到 ComfyUI请检查地址和端口"
}
except req.exceptions.Timeout:
return {
"success": False,
"message": "连接超时,请检查 ComfyUI 是否正常运行"
}
except Exception as e:
return {
"success": False,
"message": f"错误: {str(e)}"
}
@router.post("/test-cloud-connection")
def test_cloud_connection(request: dict):
"""测试云端 API 连接"""
import openai
provider = request.get("provider", "dall-e")
api_key = request.get("apiKey", "")
model = request.get("model", "dall-e-3")
if not api_key:
return {
"success": False,
"message": "API Key 不能为空"
}
try:
if provider == "dall-e":
# 测试 DALL-E
client = openai.OpenAI(api_key=api_key)
# 尝试获取模型列表(轻量级测试)
models = client.models.list()
# 检查指定的模型是否存在
model_exists = any(m.id == model for m in models.data)
if model_exists:
return {
"success": True,
"message": f"连接成功,模型 {model} 可用"
}
else:
return {
"success": False,
"message": f"模型 {model} 不可用"
}
elif provider == "stability":
# 测试 Stability AI
import requests as req
response = req.get(
"https://api.stability.ai/v1/engines/list",
headers={
"Authorization": f"Bearer {api_key}"
},
timeout=5
)
if response.status_code == 200:
return {
"success": True,
"message": "连接成功"
}
else:
return {
"success": False,
"message": f"HTTP {response.status_code}: {response.text}"
}
else:
return {
"success": False,
"message": f"不支持的提供商: {provider}"
}
except Exception as e:
return {
"success": False,
"message": f"连接失败: {str(e)}"
}

View File

@@ -1,5 +1,6 @@
3# 标准库导入
# 标准库导入
import os
import json
import shutil
import logging
from pathlib import Path
@@ -12,6 +13,7 @@ from fastapi.responses import JSONResponse, FileResponse
# 本地模块导入
from models.internal import WorldInfo, WorldInfoEntry
from core.config import settings
from services.worldbook_service import worldbook_service
# 配置日志
logger = logging.getLogger(__name__)
@@ -30,88 +32,213 @@ async def list_worldbooks():
Returns:
List[Dict[str, Any]]: 世界书列表
"""
# TODO: 实现 WorldBookService
return []
try:
return worldbook_service.list_worldbooks()
except Exception as e:
logger.error(f"Failed to list worldbooks: {str(e)}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/{name}", response_model=Dict[str, Any])
async def get_worldbook(name: str):
"""
获取指定名称的世界书
"""
raise HTTPException(status_code=501, detail="Not Implemented")
try:
return worldbook_service.get_worldbook(name)
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Failed to get worldbook '{name}': {str(e)}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/", response_model=Dict[str, Any])
async def create_worldbook(
name: str = Form(...),
description: str = Form(""),
file: Optional[UploadFile] = File(None)
):
"""
创建新世界书
创建新世界书(可选择导入文件)
"""
raise HTTPException(status_code=501, detail="Not Implemented")
try:
# 如果提供了文件,从 SillyTavern 格式导入
if file:
content = await file.read()
st_data = json.loads(content.decode('utf-8'))
return worldbook_service.import_from_sillytavern(name, st_data)
else:
# 创建空世界书
return worldbook_service.create_worldbook(name, description)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Failed to create worldbook '{name}': {str(e)}")
raise HTTPException(status_code=500, detail=str(e))
@router.put("/{name}", response_model=Dict[str, Any])
async def update_worldbook(
name: str,
file: Optional[UploadFile] = File(None)
description: Optional[str] = Form(None)
):
"""
更新世界书
更新世界书基本信息
"""
raise HTTPException(status_code=501, detail="Not Implemented")
try:
return worldbook_service.update_worldbook(name, description)
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Failed to update worldbook '{name}': {str(e)}")
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/{name}")
async def delete_worldbook(name: str):
"""
删除世界书
"""
raise HTTPException(status_code=501, detail="Not Implemented")
try:
worldbook_service.delete_worldbook(name)
return {"message": f"Worldbook '{name}' deleted successfully"}
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Failed to delete worldbook '{name}': {str(e)}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/{name}/entries", response_model=List[Dict[str, Any]])
async def list_worldbook_entries(name: str):
"""
获取世界书的所有条目
"""
raise HTTPException(status_code=501, detail="Not Implemented")
try:
return worldbook_service.list_entries(name)
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Failed to list entries for worldbook '{name}': {str(e)}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/{name}/entries/{uid}", response_model=Dict[str, Any])
async def get_worldbook_entry(name: str, uid: int):
async def get_worldbook_entry(name: str, uid: str):
"""
获取世界书的指定条目
"""
raise HTTPException(status_code=501, detail="Not Implemented")
try:
return worldbook_service.get_entry(name, uid)
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Failed to get entry '{uid}' from worldbook '{name}': {str(e)}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/{name}/entries", response_model=Dict[str, Any])
async def create_worldbook_entry(name: str, entry_data: Dict[str, Any]):
"""
在世界书中创建新条目
"""
raise HTTPException(status_code=501, detail="Not Implemented")
try:
return worldbook_service.create_entry(name, entry_data)
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Failed to create entry in worldbook '{name}': {str(e)}")
raise HTTPException(status_code=500, detail=str(e))
@router.put("/{name}/entries/{uid}", response_model=Dict[str, Any])
async def update_worldbook_entry(name: str, uid: int, entry_data: Dict[str, Any]):
async def update_worldbook_entry(name: str, uid: str, entry_data: Dict[str, Any]):
"""
更新世界书的指定条目
"""
raise HTTPException(status_code=501, detail="Not Implemented")
try:
return worldbook_service.update_entry(name, uid, entry_data)
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Failed to update entry '{uid}' in worldbook '{name}': {str(e)}")
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/{name}/entries/{uid}")
async def delete_worldbook_entry(name: str, uid: int):
async def delete_worldbook_entry(name: str, uid: str):
"""
删除世界书的指定条目
"""
raise HTTPException(status_code=501, detail="Not Implemented")
try:
worldbook_service.delete_entry(name, uid)
return {"message": f"Entry '{uid}' deleted successfully"}
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Failed to delete entry '{uid}' from worldbook '{name}': {str(e)}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/{name}/import", response_model=Dict[str, Any])
async def import_worldbook(name: str, file: UploadFile = File(...)):
"""
从文件导入世界书
从文件导入世界书(自动检测 SillyTavern 或内部格式)
"""
raise HTTPException(status_code=501, detail="Not Implemented")
try:
content = await file.read()
data = json.loads(content.decode('utf-8'))
# 智能检测格式
from models.converters import WorldBookConverter
format_type = WorldBookConverter.detect_format(data)
logger.info(f"检测到世界书格式: {format_type}")
if format_type == "sillytavern":
# SillyTavern 格式,需要转换
logger.info(f"正在转换 SillyTavern 格式为内部格式")
return worldbook_service.import_from_sillytavern(name, data)
elif format_type == "internal":
# 已经是内部格式,直接保存
logger.info(f"检测到内部格式,直接保存")
return worldbook_service.import_internal_format(name, data)
else:
raise HTTPException(status_code=400, detail="无法识别的世界书格式")
except json.JSONDecodeError:
raise HTTPException(status_code=400, detail="Invalid JSON format")
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Failed to import worldbook '{name}': {str(e)}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/{name}/export")
async def export_worldbook(name: str):
async def export_worldbook(name: str, format: str = "internal"):
"""
导出世界书为 SillyTavern 格式
导出世界书(支持 internal 和 sillytavern 两种格式)
Args:
name: 世界书名称
format: 导出格式 ('internal''sillytavern'),默认 internal
"""
raise HTTPException(status_code=501, detail="Not Implemented")
try:
if format.lower() == "sillytavern":
# 导出为 SillyTavern 格式(可能丢失特殊设置)
logger.info(f"导出世界书 '{name}' 为 SillyTavern 格式")
st_data = worldbook_service.export_to_sillytavern(name)
return JSONResponse(
content=st_data,
headers={
"Content-Disposition": f"attachment; filename={name}_sillytavern.json"
}
)
else:
# 导出为内部格式(保留所有设置)
logger.info(f"导出世界书 '{name}' 为内部格式")
internal_data = worldbook_service.get_worldbook(name)
return JSONResponse(
content=internal_data,
headers={
"Content-Disposition": f"attachment; filename={name}.json"
}
)
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Failed to export worldbook '{name}': {str(e)}")
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -52,6 +52,9 @@ class Settings:
# 临时文件目录
TEMP_PATH = DATA_PATH / "temp"
# ComfyUI 工作流目录
COMFYUI_WORKFLOWS_PATH = DATA_PATH / "comfyui_workflows"
def ensure_directories(self):
"""确保所有配置的目录存在,如果不存在则创建"""
directories = [
@@ -60,6 +63,7 @@ class Settings:
self.PRESET_PATH,
self.CHAT_PATH,
self.TEMP_PATH,
self.COMFYUI_WORKFLOWS_PATH,
]
for directory in directories:
directory.mkdir(parents=True, exist_ok=True)

231
backend/models/README.md Normal file
View File

@@ -0,0 +1,231 @@
# Backend Models 数据模型说明
## 目录结构
```
models/
├── __init__.py # 包初始化,导出所有模型
├── sillytavern.py # SillyTavern 兼容模型 (仅用于导入/导出)
├── internal.py # 内部业务模型 (项目核心使用)
└── README.md # 本文件
```
## 模型分类
### 1. SillyTavern 兼容模型 (`sillytavern.py`)
**用途**: 仅用于与 SillyTavern 格式的数据进行导入/导出兼容
**特点**:
- 严格遵循 SillyTavern 官方规范
- 不参与内部业务逻辑
- 所有字段名、结构与 SillyTavern 保持一致
- 前缀 `ST` 表示 SillyTavern
**主要模型**:
- `STWorldInfo` - SillyTavern 世界书
- `STCharacterCard` - SillyTavern 角色卡
- `STChatHeader` / `STChatMessage` - SillyTavern 聊天记录
- `STGenerationPreset` - SillyTavern 采样预设
- `STPromptPreset` - SillyTavern 提示词预设
**使用场景**:
```python
# 从 SillyTavern 导入时
st_data = json.load(file)
st_character = STCharacterCard(**st_data)
# 转换为内部模型
internal_character = converter.st_to_internal(st_character)
# 导出到 SillyTavern 时
st_data = converter.internal_to_st(internal_character)
json.dump(st_data.dict(), file)
```
### 2. 内部业务模型 (`internal.py`)
**用途**: 项目内部真正使用的数据结构,所有业务逻辑都基于这些模型
**特点**:
- 继承并扩展了 SillyTavern 的功能
- 添加了项目特色功能 (如 LOGIC 激活、RAG 配置、outputSchema 等)
- 所有 API 响应、数据存储、工作流交换都使用这些模型
- 无前缀,直接使用语义化名称
**主要模型**:
#### 世界书相关
- `ActivationType` - 激活方式枚举 (PERMANENT/KEYWORD/RAG/LOGIC)
- `LogicExpression` - 逻辑表达式
- `RAGConfig` - RAG 检索配置
- `WorldInfoEntry` - 世界书条目
- `WorldInfo` - 世界书
#### 角色卡相关
- `OutputSchemaField` - 结构化输出 schema
- `CharacterCard` - 角色卡
#### 聊天记录相关
- `ChatHeader` - 聊天头
- `ChatMessage` - 聊天消息
- `ChatLog` - 完整聊天记录
#### 预设相关
- `GenerationPreset` - 采样参数预设
- `PromptRole` - Prompt 角色枚举
- `PromptEntry` - Prompt 条目
- `PromptPresetView` - Prompt 预设视图
#### RAG 配置
- `RAGSearchConfig` - RAG 搜索配置
- `CharacterRAGConfig` - 角色卡 RAG 配置
- `ChatRAGConfig` - 聊天 RAG 配置
**使用场景**:
```python
# 业务逻辑中直接使用
from models import CharacterCard, WorldInfo
character = CharacterCard(
id="uuid-123",
name="Alice",
description="...",
...
)
# API 响应
@app.get("/characters/{id}")
async def get_character(id: str):
character = service.get_character(id)
return character # 返回 internal 模型
```
## 数据转换流程
```
SillyTavern 文件
↓ (导入)
STCharacterCard (sillytavern.py)
↓ (转换器)
CharacterCard (internal.py)
↓ (业务处理)
CharacterCard (internal.py)
↓ (转换器)
STCharacterCard (sillytavern.py)
↓ (导出)
SillyTavern 文件
```
## 开发规范
### ✅ 正确做法
1. **业务逻辑使用 internal 模型**
```python
from models import CharacterCard
def create_character(data: dict) -> CharacterCard:
return CharacterCard(**data)
```
2. **导入时使用转换器**
```python
from models import STCharacterCard, CharacterCard
from models.converters import CharacterConverter
def import_character(file_path: str) -> CharacterCard:
st_data = load_json(file_path)
st_char = STCharacterCard(**st_data)
return CharacterConverter.st_to_internal(st_char)
```
3. **API 响应使用 internal 模型**
```python
@app.get("/characters")
async def list_characters() -> List[CharacterCard]:
return service.list_characters()
```
### ❌ 错误做法
1. **不要在业务逻辑中直接使用 ST 模型**
```python
# 错误!
from models import STCharacterCard
def process_character(char: STCharacterCard):
...
```
2. **不要混合使用两种模型**
```python
# 错误!
character = CharacterCard(...)
character.name = st_character.data.name # 不要混用
```
3. **不要在 API 中暴露 ST 模型**
```python
# 错误!
@app.get("/characters")
async def list_characters() -> List[STCharacterCard]:
...
```
## 添加新模型
当需要添加新的数据类型时:
1. **判断用途**:
- 如果是为了 SillyTavern 兼容 → 添加到 `sillytavern.py`
- 如果是项目内部使用 → 添加到 `internal.py`
2. **遵循命名规范**:
- SillyTavern 模型: 前缀 `ST`
- 内部模型: 无前缀,使用清晰的语义化名称
3. **添加详细注释**:
```python
class MyModel(BaseModel):
"""
模型用途说明
详细描述该模型的作用、使用场景等
"""
field1: str = Field(..., description="字段说明")
```
4. **在 `__init__.py` 中导出**:
```python
from .internal import MyModel
__all__ = [
...,
'MyModel',
]
```
## 转换器 (待实现)
`models/converters.py` 将提供双向转换功能:
```python
class CharacterConverter:
@staticmethod
def st_to_internal(st_char: STCharacterCard) -> CharacterCard:
"""SillyTavern → Internal"""
...
@staticmethod
def internal_to_st(int_char: CharacterCard) -> STCharacterCard:
"""Internal → SillyTavern"""
...
```
## 总结
- **sillytavern.py** = 外部兼容层 (Import/Export Only)
- **internal.py** = 内部业务层 (Core Business Logic)
- **永远在业务逻辑中使用 internal 模型**
- **通过转换器进行格式转换**

View File

@@ -0,0 +1,61 @@
"""
数据模型包
导出项目内部真正使用的数据结构 (Internal Models)。
SillyTavern 兼容模型将在需要导入/导出时单独引用。
"""
# 内部业务模型 (项目核心使用)
from .internal import (
# 世界书
ActivationType,
LogicOperator,
LogicExpression,
RAGConfig,
WorldInfoEntry,
WorldInfo,
# 角色卡
OutputSchemaField,
CharacterCard,
# 聊天记录
ChatHeader,
ChatMessage,
ChatLog,
# 预设
GenerationPreset,
# 提示词预设
PromptRole,
PromptEntry,
PromptPresetView,
# RAG 配置
RAGSearchConfig,
CharacterRAGConfig,
ChatRAGConfig,
)
__all__ = [
# 内部模型
'ActivationType',
'LogicOperator',
'LogicExpression',
'RAGConfig',
'WorldInfoEntry',
'WorldInfo',
'OutputSchemaField',
'CharacterCard',
'ChatHeader',
'ChatMessage',
'ChatLog',
'GenerationPreset',
'PromptRole',
'PromptEntry',
'PromptPresetView',
'RAGSearchConfig',
'CharacterRAGConfig',
'ChatRAGConfig',
]

View File

@@ -0,0 +1,379 @@
"""
数据模型转换器
提供 SillyTavern 格式与内部格式之间的双向转换功能。
所有导入/导出操作都应该通过转换器进行,确保数据格式的一致性。
"""
import uuid
from typing import Dict, Any, List, Optional
from datetime import datetime
from models.internal import (
WorldInfo,
WorldInfoEntry,
ActivationType,
)
class WorldBookConverter:
"""世界书数据转换器
负责 SillyTavern 格式和项目内部格式之间的转换。
SillyTavern 格式特点:
- entries 是 dict (key 为 uid)
- 使用 constant 字段表示常驻激活
- position 是字符串 (如 "after_char")
项目内部格式特点:
- entries 是 list
- 使用 activationType 枚举
- position 是数字 (0-5)
- 包含 trigger_config 结构(前端需要)
"""
@staticmethod
def detect_format(data: Dict[str, Any]) -> str:
"""
智能检测世界书数据格式
Args:
data: 世界书数据
Returns:
'sillytavern' | 'internal' | 'unknown'
"""
# 检查 entries 类型
entries = data.get("entries")
if not entries:
return "unknown"
# SillyTavern 特征: entries 是 dict
if isinstance(entries, dict):
return "sillytavern"
# 内部格式特征: entries 是 list
if isinstance(entries, list):
# 进一步检查是否有 trigger_config
if len(entries) > 0 and isinstance(entries[0], dict):
first_entry = entries[0]
if "trigger_config" in first_entry:
return "internal"
# 也可能是简化的内部格式
if "activationType" in first_entry or "position" in first_entry:
return "internal"
return "unknown"
# 位置映射: SillyTavern 字符串 -> 内部数字
POSITION_MAP_ST_TO_INTERNAL = {
"after_char": 0,
"before_char": 1,
"before_example": 2,
"after_example": 3,
"author_note": 4,
"system_prompt": 5,
}
# 位置映射: 内部数字 -> SillyTavern 字符串
POSITION_MAP_INTERNAL_TO_ST = {
0: "after_char",
1: "before_char",
2: "before_example",
3: "after_example",
4: "author_note",
5: "system_prompt",
}
@staticmethod
def st_to_internal(st_data: Dict[str, Any], name: str = None) -> Dict[str, Any]:
"""
将 SillyTavern 格式的世界书转换为内部格式
Args:
st_data: SillyTavern 格式的世界书数据
name: 世界书名称(可选,优先使用 st_data 中的 name)
Returns:
内部格式的世界书字典(包含 trigger_config)
"""
now = int(datetime.now().timestamp())
# 转换条目
entries = []
st_entries = st_data.get("entries", {})
# SillyTavern 的 entries 可能是 dict 或 list
if isinstance(st_entries, dict):
entries_list = list(st_entries.values())
elif isinstance(st_entries, list):
entries_list = st_entries
else:
entries_list = []
for st_entry in entries_list:
if not isinstance(st_entry, dict):
continue
# 判断激活类型
is_constant = st_entry.get("constant", False)
activation_type = ActivationType.PERMANENT if is_constant else ActivationType.KEYWORD
# 转换位置
st_position = st_entry.get("position", "after_char")
internal_position = WorldBookConverter.POSITION_MAP_ST_TO_INTERNAL.get(st_position, 0)
# 构建 trigger_config (前端期望的格式)
trigger_config = WorldBookConverter._build_trigger_config(
is_constant=is_constant,
key=st_entry.get("key", []),
keysecondary=st_entry.get("keysecondary", []),
selective=st_entry.get("selective", True)
)
# 创建内部格式的条目
entry_dict = {
"uid": st_entry.get("uid", str(uuid.uuid4())),
"key": st_entry.get("key", []),
"keysecondary": st_entry.get("keysecondary", []),
"content": st_entry.get("content", ""),
"comment": st_entry.get("comment", ""),
"activationType": activation_type.value,
"trigger_config": trigger_config,
"order": st_entry.get("order", 100),
"position": internal_position,
"depth": st_entry.get("depth", 4),
"role": st_entry.get("role", 0),
"probability": st_entry.get("probability", 100),
"group": st_entry.get("group", []),
"disable": st_entry.get("disable", False),
"createdAt": now,
"updatedAt": now
}
entries.append(entry_dict)
# 创建内部格式的世界书
worldbook_data = {
"id": str(uuid.uuid4()),
"name": name or st_data.get("name", "Unnamed"),
"description": st_data.get("description", ""),
"entries": entries,
"createdAt": now,
"updatedAt": now,
"version": 1
}
return worldbook_data
@staticmethod
def internal_to_st(worldbook_data: Dict[str, Any]) -> Dict[str, Any]:
"""
将内部格式的世界书转换为 SillyTavern 格式
Args:
worldbook_data: 内部格式的世界书字典
Returns:
SillyTavern 格式的世界书数据
"""
# 转换条目
st_entries = {}
for entry_data in worldbook_data.get("entries", []):
if not isinstance(entry_data, dict):
continue
uid = entry_data.get("uid", str(uuid.uuid4()))
# 从 trigger_config 或 activationType 判断是否常驻
is_constant = WorldBookConverter._is_constant_entry(entry_data)
# 提取关键词
key, keysecondary = WorldBookConverter._extract_keywords(entry_data)
# 转换位置
internal_position = entry_data.get("position", 0)
st_position = WorldBookConverter.POSITION_MAP_INTERNAL_TO_ST.get(internal_position, "after_char")
# 创建 SillyTavern 格式的条目
st_entry = {
"uid": uid,
"key": key,
"keysecondary": keysecondary,
"content": entry_data.get("content", ""),
"comment": entry_data.get("comment", ""),
"constant": is_constant,
"selective": not is_constant,
"order": entry_data.get("order", 100),
"position": st_position,
"depth": entry_data.get("depth", 4),
"probability": entry_data.get("probability", 100),
"group": entry_data.get("group", []),
"disable": entry_data.get("disable", False)
}
st_entries[uid] = st_entry
# 创建 SillyTavern 格式的世界书
st_data = {
"name": worldbook_data.get("name", ""),
"description": worldbook_data.get("description", ""),
"entries": st_entries
}
return st_data
@staticmethod
def normalize_entry(entry_data: Dict[str, Any]) -> Dict[str, Any]:
"""
规范化条目数据,确保包含所有必需字段和 trigger_config
Args:
entry_data: 条目数据(可能来自不同来源)
Returns:
规范化后的条目数据
"""
now = int(datetime.now().timestamp())
# 如果已经有 trigger_config,直接返回
if "trigger_config" in entry_data and entry_data["trigger_config"]:
return entry_data
# 否则从其他字段构建 trigger_config
is_constant = WorldBookConverter._is_constant_entry(entry_data)
key, keysecondary = WorldBookConverter._extract_keywords(entry_data)
trigger_config = WorldBookConverter._build_trigger_config(
is_constant=is_constant,
key=key,
keysecondary=keysecondary,
selective=entry_data.get("selective", True)
)
# 添加缺失的字段
normalized = {
"uid": entry_data.get("uid", str(uuid.uuid4())),
"key": key,
"keysecondary": keysecondary,
"content": entry_data.get("content", ""),
"comment": entry_data.get("comment", ""),
"activationType": entry_data.get("activationType",
ActivationType.PERMANENT.value if is_constant
else ActivationType.KEYWORD.value),
"trigger_config": trigger_config,
"order": entry_data.get("order", 100),
"position": entry_data.get("position", 0),
"depth": entry_data.get("depth", 4),
"role": entry_data.get("role", 0),
"probability": entry_data.get("probability", 100),
"group": entry_data.get("group", []),
"disable": entry_data.get("disable", False),
"createdAt": entry_data.get("createdAt", now),
"updatedAt": entry_data.get("updatedAt", now)
}
return normalized
@staticmethod
def _build_trigger_config(
is_constant: bool,
key: List[str],
keysecondary: List[str],
selective: bool = True
) -> Dict[str, Any]:
"""
构建 trigger_config 结构
Args:
is_constant: 是否常驻激活
key: 主关键词列表
keysecondary: 次要关键词列表
selective: 是否选择性匹配
Returns:
trigger_config 字典
"""
return {
"triggers": {
"constant": [is_constant, None],
"keyword": [
not is_constant,
{
"key": key,
"keysecondary": keysecondary,
"selective": selective,
"selectiveLogic": 0,
"matchWholeWords": False,
"caseSensitive": False
}
],
"rag": [False, {
"threshold": 0.75,
"top_k": 5,
"query_template": None
}],
"condition": [False, {
"variable_a": "",
"operator": "=",
"variable_b": ""
}]
}
}
@staticmethod
def _is_constant_entry(entry_data: Dict[str, Any]) -> bool:
"""
判断条目是否为常驻激活
Args:
entry_data: 条目数据
Returns:
是否常驻激活
"""
# 优先从 trigger_config 判断
if "trigger_config" in entry_data and entry_data["trigger_config"]:
try:
return entry_data["trigger_config"]["triggers"]["constant"][0]
except (KeyError, IndexError, TypeError):
pass
# 其次从 activationType 判断
if "activationType" in entry_data:
return entry_data["activationType"] == ActivationType.PERMANENT.value
# 最后从 constant 字段判断
if "constant" in entry_data:
return entry_data["constant"]
return False
@staticmethod
def _extract_keywords(entry_data: Dict[str, Any]) -> tuple:
"""
从条目数据中提取关键词
Args:
entry_data: 条目数据
Returns:
(key, keysecondary) 元组
"""
# 优先从 trigger_config 提取
if "trigger_config" in entry_data and entry_data["trigger_config"]:
try:
keyword_config = entry_data["trigger_config"]["triggers"]["keyword"][1]
if keyword_config:
key = keyword_config.get("key", [])
keysecondary = keyword_config.get("keysecondary", [])
return key, keysecondary
except (KeyError, IndexError, TypeError):
pass
# 否则从顶层字段提取
key = entry_data.get("key", [])
keysecondary = entry_data.get("keysecondary", [])
return key, keysecondary

301
backend/models/internal.py Normal file
View File

@@ -0,0 +1,301 @@
"""
项目内部数据结构定义
这是本项目真正使用的核心数据模型,所有业务逻辑都基于这些类型。
与 sillytavern.py 不同,这里的模型不参与导入导出兼容,而是专注于:
- 内部业务逻辑处理
- API 响应数据结构
- 数据存储格式
- 工作流引擎数据交换
所有从 SillyTavern 导入的数据都会转换为这些内部模型进行处理,
导出时再从内部模型转换回 SillyTavern 格式。
"""
from enum import Enum
from typing import List, Optional, Dict, Any
from pydantic import BaseModel, Field
from datetime import datetime
# ==================== 世界书 (World Info) ====================
class ActivationType(str, Enum):
"""
自定义激活方式类型4种枚举
这是项目的核心创新点之一,相比 SillyTavern 的简单 constant/selective 标志,
我们提供了更灵活的激活机制。
"""
PERMANENT = 'permanent' # 永久激活 - 始终包含在上下文中
KEYWORD = 'keyword' # 关键词触发 - 匹配关键词时激活
RAG = 'rag' # RAG 检索激活 - 基于向量相似度检索
LOGIC = 'logic' # 逻辑表达式激活 - 基于变量条件判断
class LogicOperator(str, Enum):
"""逻辑运算符(用于 LOGIC 激活类型)"""
EQUALS = 'equals' # 等于
NOT_EQUALS = 'not_equals' # 不等于
CONTAINS = 'contains' # 包含
NOT_CONTAINS = 'not_contains' # 不包含
GREATER = 'greater' # 大于
LESS = 'less' # 小于
class LogicExpression(BaseModel):
"""
逻辑表达式结构(用于 LOGIC 激活类型)
示例: variable1="mood", operator="equals", variable2="happy"
表示当 mood 变量等于 happy 时激活该条目
"""
variable1: str = Field(..., description="第一个变量名")
operator: LogicOperator = Field(..., description="比较运算符")
variable2: str = Field(..., description="第二个变量名或值")
class RAGConfig(BaseModel):
"""
RAG 配置(用于 RAG 激活类型)
控制如何从向量数据库中检索相关内容
"""
libraryId: str = Field(..., description="绑定的 RAG 库 ID")
threshold: Optional[float] = Field(0.7, ge=0, le=1, description="相似度阈值 (0-1)")
maxEntries: Optional[int] = Field(5, gt=0, description="最大返回条目数")
class WorldInfoEntry(BaseModel):
"""
项目内部世界书条目结构
这是世界书的核心单元,每个条目代表一段可以被动态注入到对话上下文中的知识。
相比 SillyTavern,我们添加了 activationType、logicExpression、ragConfig 等高级功能。
"""
uid: str = Field(..., description="条目唯一标识符 (UUID)")
key: Optional[List[str]] = Field(None, description="主关键词列表 (用于 KEYWORD 激活)")
keysecondary: Optional[List[str]] = Field(None, description="次要关键词列表 (可选过滤)")
content: str = Field(..., description="条目内容 - 激活时注入的文本")
activationType: ActivationType = Field(..., description="激活方式")
logicExpression: Optional[LogicExpression] = Field(None, description="逻辑表达式 (LOGIC 类型使用)")
ragConfig: Optional[RAGConfig] = Field(None, description="RAG 配置 (RAG 类型使用)")
order: int = Field(0, description="插入顺序 - 数值越大越靠近末尾")
position: Optional[str] = Field('after_char', description="插入位置")
depth: Optional[int] = Field(None, description="插入深度 (当 position='at_depth' 时使用)")
probability: Optional[float] = Field(100, ge=0, le=100, description="激活概率 (0-100)")
group: Optional[List[str]] = Field(None, description="所属组标签")
disable: bool = Field(False, description="是否禁用")
createdAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="创建时间戳")
updatedAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="最后更新时间戳")
class WorldInfo(BaseModel):
"""
项目内部世界书结构
世界书是角色知识的集合,可以绑定到角色卡上,在对话中动态提供背景信息。
"""
id: str = Field(..., description="世界书唯一标识符 (UUID)")
name: str = Field(..., description="世界书名称")
description: Optional[str] = Field(None, description="世界书描述")
entries: List[WorldInfoEntry] = Field(default_factory=list, description="条目数组")
createdAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="创建时间戳")
updatedAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="最后更新时间戳")
version: int = Field(1, description="版本号 (用于数据迁移)")
# ==================== 角色卡 (Character Card) ====================
class OutputSchemaField(BaseModel):
"""
Vercel AI SDK Output.object() 的表头定义
用于结构化输出,让 LLM 按照指定格式返回数据。
这是项目的特色功能,支持动态表格生成。
"""
name: str = Field(..., description="字段名称")
type: str = Field(..., description="字段类型 (string/number/boolean/array/object)")
description: str = Field(..., description="字段描述")
required: Optional[bool] = Field(None, description="是否必需")
enum: Optional[List[str]] = Field(None, description="枚举值 (字符串固定选项)")
fields: Optional[List['OutputSchemaField']] = Field(None, description="嵌套字段 (object 类型)")
class CharacterCard(BaseModel):
"""
项目内部角色卡结构
角色卡是对话 AI 的核心定义,包含人设、场景、开场白等。
相比 SillyTavern,我们添加了 categories、outputSchema、worldInfoId 等功能。
"""
id: str = Field(..., description="角色唯一标识符 (UUID)")
name: str = Field(..., description="角色名称")
description: str = Field(..., description="角色详细描述")
personality: str = Field(..., description="角色性格特征")
scenario: str = Field(..., description="场景设定")
first_mes: str = Field(..., description="首条开场消息")
mes_example: str = Field(..., description="对话示例")
categories: List[str] = Field(default_factory=list, description="分类标签 (用于前端筛选)")
worldInfoId: Optional[str] = Field(None, description="绑定的世界书 ID")
outputSchema: Optional[List[OutputSchemaField]] = Field(None, description="输出 schema 定义 (结构化输出)")
avatarPath: Optional[str] = Field(None, description="角色头像路径")
alternate_greetings: Optional[List[str]] = Field(None, description="替代问候语数组")
tags: Optional[List[str]] = Field(None, description="标签数组")
createdAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="创建时间戳")
updatedAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="最后更新时间戳")
lastChatAt: Optional[int] = Field(None, description="最后聊天时间戳")
isFavorite: bool = Field(False, description="收藏状态")
version: int = Field(1, description="版本号")
# ==================== 聊天记录 (Chat Log) ====================
class ChatHeader(BaseModel):
"""
项目内部聊天记录头
包含聊天的元数据,如参与角色、创建时间等。
"""
id: str = Field(..., description="聊天唯一标识符 (UUID)")
displayName: str = Field(..., description="显示名称 (聊天标题)")
characterId: str = Field(..., description="关联的角色卡 ID")
userName: str = Field("User", description="用户角色名")
characterName: str = Field(..., description="AI 角色名称")
tableData: Optional[Dict[str, Any]] = Field(None, description="表格数据 (对应 outputSchema)")
createdAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="创建时间戳")
updatedAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="最后更新时间戳")
messageCount: int = Field(0, description="消息数量")
ragLibraryId: Optional[str] = Field(None, description="关联的 RAG 历史消息库 ID")
class ChatMessage(BaseModel):
"""
项目内部聊天消息
单条对话消息,支持多版本 (swipes)、token 统计等功能。
"""
id: str = Field(..., description="消息唯一标识符 (UUID)")
name: str = Field(..., description="发送者名称")
is_user: bool = Field(..., description="是否为用户消息")
is_system: Optional[bool] = Field(None, description="是否为系统消息")
sendDate: str = Field(..., description="发送日期 ISO 字符串")
mes: str = Field(..., description="消息内容文本")
chatId: str = Field(..., description="关联的聊天 ID")
swipes: Optional[List[str]] = Field(None, description="替换回答数组 (多版本)")
swipe_id: Optional[int] = Field(0, description="当前选择的版本索引")
tokenCount: Optional[int] = Field(None, description="Token 数量 (用于统计)")
isTemporary: Optional[bool] = Field(None, description="是否为临时消息 (未保存)")
class ChatLog(BaseModel):
"""
项目内部完整聊天记录
包含聊天头和所有消息,是完整的对话历史。
"""
header: ChatHeader = Field(..., description="聊天头")
messages: List[ChatMessage] = Field(default_factory=list, description="消息列表")
# ==================== 预设 (Preset) ====================
class GenerationPreset(BaseModel):
"""
项目内部采样参数预设
控制 LLM 生成的参数配置,如温度、top_p 等。
"""
id: str = Field(..., description="预设唯一标识符 (UUID)")
name: str = Field(..., description="预设名称")
temperature: float = Field(1.0, ge=0, le=2, description="温度 (控制随机性)")
topP: float = Field(1.0, ge=0, le=1, description="Top P (核采样)")
topK: int = Field(0, ge=0, description="Top K")
repetitionPenalty: float = Field(1.0, ge=0, description="重复惩罚")
frequencyPenalty: Optional[float] = Field(None, description="频率惩罚")
presencePenalty: Optional[float] = Field(None, description="存在惩罚")
maxLength: Optional[int] = Field(None, gt=0, description="最大生成长度")
isDefault: bool = Field(False, description="是否为默认预设")
createdAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="创建时间戳")
updatedAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="最后更新时间戳")
# ==================== 提示词预设 (Prompt Preset) ====================
class PromptRole(str, Enum):
"""
Prompt 角色类型
内部业务层只保留三种角色,简化了 SillyTavern 的复杂角色系统。
"""
SYSTEM = 'system' # 系统指令
AI = 'ai' # AI 助手
USER = 'user' # 用户
class PromptEntry(BaseModel):
"""
内部业务层 - Prompt 条目
提示词模板的基本单元,可以组合成完整的提示词预设。
这是基于某个 character_id 生成的"当前视图"
"""
identifier: str = Field(..., description="稳定关联键 (用于回写)")
name: str = Field(..., description="条目名 (前端显示)")
enabled: bool = Field(True, description="是否启用 (当前作用域下的业务状态)")
content: str = Field(..., description="条目内容 (静态内容视图)")
order: int = Field(..., description="条目顺序 (前端展示和拖拽排序)")
role: PromptRole = Field(..., description="角色类型")
tokenCount: int = Field(0, description="总 token 数 (派生显示字段)")
isSystemNode: bool = Field(False, description="是否固有节点 (不可删除)")
class PromptPresetView(BaseModel):
"""
内部业务层 - Prompt 预设视图
基于某个 character_id 的"当前视图",包含已排序、已过滤的条目列表。
"""
characterId: str = Field(..., description="关联的角色 ID")
entries: List[PromptEntry] = Field(default_factory=list, description="当前视图的条目列表")
updatedAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="最后更新时间戳")
version: int = Field(1, description="版本号")
# ==================== RAG 配置 ====================
class RAGSearchConfig(BaseModel):
"""RAG 搜索配置"""
topK: int = Field(5, gt=0, description="每次检索返回的结果数")
threshold: float = Field(0.7, ge=0, le=1, description="相似度阈值 (0-1)")
maxContextLength: int = Field(2000, gt=0, description="最大上下文长度 (字符数)")
class CharacterRAGConfig(BaseModel):
"""
角色卡 RAG 世界书库配置
记录角色卡关联的 RAG 知识库,用于动态检索相关知识。
"""
characterId: str = Field(..., description="角色卡ID")
ragLibraryIds: List[str] = Field(default_factory=list, description="关联的RAG库ID列表")
enabled: bool = Field(True, description="是否启用")
searchConfig: Optional[RAGSearchConfig] = Field(None, description="搜索配置")
position: str = Field('after_char', description="RAG内容插入位置")
createdAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="创建时间戳")
updatedAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="最后更新时间戳")
class ChatRAGConfig(BaseModel):
"""
聊天会话 RAG 历史消息配置
记录聊天会话关联的 RAG 历史消息库,用于智能检索历史对话。
"""
chatId: str = Field(..., description="聊天会话ID")
ragLibraryId: Optional[str] = Field(None, description="关联的RAG历史消息库ID")
enabled: bool = Field(True, description="是否启用")
searchConfig: Optional[Dict[str, Any]] = Field(None, description="搜索配置")
autoIndex: bool = Field(True, description="是否自动索引新消息")
indexConfig: Optional[Dict[str, Any]] = Field(None, description="索引配置")
createdAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="创建时间戳")
updatedAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="最后更新时间戳")

View File

@@ -0,0 +1,11 @@
"""
业务服务层
包含项目的核心业务逻辑,协调 Models、Utils 和 LLM 组件。
"""
from .prompt_assembler import PromptAssembler, PromptConfig
__all__ = [
'PromptAssembler',
'PromptConfig',
]

View File

@@ -0,0 +1,173 @@
"""
ComfyUI Workflow Manager
管理工作流 JSON 文件的上传、删除和加载
"""
import json
import os
from pathlib import Path
from typing import List, Dict, Optional
from fastapi import UploadFile, HTTPException
import shutil
from core.config import settings
# 工作流目录 - 使用统一的数据目录
WORKFLOW_DIR = settings.COMFYUI_WORKFLOWS_PATH
WORKFLOW_DIR.mkdir(parents=True, exist_ok=True)
class WorkflowManager:
"""ComfyUI 工作流管理器"""
@staticmethod
def list_workflows() -> List[Dict[str, str]]:
"""列出所有可用的工作流"""
workflows = []
for json_file in WORKFLOW_DIR.glob("*.json"):
try:
with open(json_file, 'r', encoding='utf-8') as f:
workflow_data = json.load(f)
workflows.append({
"filename": json_file.name,
"name": json_file.stem,
"nodes_count": len(workflow_data),
"size": json_file.stat().st_size
})
except Exception as e:
print(f"Error loading workflow {json_file.name}: {e}")
continue
return workflows
@staticmethod
def load_workflow(filename: str) -> Dict:
"""加载指定工作流"""
filepath = WORKFLOW_DIR / filename
if not filepath.exists():
raise HTTPException(status_code=404, detail=f"Workflow '{filename}' not found")
if not filepath.suffix == '.json':
raise HTTPException(status_code=400, detail="Invalid file type")
try:
with open(filepath, 'r', encoding='utf-8') as f:
return json.load(f)
except json.JSONDecodeError as e:
raise HTTPException(status_code=500, detail=f"Invalid JSON: {str(e)}")
@staticmethod
async def upload_workflow(file: UploadFile) -> Dict[str, str]:
"""上传工作流文件"""
# 验证文件名
if not file.filename or not file.filename.endswith('.json'):
raise HTTPException(status_code=400, detail="File must be a JSON file")
# 安全检查:防止路径遍历攻击
safe_filename = os.path.basename(file.filename)
if not safe_filename:
raise HTTPException(status_code=400, detail="Invalid filename")
filepath = WORKFLOW_DIR / safe_filename
# 如果文件已存在,先备份
if filepath.exists():
backup_path = WORKFLOW_DIR / f"{safe_filename}.bak"
shutil.copy2(filepath, backup_path)
# 保存文件
try:
content = await file.read()
# 验证 JSON 格式
try:
workflow_data = json.loads(content)
# 基本验证:检查是否是 ComfyUI 工作流
if not isinstance(workflow_data, dict):
raise ValueError("Workflow must be a JSON object")
# 检查是否包含必要的节点类型
has_sampler = any(
node.get("class_type") == "KSampler"
for node in workflow_data.values()
if isinstance(node, dict)
)
if not has_sampler:
raise ValueError("Invalid ComfyUI workflow: missing KSampler node")
except json.JSONDecodeError:
raise HTTPException(status_code=400, detail="Invalid JSON format")
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
# 写入文件
with open(filepath, 'w', encoding='utf-8') as f:
f.write(content.decode('utf-8'))
return {
"message": "Workflow uploaded successfully",
"filename": safe_filename,
"size": len(content)
}
except HTTPException:
raise
except Exception as e:
# 如果出错,恢复备份
backup_path = WORKFLOW_DIR / f"{safe_filename}.bak"
if backup_path.exists():
shutil.move(backup_path, filepath)
raise HTTPException(status_code=500, detail=f"Upload failed: {str(e)}")
@staticmethod
def delete_workflow(filename: str) -> Dict[str, str]:
"""删除工作流文件"""
# 安全检查
safe_filename = os.path.basename(filename)
if not safe_filename or not safe_filename.endswith('.json'):
raise HTTPException(status_code=400, detail="Invalid filename")
filepath = WORKFLOW_DIR / safe_filename
if not filepath.exists():
raise HTTPException(status_code=404, detail=f"Workflow '{filename}' not found")
# 不允许删除默认工作流
if safe_filename == "default_txt2img.json":
raise HTTPException(
status_code=403,
detail="Cannot delete default workflow"
)
try:
filepath.unlink()
return {"message": f"Workflow '{safe_filename}' deleted successfully"}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Delete failed: {str(e)}")
@staticmethod
def replace_prompt_in_workflow(workflow: Dict, prompt: str) -> Dict:
"""
在工作流中替换提示词
找到第一个 CLIPTextEncode 节点,替换其 text 字段
"""
import copy
workflow_copy = copy.deepcopy(workflow)
# 查找 CLIPTextEncode 节点(通常是正向提示词)
for node_id, node in workflow_copy.items():
if isinstance(node, dict) and node.get("class_type") == "CLIPTextEncode":
if "text" in node.get("inputs", {}):
# 替换提示词
node["inputs"]["text"] = prompt
return workflow_copy
# 如果没有找到 CLIPTextEncode 节点,抛出错误
raise ValueError("No CLIPTextEncode node found in workflow")
# 全局实例
workflow_manager = WorkflowManager()

View File

@@ -0,0 +1,172 @@
"""
LLM 模型管理服务
提供获取不同 LLM 提供商可用模型列表的功能
"""
from typing import List, Dict, Any, Optional
import requests
class LLMModelService:
"""LLM 模型管理服务"""
@staticmethod
def get_openai_models(api_key: str, base_url: Optional[str] = None) -> List[str]:
"""
获取 OpenAI 兼容 API 的模型列表
Args:
api_key: API Key
base_url: API 基础 URL默认为 OpenAI 官方 API
Returns:
模型名称列表
"""
try:
# 默认使用 OpenAI 官方 API
if not base_url:
base_url = "https://api.openai.com/v1"
# 确保 base_url 以 /v1 结尾
if not base_url.endswith('/v1'):
base_url = base_url.rstrip('/') + '/v1'
response = requests.get(
f"{base_url}/models",
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json"
},
timeout=10
)
if response.status_code != 200:
raise Exception(f"HTTP {response.status_code}: {response.text}")
data = response.json()
models = [model['id'] for model in data.get('data', [])]
# 过滤出聊天模型(可选)
chat_models = [
m for m in models
if any(keyword in m.lower() for keyword in ['gpt', 'chat'])
]
# 如果没有找到聊天模型,返回所有模型
return chat_models if chat_models else models
except Exception as e:
raise Exception(f"获取 OpenAI 模型列表失败: {str(e)}")
@staticmethod
def get_anthropic_models(api_key: str) -> List[str]:
"""
获取 Anthropic Claude 模型列表
Args:
api_key: API Key
Returns:
模型名称列表
"""
try:
# Anthropic 没有公开的模型列表 API返回已知模型
return [
"claude-3-5-sonnet-20241022",
"claude-3-5-haiku-20241022",
"claude-3-opus-20240229",
"claude-3-sonnet-20240229",
"claude-3-haiku-20240307",
"claude-2.1",
"claude-2.0",
"claude-instant-1.2"
]
except Exception as e:
raise Exception(f"获取 Anthropic 模型列表失败: {str(e)}")
@staticmethod
def get_ollama_models(base_url: str = "http://localhost:11434") -> List[str]:
"""
获取 Ollama 本地模型列表
Args:
base_url: Ollama API 地址
Returns:
模型名称列表
"""
try:
response = requests.get(
f"{base_url}/api/tags",
timeout=10
)
if response.status_code != 200:
raise Exception(f"HTTP {response.status_code}: {response.text}")
data = response.json()
models = [model['name'] for model in data.get('models', [])]
return models
except Exception as e:
raise Exception(f"获取 Ollama 模型列表失败: {str(e)}")
@staticmethod
def detect_provider(api_url: str) -> str:
"""
根据 API URL 检测提供商类型
Args:
api_url: API 地址
Returns:
提供商类型: 'openai', 'anthropic', 'ollama', 'unknown'
"""
api_url_lower = api_url.lower()
if 'openai' in api_url_lower or 'api.openai.com' in api_url_lower:
return 'openai'
elif 'anthropic' in api_url_lower or 'api.anthropic.com' in api_url_lower:
return 'anthropic'
elif 'ollama' in api_url_lower or 'localhost:11434' in api_url_lower or '127.0.0.1:11434' in api_url_lower:
return 'ollama'
elif 'siliconflow' in api_url_lower or 'silicon.cloud' in api_url_lower:
# SiliconFlow 等兼容 OpenAI API 的服务
return 'openai'
elif 'deepseek' in api_url_lower:
# DeepSeek 等兼容 OpenAI API 的服务
return 'openai'
else:
# 默认尝试 OpenAI 兼容 API
return 'openai'
@staticmethod
def get_models_by_provider(
provider: str,
api_key: str,
api_url: Optional[str] = None
) -> List[str]:
"""
根据提供商类型获取模型列表
Args:
provider: 提供商类型 ('openai', 'anthropic', 'ollama')
api_key: API Key
api_url: API 地址(可选)
Returns:
模型名称列表
"""
if provider == 'openai':
return LLMModelService.get_openai_models(api_key, api_url)
elif provider == 'anthropic':
return LLMModelService.get_anthropic_models(api_key)
elif provider == 'ollama':
base_url = api_url or "http://localhost:11434"
# 移除 /v1 后缀(如果有)
base_url = base_url.replace('/v1', '').replace('/v1/', '')
return LLMModelService.get_ollama_models(base_url)
else:
raise Exception(f"不支持的提供商: {provider}")

View File

@@ -0,0 +1,221 @@
"""
提示词组装器 (Prompt Assembler)
负责根据 SillyTavern 规范将角色卡、世界书、聊天历史等组件
拼装成最终的 LLM 消息列表。
"""
import re
from typing import List, Dict, Optional
from langchain_core.messages import SystemMessage, HumanMessage, AIMessage, BaseMessage
from models.internal import CharacterCard, ChatMessage, WorldInfoEntry
class PromptConfig:
"""提示词组装配置"""
def __init__(
self,
an_position: str = "after_history", # "before_history" or "after_history"
an_depth: int = 4,
post_history_instructions: Optional[str] = None
):
self.an_position = an_position
self.an_depth = an_depth
self.post_history_instructions = post_history_instructions
class PromptAssembler:
"""
轻量级提示词组装核心
不依赖复杂的框架,只负责纯粹的文本拼接和位置插入。
"""
# SillyTavern 的位置枚举映射
POS_WI_BEFORE = 0
POS_WI_AFTER = 1
POS_EXAMPLES_BEFORE = 2
POS_EXAMPLES_AFTER = 3
POS_AN_TOP = 4
POS_AN_BOTTOM = 5
POS_DEPTH = 6
POS_OUTLET = 7
def assemble(
self,
character: CharacterCard,
chat_history: List[ChatMessage],
user_input: str,
active_entries: List[WorldInfoEntry],
config: PromptConfig = PromptConfig()
) -> List[BaseMessage]:
"""
执行完整的提示词组装流程
Returns:
List[BaseMessage]: 准备好发送给 LLM 的消息列表
"""
# 1. 按位置分组世界书条目
grouped_entries = self._group_entries_by_position(active_entries)
# 2. 组装 Story String (包含 Pos 0-3)
story_string = self._build_story_string(character, grouped_entries)
# 3. 组装 Author's Note (包含 Pos 4-5)
authors_note_content = self._build_authors_note(grouped_entries, config.an_depth)
# 4. 处理 Chat History 并注入 Depth 条目 (Pos 6)
processed_history = self._inject_depth_entries(chat_history, grouped_entries.get(self.POS_DEPTH, []))
# 5. 准备 Outlet 替换字典 (Pos 7)
outlet_map = {entry.uid: entry.content for entry in grouped_entries.get(self.POS_OUTLET, [])}
# 6. 最终封装为 Messages
return self._wrap_to_messages(
story_string,
authors_note_content,
processed_history,
user_input,
outlet_map,
config
)
def _group_entries_by_position(self, entries: List[WorldInfoEntry]) -> Dict[int, List[WorldInfoEntry]]:
"""将激活的条目按 position 分组"""
grouped = {}
for entry in entries:
# 这里假设 entry.position 存储的是我们定义的 0-7 整数
pos = entry.position if isinstance(entry.position, int) else 1 # 默认为 wiAfter
if pos not in grouped:
grouped[pos] = []
grouped[pos].append(entry)
# 对每个组内的条目按 order 排序
for pos in grouped:
grouped[pos].sort(key=lambda x: x.order)
return grouped
def _build_story_string(self, character: CharacterCard, grouped: Dict) -> str:
"""组装故事字符串 (Story String)"""
parts = []
# Pos 0: wiBefore
for entry in grouped.get(self.POS_WI_BEFORE, []):
parts.append(entry.content)
# 角色核心信息
parts.append(f"[Character('{character.name}')]\n{character.description}\n")
parts.append(f"Personality: {character.personality}\n")
parts.append(f"Scenario: {character.scenario}\n")
# Pos 1: wiAfter
for entry in grouped.get(self.POS_WI_AFTER, []):
parts.append(entry.content)
# Pos 2: Examples Before
for entry in grouped.get(self.POS_EXAMPLES_BEFORE, []):
parts.append(entry.content)
# 示例对话
if character.mes_example:
parts.append(f"<START>\n{character.mes_example}")
# Pos 3: Examples After
for entry in grouped.get(self.POS_EXAMPLES_AFTER, []):
parts.append(entry.content)
return "\n".join(parts)
def _build_authors_note(self, grouped: Dict, depth: int) -> str:
"""组装作者笔记 (Author's Note)"""
parts = []
# Pos 4: AN Top
for entry in grouped.get(self.POS_AN_TOP, []):
parts.append(entry.content)
# AN 核心内容 (这里简化为一个占位,实际应从角色卡或设置获取)
parts.append(f"[Author's note at depth {depth}]")
# Pos 5: AN Bottom
for entry in grouped.get(self.POS_AN_BOTTOM, []):
parts.append(entry.content)
return "\n".join(parts)
def _inject_depth_entries(self, history: List[ChatMessage], depth_entries: List[WorldInfoEntry]) -> List[Dict]:
"""
在聊天历史的指定深度插入条目 (Pos 6)
返回一个包含 role 和 content 的字典列表,方便后续转换
"""
# 先将历史转换为中间格式
msg_list = []
for msg in history:
msg_list.append({"role": "user" if msg.is_user else "assistant", "content": msg.mes})
# 按 depth 分组插入
# d0 通常指最新用户输入之前,即列表末尾
for entry in depth_entries:
depth = entry.depth if entry.depth is not None else 0
# 计算插入索引 (从后往前数)
insert_index = max(0, len(msg_list) - depth)
# 确定角色
role_map = {"system": "system", "user": "user", "assistant": "assistant"}
role = role_map.get(str(entry.position).split('_')[-1] if '_' in str(entry.position) else "system", "system")
msg_list.insert(insert_index, {"role": "system", "content": entry.content})
return msg_list
def _replace_outlets(self, text: str, outlet_map: Dict[str, str]) -> str:
"""执行 Outlet 宏替换 (Pos 7)"""
def replacer(match):
uid = match.group(1)
return outlet_map.get(uid, "")
# 匹配 {{outlet::UID}}
return re.sub(r"\{\{outlet::([^}]+)\}\}", replacer, text)
def _wrap_to_messages(
self,
story_string: str,
an_content: str,
history: List[Dict],
user_input: str,
outlet_map: Dict[str, str],
config: PromptConfig
) -> List[BaseMessage]:
"""将组装好的文本块封装为 LangChain Messages"""
messages = []
# 1. System Message (Story String + Outlet 替换)
final_story = self._replace_outlets(story_string, outlet_map)
if final_story:
messages.append(SystemMessage(content=final_story))
# 2. Author's Note (根据配置位置插入)
if an_content and config.an_position == "before_history":
messages.append(SystemMessage(content=self._replace_outlets(an_content, outlet_map)))
# 3. Chat History
for msg_data in history:
if msg_data["role"] == "user":
messages.append(HumanMessage(content=msg_data["content"]))
elif msg_data["role"] == "assistant":
messages.append(AIMessage(content=msg_data["content"]))
else:
messages.append(SystemMessage(content=msg_data["content"]))
# 4. Author's Note (如果在 History 之后)
if an_content and config.an_position == "after_history":
messages.append(SystemMessage(content=self._replace_outlets(an_content, outlet_map)))
# 5. Post-History Instructions & User Input
final_input = user_input
if config.post_history_instructions:
final_input = f"{config.post_history_instructions}\n\n{user_input}"
messages.append(HumanMessage(content=final_input))
return messages

View File

@@ -0,0 +1,381 @@
"""
World Book Service
世界书服务层 - 处理世界书及条目的 CRUD 操作
"""
import json
import os
import uuid
from pathlib import Path
from typing import List, Dict, Any, Optional
from datetime import datetime
from models.internal import WorldInfo, WorldInfoEntry, ActivationType
from models.converters import WorldBookConverter
from core.config import settings
class WorldBookService:
"""世界书服务类"""
@staticmethod
def _get_worldbook_path(name: str) -> Path:
"""获取世界书文件路径"""
return settings.WORLDBOOKS_PATH / f"{name}.json"
@staticmethod
def _load_worldbook(name: str) -> Optional[Dict[str, Any]]:
"""加载世界书 JSON 文件"""
path = WorldBookService._get_worldbook_path(name)
if not path.exists():
return None
try:
with open(path, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception as e:
raise ValueError(f"Failed to load worldbook '{name}': {str(e)}")
@staticmethod
def _save_worldbook(name: str, data: Dict[str, Any]):
"""保存世界书到 JSON 文件"""
path = WorldBookService._get_worldbook_path(name)
try:
with open(path, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
except Exception as e:
raise ValueError(f"Failed to save worldbook '{name}': {str(e)}")
@staticmethod
def list_worldbooks() -> List[Dict[str, Any]]:
"""
获取所有世界书的列表(仅基本信息)
Returns:
世界书列表,每个包含 name, description, entries_count 等
"""
worldbooks = []
for json_file in settings.WORLDBOOKS_PATH.glob("*.json"):
try:
with open(json_file, 'r', encoding='utf-8') as f:
data = json.load(f)
worldbooks.append({
"name": data.get("name", json_file.stem),
"description": data.get("description", ""),
"entries_count": len(data.get("entries", [])),
"createdAt": data.get("createdAt", 0),
"updatedAt": data.get("updatedAt", 0)
})
except Exception as e:
print(f"Error loading worldbook {json_file.name}: {e}")
continue
# 按更新时间排序
worldbooks.sort(key=lambda x: x.get("updatedAt", 0), reverse=True)
return worldbooks
@staticmethod
def get_worldbook(name: str) -> Dict[str, Any]:
"""
获取指定世界书的完整数据
Args:
name: 世界书名称
Returns:
世界书完整数据
"""
data = WorldBookService._load_worldbook(name)
if not data:
raise FileNotFoundError(f"Worldbook '{name}' not found")
return data
@staticmethod
def create_worldbook(name: str, description: str = "") -> Dict[str, Any]:
"""
创建新世界书
Args:
name: 世界书名称
description: 世界书描述
Returns:
创建的世界书数据
"""
# 检查是否已存在
if WorldBookService._get_worldbook_path(name).exists():
raise ValueError(f"Worldbook '{name}' already exists")
now = int(datetime.now().timestamp())
worldbook_data = {
"id": str(uuid.uuid4()),
"name": name,
"description": description,
"entries": [],
"createdAt": now,
"updatedAt": now,
"version": 1
}
WorldBookService._save_worldbook(name, worldbook_data)
return worldbook_data
@staticmethod
def update_worldbook(name: str, description: Optional[str] = None) -> Dict[str, Any]:
"""
更新世界书基本信息
Args:
name: 世界书名称
description: 新的描述(可选)
Returns:
更新后的世界书数据
"""
data = WorldBookService._load_worldbook(name)
if not data:
raise FileNotFoundError(f"Worldbook '{name}' not found")
if description is not None:
data["description"] = description
data["updatedAt"] = int(datetime.now().timestamp())
WorldBookService._save_worldbook(name, data)
return data
@staticmethod
def delete_worldbook(name: str) -> bool:
"""
删除世界书
Args:
name: 世界书名称
Returns:
是否删除成功
"""
path = WorldBookService._get_worldbook_path(name)
if not path.exists():
raise FileNotFoundError(f"Worldbook '{name}' not found")
path.unlink()
return True
@staticmethod
def list_entries(name: str) -> List[Dict[str, Any]]:
"""
获取世界书的所有条目
Args:
name: 世界书名称
Returns:
条目列表
"""
data = WorldBookService._load_worldbook(name)
if not data:
raise FileNotFoundError(f"Worldbook '{name}' not found")
return data.get("entries", [])
@staticmethod
def get_entry(name: str, uid: str) -> Dict[str, Any]:
"""
获取世界书的指定条目
Args:
name: 世界书名称
uid: 条目 UID
Returns:
条目数据
"""
data = WorldBookService._load_worldbook(name)
if not data:
raise FileNotFoundError(f"Worldbook '{name}' not found")
for entry in data.get("entries", []):
if entry.get("uid") == uid:
return entry
raise FileNotFoundError(f"Entry '{uid}' not found in worldbook '{name}'")
@staticmethod
def create_entry(name: str, entry_data: Dict[str, Any]) -> Dict[str, Any]:
"""
在世界书中创建新条目
Args:
name: 世界书名称
entry_data: 条目数据(不包含 uid, createdAt, updatedAt
Returns:
创建的条目数据
"""
data = WorldBookService._load_worldbook(name)
if not data:
raise FileNotFoundError(f"Worldbook '{name}' not found")
# 生成 UID 和时间戳
now = int(datetime.now().timestamp())
new_entry = {
"uid": str(uuid.uuid4()),
"key": entry_data.get("key", []),
"keysecondary": entry_data.get("keysecondary", []),
"content": entry_data.get("content", ""),
"activationType": entry_data.get("activationType", ActivationType.KEYWORD.value),
"logicExpression": entry_data.get("logicExpression"),
"ragConfig": entry_data.get("ragConfig"),
"order": entry_data.get("order", 0),
"position": entry_data.get("position", "after_char"),
"depth": entry_data.get("depth"),
"probability": entry_data.get("probability", 100),
"group": entry_data.get("group", []),
"disable": entry_data.get("disable", False),
"createdAt": now,
"updatedAt": now
}
data["entries"].append(new_entry)
data["updatedAt"] = now
WorldBookService._save_worldbook(name, data)
return new_entry
@staticmethod
def update_entry(name: str, uid: str, entry_data: Dict[str, Any]) -> Dict[str, Any]:
"""
更新世界书的指定条目
Args:
name: 世界书名称
uid: 条目 UID
entry_data: 更新的字段
Returns:
更新后的条目数据
"""
data = WorldBookService._load_worldbook(name)
if not data:
raise FileNotFoundError(f"Worldbook '{name}' not found")
for i, entry in enumerate(data.get("entries", [])):
if entry.get("uid") == uid:
# 更新字段
for key, value in entry_data.items():
if key not in ["uid", "createdAt"]: # 不修改 UID 和创建时间
entry[key] = value
# 更新时间戳
entry["updatedAt"] = int(datetime.now().timestamp())
data["entries"][i] = entry
data["updatedAt"] = entry["updatedAt"]
WorldBookService._save_worldbook(name, data)
return entry
raise FileNotFoundError(f"Entry '{uid}' not found in worldbook '{name}'")
@staticmethod
def delete_entry(name: str, uid: str) -> bool:
"""
删除世界书的指定条目
Args:
name: 世界书名称
uid: 条目 UID
Returns:
是否删除成功
"""
data = WorldBookService._load_worldbook(name)
if not data:
raise FileNotFoundError(f"Worldbook '{name}' not found")
original_length = len(data.get("entries", []))
data["entries"] = [e for e in data.get("entries", []) if e.get("uid") != uid]
if len(data["entries"]) == original_length:
raise FileNotFoundError(f"Entry '{uid}' not found in worldbook '{name}'")
data["updatedAt"] = int(datetime.now().timestamp())
WorldBookService._save_worldbook(name, data)
return True
@staticmethod
def import_from_sillytavern(name: str, st_data: Dict[str, Any]) -> Dict[str, Any]:
"""
从 SillyTavern 格式导入世界书
Args:
name: 世界书名称
st_data: SillyTavern 格式的世界书数据
Returns:
转换后的内部格式世界书数据
"""
# 使用转换器进行转换
worldbook_data = WorldBookConverter.st_to_internal(st_data, name)
# 保存到文件
WorldBookService._save_worldbook(name, worldbook_data)
return worldbook_data
@staticmethod
def import_internal_format(name: str, internal_data: Dict[str, Any]) -> Dict[str, Any]:
"""
直接导入内部格式的世界书(无需转换)
Args:
name: 世界书名称
internal_data: 内部格式的世界书数据
Returns:
内部格式世界书数据
"""
# 确保包含必要的字段
if "name" not in internal_data:
internal_data["name"] = name
# 规范化所有条目,确保有 trigger_config
if "entries" in internal_data and isinstance(internal_data["entries"], list):
normalized_entries = []
for entry in internal_data["entries"]:
if isinstance(entry, dict):
normalized_entry = WorldBookConverter.normalize_entry(entry)
normalized_entries.append(normalized_entry)
internal_data["entries"] = normalized_entries
# 保存文件
WorldBookService._save_worldbook(name, internal_data)
return internal_data
@staticmethod
def export_to_sillytavern(name: str) -> Dict[str, Any]:
"""
导出为 SillyTavern 格式
Args:
name: 世界书名称
Returns:
SillyTavern 格式的世界书数据
"""
data = WorldBookService._load_worldbook(name)
if not data:
raise FileNotFoundError(f"Worldbook '{name}' not found")
# 使用转换器进行转换
st_data = WorldBookConverter.internal_to_st(data)
return st_data
# 全局实例
worldbook_service = WorldBookService()

17
backend/utils/__init__.py Normal file
View File

@@ -0,0 +1,17 @@
"""
工具类包
提供通用的工具函数和辅助类如文件操作、LLM 调用封装等。
"""
from .file_utils import get_all_roles_and_chats, read_jsonl_file, write_jsonl_file
from .llm_client import get_llm, get_fast_llm, get_creative_llm, get_streaming_llm
__all__ = [
'get_all_roles_and_chats',
'read_jsonl_file',
'write_jsonl_file',
'get_llm',
'get_fast_llm',
'get_creative_llm',
'get_streaming_llm',
]

130
backend/utils/file_utils.py Normal file
View File

@@ -0,0 +1,130 @@
"""
文件操作工具函数
提供文件和目录操作的通用工具
"""
from pathlib import Path
from typing import Dict, List
import json
import logging
logger = logging.getLogger(__name__)
def get_all_roles_and_chats(data_path: Path) -> Dict[str, List[str]]:
"""
获取所有角色和聊天列表
Args:
data_path: 数据目录路径
Returns:
Dict[str, List[str]]: 字典结构,键是角色名称,值是该角色的聊天列表
"""
chat_dir = data_path / "chat"
result = {}
if not chat_dir.exists():
logger.warning(f"聊天目录不存在: {chat_dir}")
return result
for entry in chat_dir.iterdir():
try:
if entry.is_dir():
jsonl_files = []
for file in entry.iterdir():
if file.is_file() and file.suffix == '.jsonl':
jsonl_files.append(file.stem)
if jsonl_files:
result[entry.name] = jsonl_files
except Exception as e:
logger.error(f"处理文件夹 {entry.name} 时出错: {str(e)}")
continue
return result
def ensure_directory_exists(path: Path) -> None:
"""
确保目录存在,如果不存在则创建
Args:
path: 目录路径
"""
path.mkdir(parents=True, exist_ok=True)
def read_json_file(file_path: Path) -> dict:
"""
读取 JSON 文件
Args:
file_path: 文件路径
Returns:
dict: JSON 数据
"""
with open(file_path, 'r', encoding='utf-8') as f:
return json.load(f)
def write_json_file(file_path: Path, data: dict) -> None:
"""
写入 JSON 文件
Args:
file_path: 文件路径
data: 要写入的数据
"""
with open(file_path, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
def read_jsonl_file(file_path: Path) -> List[dict]:
"""
读取 JSONL 文件
Args:
file_path: 文件路径
Returns:
List[dict]: JSONL 数据列表
"""
lines = []
with open(file_path, 'r', encoding='utf-8') as f:
for line in f:
line = line.strip()
if line:
try:
lines.append(json.loads(line))
except json.JSONDecodeError as e:
logger.warning(f"解析 JSONL 行失败: {e}")
return lines
def append_to_jsonl_file(file_path: Path, data: dict) -> None:
"""
追加数据到 JSONL 文件
Args:
file_path: 文件路径
data: 要追加的数据
"""
with open(file_path, 'a', encoding='utf-8') as f:
f.write(json.dumps(data, ensure_ascii=False) + '\n')
def write_jsonl_file(file_path: Path, data_list: List[dict]) -> None:
"""
写入 JSONL 文件 (覆盖模式)
Args:
file_path: 文件路径
data_list: 数据列表
"""
with open(file_path, 'w', encoding='utf-8') as f:
for data in data_list:
f.write(json.dumps(data, ensure_ascii=False) + '\n')

View File

@@ -0,0 +1,88 @@
"""
LLM 客户端工具
提供统一的 LLM 接口,支持多种模型提供商。
使用 LangChain 的 ChatModel 抽象,简化不同厂商 API 的调用。
"""
from typing import Optional
from langchain_core.language_models.chat_models import BaseChatModel
from core.config import settings
def get_llm(
provider: str = "openai",
model: Optional[str] = None,
temperature: float = 0.7,
streaming: bool = False,
**kwargs
) -> BaseChatModel:
"""
获取 LLM 实例
Args:
provider: 模型提供商 ("openai", "anthropic", "ollama")
model: 模型名称 (如果不指定则使用配置中的默认值)
temperature: 温度参数 (0-2)
streaming: 是否启用流式输出
**kwargs: 其他参数传递给模型
Returns:
BaseChatModel: LangChain 的聊天模型实例
"""
if provider == "openai":
from langchain_openai import ChatOpenAI
return ChatOpenAI(
model=model or settings.OPENAI_MODEL or "gpt-4",
temperature=temperature,
api_key=settings.OPENAI_API_KEY,
streaming=streaming,
**kwargs
)
elif provider == "anthropic":
from langchain_anthropic import ChatAnthropic
return ChatAnthropic(
model=model or settings.ANTHROPIC_MODEL or "claude-3-opus-20240229",
temperature=temperature,
api_key=settings.ANTHROPIC_API_KEY,
max_tokens=kwargs.pop("max_tokens", 4096),
streaming=streaming,
**kwargs
)
elif provider == "ollama":
try:
from langchain_ollama import ChatOllama
return ChatOllama(
model=model or settings.OLLAMA_MODEL or "llama3",
base_url=settings.OLLAMA_BASE_URL or "http://localhost:11434",
temperature=temperature,
**kwargs
)
except ImportError:
raise ImportError(
"langchain-ollama not installed. Run: pip install langchain-ollama"
)
else:
raise ValueError(f"Unsupported provider: {provider}. Use 'openai', 'anthropic', or 'ollama'")
# 便捷函数 - 常用配置
def get_fast_llm(provider: str = "openai") -> BaseChatModel:
"""获取快速响应的 LLM (低温度,适合事实性问题)"""
return get_llm(provider, temperature=0.3)
def get_creative_llm(provider: str = "openai") -> BaseChatModel:
"""获取创造性 LLM (高温度,适合创意写作)"""
return get_llm(provider, temperature=0.9)
def get_streaming_llm(provider: str = "openai") -> BaseChatModel:
"""获取支持流式输出的 LLM"""
return get_llm(provider, streaming=True)