完成请求推送,但组装mes还有问题
This commit is contained in:
@@ -1,18 +1,26 @@
|
||||
from fastapi import APIRouter
|
||||
from .routes import presetsRoute, chatsRoute, worldbooksRoute, apiConfigRoute, charactersRoute
|
||||
from .routes import presetsRoute, chatsRoute, worldbooksRoute, apiConfigRoute, charactersRoute, chatWsRoute, tokenUsageRoute, imageGalleryRoute, regexRoute
|
||||
from utils.file_utils import get_all_roles_and_chats
|
||||
from core.config import settings
|
||||
from pathlib import Path
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# 注册子路由
|
||||
# 注册子路由(HTTP路由)
|
||||
router.include_router(presetsRoute.router)
|
||||
router.include_router(chatsRoute.router)
|
||||
router.include_router(worldbooksRoute.router)
|
||||
router.include_router(apiConfigRoute.router)
|
||||
router.include_router(charactersRoute.router)
|
||||
|
||||
# ✅ 注册新增路由
|
||||
router.include_router(tokenUsageRoute.router)
|
||||
router.include_router(imageGalleryRoute.router)
|
||||
router.include_router(regexRoute.router)
|
||||
|
||||
# ✅ 注册 WebSocket 路由(必须在 HTTP 路由之后,避免路径冲突)
|
||||
router.include_router(chatWsRoute.router)
|
||||
|
||||
|
||||
# 保留原有的其他路由
|
||||
@router.get("/tool_bar/get_all_role_and_chat")
|
||||
|
||||
@@ -2,24 +2,28 @@ from fastapi import APIRouter, HTTPException, UploadFile, File
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Dict, Optional, List, Any
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
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)
|
||||
|
||||
# 调试信息:打印配置目录路径
|
||||
print(f"[API Config] DATA_PATH: {settings.DATA_PATH}", file=sys.stderr)
|
||||
print(f"[API Config] CONFIG_DIR: {CONFIG_DIR}", file=sys.stderr)
|
||||
print(f"[API Config] CONFIG_DIR exists: {CONFIG_DIR.exists()}", file=sys.stderr)
|
||||
if CONFIG_DIR.exists():
|
||||
config_files = list(CONFIG_DIR.glob("*.json"))
|
||||
print(f"[API Config] Found {len(config_files)} config files", file=sys.stderr)
|
||||
for f in config_files:
|
||||
print(f" - {f.name}", file=sys.stderr)
|
||||
|
||||
|
||||
class ApiConfigItem(BaseModel):
|
||||
"""单个 API 配置项"""
|
||||
@@ -50,32 +54,8 @@ class ProfileResponse(BaseModel):
|
||||
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]:
|
||||
"""加载配置文件"""
|
||||
@@ -119,29 +99,28 @@ def get_all_profiles():
|
||||
|
||||
@router.get("/profiles/{profile_id}", response_model=ProfileResponse)
|
||||
def get_profile(profile_id: str):
|
||||
"""获取单个配置文件(API Key 已脱敏)"""
|
||||
"""获取单个配置文件(明文存储,不返回 API Key)"""
|
||||
profile = load_profile(profile_id)
|
||||
if not profile:
|
||||
raise HTTPException(status_code=404, detail="配置文件不存在")
|
||||
|
||||
# 脱敏所有 API Key
|
||||
masked_apis = {}
|
||||
# 移除 API Key 字段,不返回给前端
|
||||
safe_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
|
||||
safe_config = api_config.copy()
|
||||
safe_config.pop("apiKey", None)
|
||||
safe_apis[category] = safe_config
|
||||
|
||||
return {
|
||||
"id": profile.get("id", profile_id),
|
||||
"name": profile.get("name", profile_id),
|
||||
"apis": masked_apis
|
||||
"apis": safe_apis
|
||||
}
|
||||
|
||||
|
||||
@router.post("/profiles", response_model=ProfileResponse)
|
||||
def create_or_update_profile(request: ProfileSaveRequest):
|
||||
"""创建或更新配置文件(增量更新)"""
|
||||
"""创建或更新配置文件(增量更新,明文存储 API Key)"""
|
||||
# 加载现有配置
|
||||
existing_profile = load_profile(request.profileId)
|
||||
|
||||
@@ -150,16 +129,11 @@ def create_or_update_profile(request: ProfileSaveRequest):
|
||||
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)
|
||||
# 如果前端传入了空的 apiKey,保留原有的 key
|
||||
if api_config.apiKey == "" and category in existing_profile.get("apis", {}):
|
||||
existing_key = existing_profile["apis"][category].get("apiKey", "")
|
||||
if existing_key:
|
||||
api_config_dict["apiKey"] = existing_key
|
||||
|
||||
# 更新配置
|
||||
if "apis" not in existing_profile:
|
||||
@@ -177,28 +151,25 @@ def create_or_update_profile(request: ProfileSaveRequest):
|
||||
"apis": {}
|
||||
}
|
||||
|
||||
# 添加所有 API 配置
|
||||
# 添加所有 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 = {}
|
||||
# 返回不包含 API Key 的数据
|
||||
safe_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
|
||||
safe_config = api_config.copy()
|
||||
safe_config.pop("apiKey", None)
|
||||
safe_apis[category] = safe_config
|
||||
|
||||
return {
|
||||
"id": profile_data.get("id", request.profileId),
|
||||
"name": profile_data.get("name", request.profileId),
|
||||
"apis": masked_apis
|
||||
"apis": safe_apis
|
||||
}
|
||||
|
||||
|
||||
@@ -217,13 +188,31 @@ def delete_profile(profile_id: str):
|
||||
def test_connection(api_config: ApiConfigItem):
|
||||
"""测试 API 连接并获取模型列表"""
|
||||
try:
|
||||
api_key_to_use = api_config.apiKey or ""
|
||||
|
||||
# 如果 API Key 为空,尝试从已保存的配置中获取
|
||||
if not api_key_to_use and api_config.category:
|
||||
# 遍历所有配置文件,找到包含该 category 的配置
|
||||
for config_file in CONFIG_DIR.glob("*.json"):
|
||||
try:
|
||||
with open(config_file, 'r', encoding='utf-8') as f:
|
||||
profile = json.load(f)
|
||||
|
||||
# 检查是否包含该 category
|
||||
if api_config.category in profile.get("apis", {}):
|
||||
api_key_to_use = profile["apis"][api_config.category].get("apiKey", "")
|
||||
if api_key_to_use:
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# 检测提供商类型
|
||||
provider = LLMModelService.detect_provider(api_config.apiUrl)
|
||||
|
||||
# 获取模型列表
|
||||
models = LLMModelService.get_models_by_provider(
|
||||
provider=provider,
|
||||
api_key=api_config.apiKey or "",
|
||||
api_key=api_key_to_use,
|
||||
api_url=api_config.apiUrl
|
||||
)
|
||||
|
||||
|
||||
420
backend/api/routes/chatWsRoute.py
Normal file
420
backend/api/routes/chatWsRoute.py
Normal file
@@ -0,0 +1,420 @@
|
||||
"""
|
||||
聊天 WebSocket 路由
|
||||
处理实时对话生成
|
||||
"""
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
from typing import Dict, Any
|
||||
import json
|
||||
import asyncio
|
||||
|
||||
try:
|
||||
from backend.services.chat_workflow_service import ChatWorkflowService
|
||||
from backend.services.chat_service import ChatService
|
||||
from backend.services.task_queue_manager import task_queue_manager
|
||||
from backend.core.config import settings
|
||||
except ImportError:
|
||||
from services.chat_workflow_service import ChatWorkflowService
|
||||
from services.chat_service import ChatService
|
||||
from services.task_queue_manager import task_queue_manager
|
||||
from core.config import settings
|
||||
|
||||
router = APIRouter(prefix="/chat", tags=["chat-websocket"])
|
||||
|
||||
# 初始化服务
|
||||
workflow_service = ChatWorkflowService()
|
||||
chat_service = ChatService(settings.DATA_PATH)
|
||||
|
||||
# ✅ 全局变量:用于存储需要中断的聊天会话
|
||||
interrupt_flags: Dict[str, bool] = {}
|
||||
|
||||
|
||||
@router.websocket("/{role_name}/{chat_name}/ws")
|
||||
async def websocket_chat_endpoint(
|
||||
websocket: WebSocket,
|
||||
role_name: str,
|
||||
chat_name: str
|
||||
):
|
||||
"""
|
||||
WebSocket 聊天端点
|
||||
|
||||
接收前端发送的完整对话请求,调用工作流生成回复,支持流式输出
|
||||
"""
|
||||
await websocket.accept()
|
||||
print(f"\n{'='*80}")
|
||||
print(f"[WebSocket] 📡 连接建立: {role_name}/{chat_name}")
|
||||
print(f"{'='*80}\n")
|
||||
|
||||
chat_id = f"{role_name}/{chat_name}"
|
||||
|
||||
try:
|
||||
while True:
|
||||
# 1. 接收前端消息
|
||||
data = await websocket.receive_text()
|
||||
request_data = json.loads(data)
|
||||
|
||||
# ✅ 检查是否是取消任务的请求
|
||||
if request_data.get("type") == "cancel_task":
|
||||
task_id = request_data.get("taskId")
|
||||
print(f"[WebSocket] ❌ 收到取消任务请求: {task_id}")
|
||||
|
||||
# ✅ 特殊处理:如果是 LLM 生成任务,需要中断当前流式生成
|
||||
if task_id == "current_llm_generation":
|
||||
print(f"[WebSocket] 🛑 正在终止 LLM 流式生成...")
|
||||
# TODO: 实现 LLM 生成的中断逻辑
|
||||
# 目前只能通过关闭连接来终止
|
||||
|
||||
await websocket.send_json({
|
||||
"type": "task_cancelled",
|
||||
"taskId": task_id,
|
||||
"success": True,
|
||||
"message": "LLM 生成已终止"
|
||||
})
|
||||
else:
|
||||
# 取消其他类型的任务(图像生成、动态表格等)
|
||||
success = await task_queue_manager.cancel_task(task_id)
|
||||
|
||||
await websocket.send_json({
|
||||
"type": "task_cancelled",
|
||||
"taskId": task_id,
|
||||
"success": success
|
||||
})
|
||||
print(f"[WebSocket] ✅ 任务取消结果: {success}")
|
||||
continue
|
||||
|
||||
print(f"\n{'-'*80}")
|
||||
print(f"[WebSocket] 📨 收到请求:")
|
||||
print(f" - Floor: {request_data.get('floor')}")
|
||||
print(f" - Role: {request_data.get('currentRole')}")
|
||||
print(f" - Chat: {request_data.get('currentChat')}")
|
||||
print(f" - Stream: {request_data.get('stream', False)}")
|
||||
print(f" - Message Length: {len(request_data.get('mes', ''))}")
|
||||
|
||||
# ✅ 打印 API 配置信息(隐藏密钥)
|
||||
api_config = request_data.get('apiConfig', {})
|
||||
current_profile = request_data.get('currentProfile', {})
|
||||
profile_id = current_profile.get('id') if isinstance(current_profile, dict) else None
|
||||
|
||||
print(f" - Profile ID: {profile_id or 'N/A'}")
|
||||
print(f" - API URL: {api_config.get('api_url', 'N/A')[:50]}..." if len(api_config.get('api_url', '')) > 50 else f" - API URL: {api_config.get('api_url', 'N/A')}")
|
||||
print(f" - Model: {api_config.get('model', 'N/A')}")
|
||||
|
||||
# ✅ 始终从配置文件中读取 API Key(不信任前端传来的 Key)
|
||||
if profile_id:
|
||||
try:
|
||||
from .apiConfigRoute import load_profile
|
||||
|
||||
profile = load_profile(profile_id)
|
||||
if profile:
|
||||
# 找到 mainLLM 的配置
|
||||
main_llm_config = profile.get('apis', {}).get('mainLLM', {})
|
||||
api_key = main_llm_config.get('apiKey', '')
|
||||
|
||||
if api_key:
|
||||
# 使用明文 API Key
|
||||
api_config['api_key'] = api_key
|
||||
request_data['apiConfig'] = api_config
|
||||
print(f" - API Key: ✅ 已从配置文件加载")
|
||||
else:
|
||||
print(f" - API Key: ⚠️ 配置文件中未找到 Key")
|
||||
else:
|
||||
print(f" - API Key: ❌ 无法加载配置文件: {profile_id}")
|
||||
except Exception as e:
|
||||
print(f" - API Key: ❌ 加载失败: {str(e)}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
else:
|
||||
print(f" - API Key: ⚠️ 未提供 profileId,无法加载")
|
||||
|
||||
print(f"{'-'*80}\n")
|
||||
|
||||
# 2. 提取流式输出标志
|
||||
stream_output = request_data.get("stream", False)
|
||||
|
||||
if stream_output:
|
||||
# === 真正的流式输出模式 ===
|
||||
print(f"[WebSocket] 🌊 进入流式处理模式")
|
||||
await _handle_stream_chat(
|
||||
websocket, role_name, chat_name, request_data, workflow_service
|
||||
)
|
||||
else:
|
||||
# === 非流式输出模式 ===
|
||||
print(f"[WebSocket] 📦 进入非流式处理模式")
|
||||
result = await workflow_service.process_chat_request(request_data)
|
||||
|
||||
if result["success"]:
|
||||
content = result["content"]
|
||||
|
||||
print(f"\n[WebSocket] ✨ 生成成功,内容长度: {len(content)}")
|
||||
|
||||
# ✅ 发送激活的世界书条目信息
|
||||
active_entries = result.get("activeEntries", [])
|
||||
print(f"[WebSocket] 📚 发送世界书激活信息: {len(active_entries)} 个条目")
|
||||
await websocket.send_json({
|
||||
"type": "worldbook_active",
|
||||
"entries": active_entries
|
||||
})
|
||||
|
||||
# ✅ 发送任务ID信息
|
||||
task_ids = result.get("taskIds", {})
|
||||
if task_ids.get("imageWorkflow") or task_ids.get("dynamicTable"):
|
||||
print(f"[WebSocket] 📋 发送任务ID信息: {task_ids}")
|
||||
await websocket.send_json({
|
||||
"type": "tasks_created",
|
||||
"tasks": task_ids
|
||||
})
|
||||
|
||||
# 一次性发送完整内容
|
||||
print(f"[WebSocket] 📤 发送完整内容 (chunk)")
|
||||
await websocket.send_json({
|
||||
"type": "chunk",
|
||||
"content": content
|
||||
})
|
||||
|
||||
print(f"[WebSocket] ✅ 发送完成信号")
|
||||
await websocket.send_json({
|
||||
"type": "complete"
|
||||
})
|
||||
|
||||
# 保存消息
|
||||
print(f"[WebSocket] 💾 保存消息到文件...")
|
||||
await _save_messages(role_name, chat_name, request_data, content)
|
||||
print(f"[WebSocket] ✅ 消息保存完成\n")
|
||||
else:
|
||||
error_msg = result["error"]
|
||||
print(f"[WebSocket] ❌ 处理失败: {error_msg}")
|
||||
await websocket.send_json({
|
||||
"type": "error",
|
||||
"message": error_msg
|
||||
})
|
||||
|
||||
except WebSocketDisconnect:
|
||||
print(f"\n{'='*80}")
|
||||
print(f"[WebSocket] 🔌 连接断开: {role_name}/{chat_name}")
|
||||
print(f"{'='*80}\n")
|
||||
except Exception as e:
|
||||
print(f"\n{'='*80}")
|
||||
print(f"[WebSocket] ⚠️ 错误: {str(e)}")
|
||||
print(f"{'='*80}\n")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
try:
|
||||
await websocket.send_json({
|
||||
"type": "error",
|
||||
"message": f"服务器错误: {str(e)}"
|
||||
})
|
||||
except:
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
await websocket.close()
|
||||
except:
|
||||
pass
|
||||
|
||||
|
||||
async def _handle_stream_chat(
|
||||
websocket: WebSocket,
|
||||
role_name: str,
|
||||
chat_name: str,
|
||||
request_data: Dict[str, Any],
|
||||
workflow_service
|
||||
):
|
||||
"""
|
||||
处理流式聊天请求
|
||||
|
||||
Args:
|
||||
websocket: WebSocket 连接
|
||||
role_name: 角色名
|
||||
chat_name: 聊天名
|
||||
request_data: 请求数据
|
||||
workflow_service: 工作流服务实例
|
||||
"""
|
||||
try:
|
||||
print(f"[StreamChat] 🚀 开始流式处理")
|
||||
|
||||
# ✅ 第1步:加载角色卡
|
||||
current_role = request_data.get("currentRole")
|
||||
character_data = request_data.get("characterData")
|
||||
|
||||
if character_data:
|
||||
try:
|
||||
from backend.models.internal import CharacterCard
|
||||
except ImportError:
|
||||
from models.internal import CharacterCard
|
||||
character = CharacterCard(**character_data)
|
||||
else:
|
||||
from backend.services.character_service import CharacterService
|
||||
character_service = CharacterService()
|
||||
character = character_service.get_character_by_name(current_role)
|
||||
|
||||
if not character:
|
||||
print(f"[StreamChat] ❌ 错误: 无法加载角色 '{current_role}'")
|
||||
await websocket.send_json({
|
||||
"type": "error",
|
||||
"message": f"角色 '{current_role}' 不存在"
|
||||
})
|
||||
return
|
||||
|
||||
print(f"[StreamChat] ✅ 已加载角色卡: {character.name}")
|
||||
|
||||
# ✅ 第2步:激活世界书条目(在LLM调用之前)
|
||||
print(f"[StreamChat] 📚 正在激活世界书条目...")
|
||||
active_entries = await workflow_service._collect_and_activate_worldbooks(
|
||||
request_data,
|
||||
character
|
||||
)
|
||||
|
||||
# ✅ 发送激活的世界书条目信息(在LLM调用前)
|
||||
if active_entries:
|
||||
print(f"[StreamChat] 📤 发送世界书激活信息: {len(active_entries)} 个条目")
|
||||
await websocket.send_json({
|
||||
"type": "worldbook_active",
|
||||
"entries": active_entries
|
||||
})
|
||||
|
||||
# ✅ TODO: RAG检索(暂时为空,待实现)
|
||||
rag_results = []
|
||||
if rag_results:
|
||||
print(f"[StreamChat] 🔍 发送 RAG 检索结果: {len(rag_results)} 条")
|
||||
await websocket.send_json({
|
||||
"type": "rag_results",
|
||||
"results": rag_results
|
||||
})
|
||||
|
||||
# ✅ 第2步:启动并行任务(在LLM调用前创建任务ID)
|
||||
options = request_data.get("options", {})
|
||||
task_ids = {
|
||||
"imageWorkflow": None,
|
||||
"dynamicTable": None
|
||||
}
|
||||
|
||||
if options.get("imageWorkflow", False):
|
||||
import uuid
|
||||
chat_id = f"{role_name}/{chat_name}"
|
||||
task_ids["imageWorkflow"] = f"img_{uuid.uuid4().hex[:8]}"
|
||||
from backend.services.task_queue_manager import task_queue_manager, TaskType
|
||||
await task_queue_manager.add_task(task_ids["imageWorkflow"], TaskType.IMAGE_WORKFLOW, chat_id)
|
||||
|
||||
if options.get("dynamicTable", False):
|
||||
import uuid
|
||||
chat_id = f"{role_name}/{chat_name}"
|
||||
task_ids["dynamicTable"] = f"tbl_{uuid.uuid4().hex[:8]}"
|
||||
from backend.services.task_queue_manager import task_queue_manager, TaskType
|
||||
await task_queue_manager.add_task(task_ids["dynamicTable"], TaskType.DYNAMIC_TABLE, chat_id)
|
||||
|
||||
# ✅ 发送任务ID信息(在LLM调用前)
|
||||
if task_ids.get("imageWorkflow") or task_ids.get("dynamicTable"):
|
||||
print(f"[StreamChat] 📤 发送任务ID信息: {task_ids}")
|
||||
await websocket.send_json({
|
||||
"type": "tasks_created",
|
||||
"tasks": task_ids
|
||||
})
|
||||
|
||||
# ✅ 第3步:调用LLM流式生成
|
||||
chunk_count = 0
|
||||
result = await workflow_service.process_chat_request_stream(
|
||||
request_data,
|
||||
on_chunk=lambda chunk: asyncio.create_task(
|
||||
_send_chunk_with_log(websocket, chunk, chunk_count)
|
||||
)
|
||||
)
|
||||
|
||||
if result["success"]:
|
||||
content = result["content"]
|
||||
|
||||
print(f"\n[StreamChat] ✨ 流式生成成功,总长度: {len(content)}")
|
||||
|
||||
# 发送完成信号
|
||||
print(f"[StreamChat] ✅ 发送完成信号")
|
||||
await websocket.send_json({
|
||||
"type": "complete"
|
||||
})
|
||||
|
||||
# 保存消息
|
||||
print(f"[StreamChat] 💾 保存消息到文件...")
|
||||
await _save_messages(role_name, chat_name, request_data, content)
|
||||
print(f"[StreamChat] ✅ 消息保存完成\n")
|
||||
else:
|
||||
error_msg = result["error"]
|
||||
print(f"[StreamChat] ❌ 流式处理失败: {error_msg}")
|
||||
await websocket.send_json({
|
||||
"type": "error",
|
||||
"message": error_msg
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n[StreamChat] ⚠️ 错误: {str(e)}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
await websocket.send_json({
|
||||
"type": "error",
|
||||
"message": f"流式处理失败: {str(e)}"
|
||||
})
|
||||
|
||||
|
||||
async def _send_chunk_with_log(websocket: WebSocket, chunk: str, chunk_count: list):
|
||||
"""
|
||||
发送 chunk 并记录日志
|
||||
|
||||
Args:
|
||||
websocket: WebSocket 连接
|
||||
chunk: 文本片段
|
||||
chunk_count: 计数器(使用列表以便在闭包中修改)
|
||||
"""
|
||||
chunk_count[0] += 1
|
||||
if chunk_count[0] % 10 == 0: # 每10个chunk记录一次
|
||||
print(f"[StreamChat] 📤 已发送 {chunk_count[0]} 个 chunks")
|
||||
|
||||
await websocket.send_json({"type": "chunk", "content": chunk})
|
||||
|
||||
|
||||
async def _save_messages(
|
||||
role_name: str,
|
||||
chat_name: str,
|
||||
request_data: Dict[str, Any],
|
||||
ai_response: str
|
||||
):
|
||||
"""
|
||||
保存用户消息和AI回复到聊天文件
|
||||
|
||||
Args:
|
||||
role_name: 角色名
|
||||
chat_name: 聊天名
|
||||
request_data: 前端发送的请求数据
|
||||
ai_response: AI生成的回复
|
||||
"""
|
||||
try:
|
||||
from datetime import datetime
|
||||
|
||||
# 1. 保存用户消息
|
||||
user_message = {
|
||||
"id": f"msg_{datetime.now().timestamp()}_user",
|
||||
"name": request_data.get("userName", "User"),
|
||||
"is_user": True,
|
||||
"is_system": False,
|
||||
"sendDate": datetime.now().isoformat(),
|
||||
"mes": request_data.get("mes", ""),
|
||||
"chatId": f"{role_name}/{chat_name}",
|
||||
"floor": request_data.get("floor", 0)
|
||||
}
|
||||
|
||||
chat_service.add_message(role_name, chat_name, user_message)
|
||||
|
||||
# 2. 保存AI回复
|
||||
ai_message = {
|
||||
"id": f"msg_{datetime.now().timestamp()}_ai",
|
||||
"name": request_data.get("characterName", role_name),
|
||||
"is_user": False,
|
||||
"is_system": False,
|
||||
"sendDate": datetime.now().isoformat(),
|
||||
"mes": ai_response,
|
||||
"chatId": f"{role_name}/{chat_name}",
|
||||
"floor": request_data.get("floor", 0) + 1
|
||||
}
|
||||
|
||||
chat_service.add_message(role_name, chat_name, ai_message)
|
||||
|
||||
print(f"[WebSocket] 消息已保存: {role_name}/{chat_name}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"[WebSocket] 保存消息失败: {e}")
|
||||
# 不抛出异常,避免影响主流程
|
||||
@@ -43,7 +43,7 @@ async def list_role_chats(role_name: str):
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
|
||||
@router.post("/{role_name}", status_code=status.HTTP_201_CREATED)
|
||||
async def create_chat(role_name: str, chat_data: dict):
|
||||
"""创建新聊天"""
|
||||
|
||||
140
backend/api/routes/imageGalleryRoute.py
Normal file
140
backend/api/routes/imageGalleryRoute.py
Normal file
@@ -0,0 +1,140 @@
|
||||
"""
|
||||
图片画廊路由
|
||||
|
||||
提供图片查询、删除等管理接口
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from typing import Dict, Any, List, Optional
|
||||
import os
|
||||
|
||||
try:
|
||||
from backend.services.image_metadata_service import image_metadata_service
|
||||
except ImportError:
|
||||
from services.image_metadata_service import image_metadata_service
|
||||
|
||||
router = APIRouter(prefix="/image-gallery", tags=["image-gallery"])
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
async def get_gallery_stats():
|
||||
"""获取画廊统计信息"""
|
||||
return await image_metadata_service.get_gallery_stats()
|
||||
|
||||
|
||||
@router.get("/images/{chat_id}")
|
||||
async def get_chat_images(
|
||||
chat_id: str,
|
||||
floor: Optional[int] = None
|
||||
):
|
||||
"""
|
||||
获取指定聊天的图片列表
|
||||
|
||||
Args:
|
||||
chat_id: 聊天ID (role_name/chat_name)
|
||||
floor: 楼层号(可选)
|
||||
"""
|
||||
images = await image_metadata_service.get_images_by_chat(chat_id, floor)
|
||||
return {
|
||||
"chatId": chat_id,
|
||||
"totalImages": len(images),
|
||||
"images": [img.model_dump() for img in images]
|
||||
}
|
||||
|
||||
|
||||
@router.get("/images/role/{role_name}")
|
||||
async def get_role_images(role_name: str):
|
||||
"""获取指定角色的所有图片"""
|
||||
images = await image_metadata_service.get_images_by_role(role_name)
|
||||
return {
|
||||
"roleName": role_name,
|
||||
"totalImages": len(images),
|
||||
"images": [img.model_dump() for img in images]
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/images/{chat_id}/{image_id}")
|
||||
async def delete_image(chat_id: str, image_id: str):
|
||||
"""
|
||||
删除图片(元数据和文件)
|
||||
|
||||
Args:
|
||||
chat_id: 聊天ID
|
||||
image_id: 图片ID
|
||||
"""
|
||||
# 先获取元数据以得到文件路径
|
||||
images = await image_metadata_service.get_images_by_chat(chat_id)
|
||||
target_image = None
|
||||
for img in images:
|
||||
if img.id == image_id:
|
||||
target_image = img
|
||||
break
|
||||
|
||||
if not target_image:
|
||||
raise HTTPException(status_code=404, detail="图片不存在")
|
||||
|
||||
# 删除元数据
|
||||
success = await image_metadata_service.delete_image(chat_id, image_id)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=500, detail="删除失败")
|
||||
|
||||
# 删除实际文件
|
||||
try:
|
||||
file_path = image_metadata_service.get_image_full_path(target_image.filepath)
|
||||
if file_path.exists():
|
||||
file_path.unlink()
|
||||
except Exception as e:
|
||||
print(f"[ImageGallery] 删除文件失败: {e}")
|
||||
# 不抛出异常,因为元数据已删除
|
||||
|
||||
return {"message": "图片已删除"}
|
||||
|
||||
|
||||
@router.post("/images/{chat_id}/clear")
|
||||
async def clear_chat_images(chat_id: str):
|
||||
"""
|
||||
清空指定聊天的所有图片
|
||||
|
||||
Args:
|
||||
chat_id: 聊天ID
|
||||
"""
|
||||
count = await image_metadata_service.clear_chat_images(chat_id)
|
||||
return {
|
||||
"message": f"已清空 {count} 张图片",
|
||||
"deletedCount": count
|
||||
}
|
||||
|
||||
|
||||
@router.post("/images/{chat_id}/{image_id}/set-current")
|
||||
async def set_current_swipe(chat_id: str, image_id: str):
|
||||
"""
|
||||
设置某张图片为当前显示的 swipe
|
||||
|
||||
Args:
|
||||
chat_id: 聊天ID
|
||||
image_id: 图片ID
|
||||
"""
|
||||
success = await image_metadata_service.set_current_swipe(chat_id, image_id)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="图片不存在")
|
||||
|
||||
return {"message": "已设置为当前显示"}
|
||||
|
||||
|
||||
@router.get("/image/{filepath:path}")
|
||||
async def get_image(filepath: str):
|
||||
"""
|
||||
获取图片文件
|
||||
|
||||
Args:
|
||||
filepath: 文件相对路径
|
||||
"""
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
file_path = image_metadata_service.get_image_full_path(filepath)
|
||||
|
||||
if not file_path.exists():
|
||||
raise HTTPException(status_code=404, detail="图片文件不存在")
|
||||
|
||||
return FileResponse(str(file_path))
|
||||
248
backend/api/routes/regexRoute.py
Normal file
248
backend/api/routes/regexRoute.py
Normal file
@@ -0,0 +1,248 @@
|
||||
"""
|
||||
正则规则 API 路由
|
||||
|
||||
提供正则规则的 CRUD 操作和导入导出功能
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException, UploadFile, File
|
||||
from pydantic import BaseModel
|
||||
from typing import List, Optional
|
||||
import json
|
||||
import logging
|
||||
|
||||
from services.regex_service import regex_service
|
||||
from models.regex_rules import RegexRule, RegexRuleset, RegexScope
|
||||
from services.system_settings_service import system_settings_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/regex", tags=["regex"])
|
||||
|
||||
|
||||
# ==================== 数据模型 ====================
|
||||
|
||||
class RuleUpdateRequest(BaseModel):
|
||||
"""规则更新请求"""
|
||||
rule: RegexRule
|
||||
scope: RegexScope
|
||||
name: Optional[str] = None # 角色卡名称或预设名称(scope 为 CHARACTER/PRESET 时需要)
|
||||
|
||||
|
||||
class SystemSettingsUpdate(BaseModel):
|
||||
"""系统设置更新请求"""
|
||||
thinkingTagPrefix: Optional[str] = None
|
||||
thinkingTagSuffix: Optional[str] = None
|
||||
currentPresetName: Optional[str] = None
|
||||
|
||||
|
||||
# ==================== 规则查询 ====================
|
||||
|
||||
@router.get("/rules")
|
||||
async def get_rules(
|
||||
character_name: Optional[str] = None,
|
||||
preset_name: Optional[str] = None
|
||||
):
|
||||
"""
|
||||
获取适用的正则规则列表
|
||||
|
||||
Args:
|
||||
character_name: 当前角色卡名称(可选)
|
||||
preset_name: 当前预设名称(可选)
|
||||
|
||||
Returns:
|
||||
规则列表
|
||||
"""
|
||||
try:
|
||||
rules = regex_service.get_rules_for_context(character_name, preset_name)
|
||||
return {
|
||||
"success": True,
|
||||
"rules": [rule.dict() for rule in rules],
|
||||
"count": len(rules)
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"获取规则失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/rulesets/global")
|
||||
async def get_global_rulesets():
|
||||
"""获取所有全局规则集"""
|
||||
try:
|
||||
rulesets = list(regex_service.global_rulesets.values())
|
||||
return {
|
||||
"success": True,
|
||||
"rulesets": [rs.dict() for rs in rulesets]
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"获取全局规则集失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/rulesets/character/{character_name}")
|
||||
async def get_character_ruleset(character_name: str):
|
||||
"""获取指定角色卡的规则集"""
|
||||
try:
|
||||
if character_name in regex_service.character_rulesets:
|
||||
ruleset = regex_service.character_rulesets[character_name]
|
||||
return {
|
||||
"success": True,
|
||||
"ruleset": ruleset.dict()
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"success": True,
|
||||
"ruleset": None
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"获取角色规则集失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/rulesets/preset/{preset_name}")
|
||||
async def get_preset_ruleset(preset_name: str):
|
||||
"""获取指定预设的规则集"""
|
||||
try:
|
||||
if preset_name in regex_service.preset_rulesets:
|
||||
ruleset = regex_service.preset_rulesets[preset_name]
|
||||
return {
|
||||
"success": True,
|
||||
"ruleset": ruleset.dict()
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"success": True,
|
||||
"ruleset": None
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"获取预设规则集失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ==================== 规则管理 ====================
|
||||
|
||||
@router.post("/rules")
|
||||
async def add_rule(request: RuleUpdateRequest):
|
||||
"""添加新规则"""
|
||||
try:
|
||||
regex_service.save_ruleset(
|
||||
RegexRuleset(
|
||||
id=request.rule.id,
|
||||
name=request.rule.scriptName,
|
||||
rules=[request.rule]
|
||||
),
|
||||
request.scope,
|
||||
request.name
|
||||
)
|
||||
|
||||
# 重新加载规则
|
||||
regex_service._load_all_rules()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "规则添加成功"
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"添加规则失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/rules/{rule_id}")
|
||||
async def delete_rule(rule_id: str):
|
||||
"""删除规则(需要指定作用域和名称)"""
|
||||
# TODO: 实现具体的删除逻辑
|
||||
return {
|
||||
"success": False,
|
||||
"message": "暂未实现,请使用规则集级别的删除"
|
||||
}
|
||||
|
||||
|
||||
# ==================== 规则导入导出 ====================
|
||||
|
||||
@router.post("/import")
|
||||
async def import_rules(file: UploadFile = File(...)):
|
||||
"""
|
||||
导入正则规则(支持 SillyTavern 格式)
|
||||
|
||||
可以导入:
|
||||
1. 单个规则文件(JSON 数组)
|
||||
2. 规则集文件(JSON 对象)
|
||||
"""
|
||||
try:
|
||||
content = await file.read()
|
||||
data = json.loads(content.decode('utf-8'))
|
||||
|
||||
# 判断格式并导入
|
||||
if isinstance(data, list):
|
||||
# SillyTavern 格式 - 导入为全局规则
|
||||
ruleset = regex_service._convert_sillytavern_format(data, file.filename.replace('.json', ''))
|
||||
regex_service.save_ruleset(ruleset, RegexScope.GLOBAL)
|
||||
elif isinstance(data, dict):
|
||||
if 'rules' in data:
|
||||
# 规则集格式
|
||||
ruleset = RegexRuleset(**data)
|
||||
regex_service.save_ruleset(ruleset, RegexScope.GLOBAL)
|
||||
else:
|
||||
raise ValueError("未知的文件格式")
|
||||
else:
|
||||
raise ValueError("无效的文件格式")
|
||||
|
||||
# 重新加载规则
|
||||
regex_service._load_all_rules()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"成功导入规则集: {ruleset.name}"
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"导入规则失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/export/global")
|
||||
async def export_global_rules():
|
||||
"""导出所有全局规则"""
|
||||
try:
|
||||
all_rulesets = list(regex_service.global_rulesets.values())
|
||||
return {
|
||||
"success": True,
|
||||
"rulesets": [rs.dict() for rs in all_rulesets]
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"导出规则失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ==================== 系统设置 ====================
|
||||
|
||||
@router.get("/settings")
|
||||
async def get_system_settings():
|
||||
"""获取系统设置"""
|
||||
try:
|
||||
settings = system_settings_service.settings
|
||||
return {
|
||||
"success": True,
|
||||
"settings": settings.dict()
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"获取系统设置失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.put("/settings")
|
||||
async def update_system_settings(request: SystemSettingsUpdate):
|
||||
"""更新系统设置"""
|
||||
try:
|
||||
if request.thinkingTagPrefix is not None or request.thinkingTagSuffix is not None:
|
||||
prefix = request.thinkingTagPrefix or system_settings_service.settings.thinkingTagPrefix
|
||||
suffix = request.thinkingTagSuffix or system_settings_service.settings.thinkingTagSuffix
|
||||
system_settings_service.update_thinking_tags(prefix, suffix)
|
||||
|
||||
if request.currentPresetName is not None:
|
||||
system_settings_service.update_current_preset(request.currentPresetName)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "系统设置已更新"
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"更新系统设置失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
130
backend/api/routes/tokenUsageRoute.py
Normal file
130
backend/api/routes/tokenUsageRoute.py
Normal file
@@ -0,0 +1,130 @@
|
||||
"""
|
||||
Token 使用统计路由
|
||||
|
||||
提供 token 使用情况的查询接口
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from typing import Dict, Any, List, Optional
|
||||
|
||||
try:
|
||||
from backend.services.token_usage_service import token_usage_service
|
||||
except ImportError:
|
||||
from services.token_usage_service import token_usage_service
|
||||
|
||||
router = APIRouter(prefix="/token-usage", tags=["token-usage"])
|
||||
|
||||
|
||||
@router.get("/months")
|
||||
async def list_months():
|
||||
"""列出所有有数据的月份"""
|
||||
return await token_usage_service.list_months()
|
||||
|
||||
|
||||
@router.get("/stats/{year}/{month}")
|
||||
async def get_monthly_stats(
|
||||
year: int,
|
||||
month: int,
|
||||
role_name: Optional[str] = None,
|
||||
chat_name: Optional[str] = None
|
||||
):
|
||||
"""
|
||||
获取指定月份的统计数据
|
||||
|
||||
Args:
|
||||
year: 年份
|
||||
month: 月份
|
||||
role_name: 角色名称(可选)
|
||||
chat_name: 聊天名称(可选)
|
||||
"""
|
||||
if month < 1 or month > 12:
|
||||
raise HTTPException(status_code=400, detail="月份必须在 1-12 之间")
|
||||
|
||||
try:
|
||||
stats = await token_usage_service.get_stats_by_month(
|
||||
year=year,
|
||||
month=month,
|
||||
role_name=role_name,
|
||||
chat_name=chat_name
|
||||
)
|
||||
return stats
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"获取统计数据失败: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/api-urls")
|
||||
async def get_api_url_stats():
|
||||
"""
|
||||
✅ 获取按 API URL 分组的统计数据(快速查询)
|
||||
|
||||
Returns:
|
||||
{
|
||||
"api_url_1": {
|
||||
"totalPromptTokens": 1000,
|
||||
"totalCompletionTokens": 2000,
|
||||
"totalTokens": 3000,
|
||||
"count": 10,
|
||||
"firstUsed": 1234567890,
|
||||
"lastUsed": 1234567899
|
||||
},
|
||||
...
|
||||
}
|
||||
"""
|
||||
try:
|
||||
stats = await token_usage_service.get_api_url_stats()
|
||||
return stats
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"获取 API URL 统计失败: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/daily/{year}/{month}")
|
||||
async def get_daily_stats(year: int, month: int):
|
||||
"""
|
||||
✅ 获取指定月份的每日统计数据(快速查询)
|
||||
|
||||
Args:
|
||||
year: 年份
|
||||
month: 月份
|
||||
|
||||
Returns:
|
||||
{
|
||||
"2024-01-01": {
|
||||
"promptTokens": 1000,
|
||||
"completionTokens": 2000,
|
||||
"totalTokens": 3000,
|
||||
"count": 10
|
||||
},
|
||||
...
|
||||
}
|
||||
"""
|
||||
if month < 1 or month > 12:
|
||||
raise HTTPException(status_code=400, detail="月份必须在 1-12 之间")
|
||||
|
||||
try:
|
||||
stats = await token_usage_service.get_daily_stats(year, month)
|
||||
return stats
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"获取每日统计失败: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/roles/{year}/{month}")
|
||||
async def get_available_roles(year: int, month: int):
|
||||
"""获取指定月份有数据的角色列表"""
|
||||
if month < 1 or month > 12:
|
||||
raise HTTPException(status_code=400, detail="月份必须在 1-12 之间")
|
||||
|
||||
roles = await token_usage_service.get_available_roles(year, month)
|
||||
return {"roles": roles}
|
||||
|
||||
|
||||
@router.get("/chats/{year}/{month}")
|
||||
async def get_available_chats(
|
||||
year: int,
|
||||
month: int,
|
||||
role_name: Optional[str] = None
|
||||
):
|
||||
"""获取指定月份有数据的聊天列表"""
|
||||
if month < 1 or month > 12:
|
||||
raise HTTPException(status_code=400, detail="月份必须在 1-12 之间")
|
||||
|
||||
chats = await token_usage_service.get_available_chats(year, month, role_name)
|
||||
return {"chats": chats}
|
||||
@@ -19,13 +19,6 @@ load_dotenv(PROJECT_ROOT / ".env")
|
||||
|
||||
|
||||
class Settings:
|
||||
# --- 主模型配置 ---
|
||||
MAIN_LLM_API_KEY = os.getenv("MAIN_LLM_API_KEY")
|
||||
MAIN_LLM_MODEL = os.getenv("MAIN_LLM_MODEL", "gpt-3.5-turbo")
|
||||
MAIN_LLM_BASE_URL = os.getenv("MAIN_LLM_BASE_URL", "https://api.openai.com/v1")
|
||||
MAIN_LLM_MAX_TOKENS = int(os.getenv("MAIN_LLM_MAX_TOKENS", "4096"))
|
||||
MAIN_LLM_STREAM = os.getenv("MAIN_LLM_STREAM", "true").lower() == "true"
|
||||
|
||||
# --- 路径配置 (核心修改) ---
|
||||
|
||||
# 强制使用计算出的项目根目录,不再依赖 .env 中的 BASE_PATH
|
||||
@@ -38,7 +31,8 @@ class Settings:
|
||||
STATE_FILE = DATA_PATH / "state.json"
|
||||
SCHEMA_FILE = DATA_PATH / "schema.json"
|
||||
PRESETS_FILE = DATA_PATH / "presets.json"
|
||||
REGEX_FILE = DATA_PATH / "regex_rules.json"
|
||||
REGEX_FILE = DATA_PATH / "regex_rules.json" # 正则规则文件
|
||||
SYSTEM_SETTINGS_FILE = DATA_PATH / "system_settings.json" # 系统设置文件
|
||||
VECTORSTORE_PATH = DATA_PATH / "vectorstore"
|
||||
|
||||
# --- 业务数据目录 ---
|
||||
@@ -49,8 +43,11 @@ class Settings:
|
||||
# 预设目录
|
||||
PRESET_PATH = DATA_PATH / "preset"
|
||||
|
||||
# 聊天记录目录
|
||||
# 聊天记录目录(同时存放角色卡和聊天)
|
||||
CHAT_PATH = DATA_PATH / "chat"
|
||||
|
||||
# 兼容别名:用于代码中引用
|
||||
CHATS_PATH = CHAT_PATH
|
||||
|
||||
# 临时文件目录
|
||||
TEMP_PATH = DATA_PATH / "temp"
|
||||
@@ -58,8 +55,8 @@ class Settings:
|
||||
# ComfyUI 工作流目录
|
||||
COMFYUI_WORKFLOWS_PATH = DATA_PATH / "comfyui_workflows"
|
||||
|
||||
# 角色卡目录
|
||||
CHARACTERS_PATH = DATA_PATH / "characters"
|
||||
# 角色卡目录(已合并到 CHAT_PATH)
|
||||
CHARACTERS_PATH = CHAT_PATH
|
||||
|
||||
# 图片资源目录
|
||||
IMAGES_PATH = DATA_PATH / "images"
|
||||
@@ -78,6 +75,10 @@ class Settings:
|
||||
]
|
||||
for directory in directories:
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 确保核心数据文件的父目录存在
|
||||
for file_path in [self.STATE_FILE, self.SCHEMA_FILE, self.PRESETS_FILE, self.REGEX_FILE, self.SYSTEM_SETTINGS_FILE]:
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
@@ -141,7 +141,14 @@ class CharacterCard(BaseModel):
|
||||
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="标签数组")
|
||||
|
||||
# TODO: 拓展提示词设置(插件/拓展系统预留接口)
|
||||
# - tableMaintenancePrompt: 用于指导 AI 维护动态表格(RPG状态、任务追踪等)
|
||||
# - imageGenerationPrompt: 用于指导 AI 生成图片描述提示词
|
||||
# 当前状态:字段已定义,默认值为 None,等待插件系统实现
|
||||
tableMaintenancePrompt: Optional[str] = Field(None, description="动态表格维护提示词 - 指导 AI 如何更新表格数据")
|
||||
imageGenerationPrompt: Optional[str] = Field(None, description="生图提示词模板 - 指导 AI 如何生成图片描述")
|
||||
|
||||
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="最后聊天时间戳")
|
||||
@@ -300,3 +307,84 @@ class ChatRAGConfig(BaseModel):
|
||||
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="最后更新时间戳")
|
||||
|
||||
|
||||
# ==================== Token 统计 ====================
|
||||
|
||||
class TokenUsageStatus(str, Enum):
|
||||
"""Token 使用状态"""
|
||||
COMPLETED = 'completed' # 成功完成
|
||||
INTERRUPTED = 'interrupted' # 被用户中断
|
||||
FAILED = 'failed' # 请求失败(API错误等)
|
||||
|
||||
|
||||
class TokenUsageRecord(BaseModel):
|
||||
"""
|
||||
Token 使用记录
|
||||
|
||||
记录每次 LLM 调用的 token 使用情况,支持按时间、角色、聊天维度统计
|
||||
"""
|
||||
id: str = Field(..., description="记录唯一标识符 (UUID)")
|
||||
chatId: str = Field(..., description="聊天ID (role_name/chat_name)")
|
||||
roleName: str = Field(..., description="角色名称")
|
||||
chatName: str = Field(..., description="聊天名称")
|
||||
messageId: Optional[str] = Field(None, description="关联的消息ID")
|
||||
floor: Optional[int] = Field(None, description="楼层号")
|
||||
|
||||
# Token 统计
|
||||
promptTokens: int = Field(0, description="输入 token 数")
|
||||
completionTokens: int = Field(0, description="输出 token 数")
|
||||
totalTokens: int = Field(0, description="总 token 数")
|
||||
|
||||
# 状态信息
|
||||
status: TokenUsageStatus = Field(TokenUsageStatus.COMPLETED, description="请求状态")
|
||||
errorMessage: Optional[str] = Field(None, description="错误信息(如果失败)")
|
||||
|
||||
# 时间信息
|
||||
timestamp: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="请求时间戳")
|
||||
duration: Optional[float] = Field(None, description="请求耗时(秒)")
|
||||
|
||||
# API 信息
|
||||
model: Optional[str] = Field(None, description="使用的模型")
|
||||
apiProvider: Optional[str] = Field(None, description="API 提供商")
|
||||
apiUrl: Optional[str] = Field(None, description="API URL地址")
|
||||
|
||||
|
||||
# ==================== 图片元数据 ====================
|
||||
|
||||
class ImageMetadata(BaseModel):
|
||||
"""
|
||||
图片元数据
|
||||
|
||||
记录生成的图片信息,绑定到角色/聊天的特定楼层
|
||||
"""
|
||||
id: str = Field(..., description="图片唯一标识符 (UUID)")
|
||||
chatId: str = Field(..., description="聊天ID (role_name/chat_name)")
|
||||
roleName: str = Field(..., description="角色名称")
|
||||
chatName: str = Field(..., description="聊天名称")
|
||||
floor: int = Field(..., description="楼层号")
|
||||
|
||||
# 图片信息
|
||||
filename: str = Field(..., description="文件名")
|
||||
filepath: str = Field(..., description="文件相对路径")
|
||||
width: Optional[int] = Field(None, description="图片宽度")
|
||||
height: Optional[int] = Field(None, description="图片高度")
|
||||
fileSize: Optional[int] = Field(None, description="文件大小(字节)")
|
||||
|
||||
# Swipe 支持
|
||||
swipeIndex: int = Field(0, description="Swipe 索引(同一楼层多张图片)")
|
||||
isCurrentSwipe: bool = Field(True, description="是否为当前显示的 swipe")
|
||||
|
||||
# 生成信息
|
||||
prompt: Optional[str] = Field(None, description="生成使用的提示词")
|
||||
negativePrompt: Optional[str] = Field(None, description="负面提示词")
|
||||
seed: Optional[int] = Field(None, description="随机种子")
|
||||
model: Optional[str] = Field(None, description="使用的模型/checkpoint")
|
||||
workflowName: Optional[str] = Field(None, description="使用的工作流名称")
|
||||
|
||||
# 任务信息
|
||||
taskId: Optional[str] = Field(None, description="关联的任务ID")
|
||||
generationTime: Optional[float] = Field(None, description="生成耗时(秒)")
|
||||
|
||||
# 时间信息
|
||||
createdAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="创建时间戳")
|
||||
|
||||
164
backend/models/regex_rules.py
Normal file
164
backend/models/regex_rules.py
Normal file
@@ -0,0 +1,164 @@
|
||||
"""
|
||||
正则替换规则模型
|
||||
|
||||
兼容 SillyTavern 的正则系统,支持全局、角色卡、预设三种作用域。
|
||||
"""
|
||||
from enum import Enum
|
||||
from typing import List, Optional, Dict, Any
|
||||
from pydantic import BaseModel, Field
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class RegexPlacement(int, Enum):
|
||||
"""
|
||||
正则应用位置(对应 SillyTavern 的 placement 数组)
|
||||
|
||||
0: System Prompt - 系统提示词
|
||||
1: User Input - 用户输入
|
||||
2: AI Output - AI 输出
|
||||
3: Quick Reply - 快捷回复
|
||||
4: World Info - 世界书信息
|
||||
5: Reasoning/Thinking - 推理/思考内容
|
||||
"""
|
||||
SYSTEM_PROMPT = 0
|
||||
USER_INPUT = 1
|
||||
AI_OUTPUT = 2
|
||||
QUICK_REPLY = 3
|
||||
WORLD_INFO = 4
|
||||
REASONING = 5
|
||||
|
||||
|
||||
class RegexScope(str, Enum):
|
||||
"""
|
||||
正则规则作用域
|
||||
|
||||
- GLOBAL: 全局生效,对所有聊天应用
|
||||
- CHARACTER: 绑定到特定角色卡
|
||||
- PRESET: 绑定到特定预设
|
||||
"""
|
||||
GLOBAL = 'global'
|
||||
CHARACTER = 'character'
|
||||
PRESET = 'preset'
|
||||
|
||||
|
||||
class SubstituteMode(int, Enum):
|
||||
"""
|
||||
替换模式
|
||||
|
||||
对应 SillyTavern 的 substituteRegex 字段
|
||||
"""
|
||||
REPLACE_ALL = 0 # 替换所有匹配
|
||||
REPLACE_FIRST = 1 # 仅替换首次匹配
|
||||
REPLACE_CAPTURED = 2 # 替换捕获组
|
||||
|
||||
|
||||
class RegexRule(BaseModel):
|
||||
"""
|
||||
单条正则替换规则
|
||||
|
||||
完全兼容 SillyTavern 的正则规则格式
|
||||
"""
|
||||
id: str = Field(..., description="规则唯一标识符 (UUID)")
|
||||
scriptName: str = Field(..., description="脚本名称(用于显示)")
|
||||
|
||||
# 核心正则配置
|
||||
findRegex: str = Field(..., description="查找正则表达式(如:/<thinking>[\\s\\S]*?<\\/thinking>/gi)")
|
||||
replaceString: str = Field("", description="替换字符串(支持捕获组引用 $1, $2 等)")
|
||||
trimStrings: List[str] = Field(default_factory=list, description="要额外修剪的字符串数组")
|
||||
|
||||
# 应用位置(关键!对应 SillyTavern 的 placement 数组)
|
||||
placement: List[RegexPlacement] = Field(
|
||||
default_factory=lambda: [RegexPlacement.AI_OUTPUT],
|
||||
description="应用位置数组:0=系统提示词, 1=用户输入, 2=AI输出, 3=快捷回复, 4=世界书, 5=推理内容"
|
||||
)
|
||||
|
||||
# 替换模式
|
||||
substituteRegex: SubstituteMode = Field(
|
||||
SubstituteMode.REPLACE_ALL,
|
||||
description="替换模式:0=全部,1=首次,2=捕获组"
|
||||
)
|
||||
|
||||
# 作用范围控制
|
||||
markdownOnly: bool = Field(False, description="是否仅应用于 Markdown 渲染后的内容")
|
||||
promptOnly: bool = Field(False, description="是否仅应用于发送给 LLM 的提示词")
|
||||
runOnEdit: bool = Field(True, description="用户编辑消息时是否重新应用")
|
||||
|
||||
# 消息深度控制
|
||||
minDepth: int = Field(0, ge=0, description="最小消息深度(从最新消息开始计数)")
|
||||
maxDepth: Optional[int] = Field(None, ge=0, description="最大消息深度(None 表示无限制)")
|
||||
|
||||
# 作用域配置
|
||||
scope: RegexScope = Field(RegexScope.GLOBAL, description="规则作用域")
|
||||
characterName: Optional[str] = Field(None, description="绑定的角色卡名称(scope=CHARACTER 时使用)")
|
||||
presetName: Optional[str] = Field(None, description="绑定的预设名称(scope=PRESET 时使用)")
|
||||
|
||||
# 启用状态
|
||||
disabled: bool = Field(False, description="是否禁用此规则(与 enabled 相反,为了兼容 ST)")
|
||||
|
||||
# 执行顺序
|
||||
order: int = Field(0, description="执行顺序(数值越小越先执行)")
|
||||
|
||||
# 元数据
|
||||
createdAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="创建时间戳")
|
||||
updatedAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="最后更新时间戳")
|
||||
description: Optional[str] = Field(None, description="规则描述(可选)")
|
||||
|
||||
|
||||
class RegexRuleset(BaseModel):
|
||||
"""
|
||||
正则规则集
|
||||
|
||||
一组正则规则的集合,可以整体导入/导出,兼容 SillyTavern 格式
|
||||
"""
|
||||
id: str = Field(..., description="规则集唯一标识符 (UUID)")
|
||||
name: str = Field(..., description="规则集名称")
|
||||
description: Optional[str] = Field(None, description="规则集描述")
|
||||
|
||||
rules: List[RegexRule] = 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="版本号(用于数据迁移)")
|
||||
|
||||
# SillyTavern 兼容性标记
|
||||
isSillyTavernFormat: bool = Field(False, description="是否为 SillyTavern 导入格式")
|
||||
|
||||
|
||||
# ==================== 使用示例 ====================
|
||||
|
||||
if __name__ == '__main__':
|
||||
import json
|
||||
|
||||
# 创建一条规则
|
||||
rule = RegexRule(
|
||||
id="example-hide-thinking-001",
|
||||
scriptName="隐藏思考标签",
|
||||
findRegex=r"<thinking>[\s\S]*?<\/thinking>",
|
||||
replaceString="",
|
||||
trimStrings=[],
|
||||
placement=[RegexPlacement.AI_OUTPUT],
|
||||
substituteRegex=SubstituteMode.REPLACE_ALL,
|
||||
markdownOnly=False,
|
||||
promptOnly=False,
|
||||
runOnEdit=True,
|
||||
minDepth=0,
|
||||
maxDepth=None,
|
||||
scope=RegexScope.GLOBAL,
|
||||
characterName=None,
|
||||
presetName=None,
|
||||
disabled=False,
|
||||
order=1,
|
||||
description="隐藏 AI 回复中的 <thinking> 标签及其内容"
|
||||
)
|
||||
|
||||
# 创建规则集
|
||||
ruleset = RegexRuleset(
|
||||
id="ruleset-001",
|
||||
name="默认正则规则集",
|
||||
description="包含常用的文本处理规则",
|
||||
rules=[rule]
|
||||
)
|
||||
|
||||
# 导出为 JSON(兼容 SillyTavern)
|
||||
print(json.dumps(ruleset.dict(), indent=2, ensure_ascii=False))
|
||||
49
backend/models/system_settings.py
Normal file
49
backend/models/system_settings.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""
|
||||
系统设置模型
|
||||
|
||||
包含全局配置,如思考标签前后缀等。
|
||||
"""
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel, Field
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class SystemSettings(BaseModel):
|
||||
"""
|
||||
系统全局设置
|
||||
|
||||
持久化存储到 data/system_settings.json
|
||||
"""
|
||||
|
||||
# ==================== 思考标签配置 ====================
|
||||
thinkingTagPrefix: str = Field(
|
||||
"<thinking>",
|
||||
description="思考标签前缀(默认:<thinking>)"
|
||||
)
|
||||
thinkingTagSuffix: str = Field(
|
||||
"</thinking>",
|
||||
description="思考标签后缀(默认:</thinking>)"
|
||||
)
|
||||
|
||||
# ==================== 当前选中的预设 ====================
|
||||
currentPresetName: Optional[str] = Field(
|
||||
None,
|
||||
description="当前选中的预设名称(用于确定全局正则的作用域)"
|
||||
)
|
||||
|
||||
# ==================== 元数据 ====================
|
||||
updatedAt: int = Field(
|
||||
default_factory=lambda: int(datetime.now().timestamp()),
|
||||
description="最后更新时间戳"
|
||||
)
|
||||
version: int = Field(1, description="版本号")
|
||||
|
||||
|
||||
# ==================== 默认设置 ====================
|
||||
|
||||
DEFAULT_SYSTEM_SETTINGS = SystemSettings()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import json
|
||||
print(json.dumps(DEFAULT_SYSTEM_SETTINGS.dict(), indent=2, ensure_ascii=False))
|
||||
@@ -177,8 +177,8 @@ class CharacterService:
|
||||
更新角色卡
|
||||
|
||||
Args:
|
||||
name: 角色名
|
||||
updates: 更新的字段
|
||||
name: 角色名(旧名称,用于定位文件夹)
|
||||
updates: 更新的字段(可以包含 name 字段来重命名)
|
||||
|
||||
Returns:
|
||||
更新后的 CharacterCard 对象
|
||||
@@ -193,6 +193,29 @@ class CharacterService:
|
||||
with open(char_file, 'r', encoding='utf-8') as f:
|
||||
existing_data = json.load(f)
|
||||
|
||||
# 检查是否需要重命名
|
||||
new_name = updates.get('name')
|
||||
needs_rename = new_name and new_name != name
|
||||
|
||||
if needs_rename:
|
||||
# 验证新名称是否合法
|
||||
if not new_name or new_name.strip() == '':
|
||||
raise ValueError("角色名不能为空")
|
||||
|
||||
# 检查新名称是否已存在
|
||||
new_folder = self.characters_dir / new_name
|
||||
if new_folder.exists():
|
||||
raise FileExistsError(f"角色 '{new_name}' 已存在")
|
||||
|
||||
# 重命名文件夹
|
||||
try:
|
||||
import shutil
|
||||
shutil.move(str(char_folder), str(new_folder))
|
||||
char_folder = new_folder
|
||||
char_file = char_folder / "character.json"
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"重命名文件夹失败: {str(e)}")
|
||||
|
||||
# 合并更新
|
||||
existing_data.update(updates)
|
||||
existing_data['updatedAt'] = int(datetime.now().timestamp())
|
||||
|
||||
@@ -57,7 +57,7 @@ class ChatService:
|
||||
logger.error(f"处理角色目录 {role_dir.name} 时出错: {str(e)}")
|
||||
continue
|
||||
|
||||
return {"chat": [{"role_name": role, **chat} for role, chats in result.items() for chat in chats]}
|
||||
return result
|
||||
|
||||
def _get_chat_summary(self, role_name: str, chat_name: str) -> Optional[Dict]:
|
||||
"""
|
||||
@@ -197,7 +197,8 @@ class ChatService:
|
||||
tags = []
|
||||
|
||||
try:
|
||||
character_file = Path("data/characters") / role_name / "character.json"
|
||||
from backend.core.config import settings
|
||||
character_file = settings.CHARACTERS_PATH / role_name / "character.json"
|
||||
if character_file.exists():
|
||||
with open(character_file, 'r', encoding='utf-8') as f:
|
||||
character_data = json.load(f)
|
||||
|
||||
1009
backend/services/chat_workflow_service.py
Normal file
1009
backend/services/chat_workflow_service.py
Normal file
File diff suppressed because it is too large
Load Diff
346
backend/services/image_metadata_service.py
Normal file
346
backend/services/image_metadata_service.py
Normal file
@@ -0,0 +1,346 @@
|
||||
"""
|
||||
图片元数据服务
|
||||
|
||||
负责管理生成图片的元数据,支持绑定到角色/聊天的特定楼层
|
||||
数据持久化到 data/image_metadata 目录
|
||||
"""
|
||||
import json
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional, Any
|
||||
from datetime import datetime
|
||||
|
||||
try:
|
||||
from backend.models.internal import ImageMetadata
|
||||
from backend.core.config import settings
|
||||
except ImportError:
|
||||
from models.internal import ImageMetadata
|
||||
from core.config import settings
|
||||
|
||||
|
||||
class ImageMetadataService:
|
||||
"""
|
||||
图片元数据服务
|
||||
|
||||
功能:
|
||||
- 记录生成图片的元数据
|
||||
- 按角色/聊天/楼层组织
|
||||
- 支持 swipe(同一楼层多张图片)
|
||||
- 提供画廊查询接口
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.metadata_dir = settings.DATA_PATH / "image_metadata"
|
||||
self.metadata_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 图片存储目录
|
||||
self.images_dir = settings.DATA_PATH / "images"
|
||||
self.images_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _get_chat_metadata_file(self, chat_id: str) -> Path:
|
||||
"""获取指定聊天的元数据文件路径"""
|
||||
# chat_id 格式: role_name/chat_name
|
||||
parts = chat_id.split("/")
|
||||
if len(parts) == 2:
|
||||
role_name, chat_name = parts
|
||||
role_dir = self.metadata_dir / role_name
|
||||
role_dir.mkdir(parents=True, exist_ok=True)
|
||||
return role_dir / f"{chat_name}.json"
|
||||
else:
|
||||
# fallback
|
||||
return self.metadata_dir / f"{chat_id.replace('/', '_')}.json"
|
||||
|
||||
def _load_chat_metadata(self, chat_id: str) -> List[ImageMetadata]:
|
||||
"""加载指定聊天的所有图片元数据"""
|
||||
file_path = self._get_chat_metadata_file(chat_id)
|
||||
|
||||
if not file_path.exists():
|
||||
return []
|
||||
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
return [ImageMetadata(**item) for item in data]
|
||||
except Exception as e:
|
||||
print(f"[ImageMetadata] 加载元数据失败: {e}")
|
||||
return []
|
||||
|
||||
def _save_chat_metadata(self, chat_id: str, metadata_list: List[ImageMetadata]):
|
||||
"""保存聊天的所有图片元数据"""
|
||||
file_path = self._get_chat_metadata_file(chat_id)
|
||||
|
||||
try:
|
||||
data = [m.model_dump() for m in metadata_list]
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
except Exception as e:
|
||||
print(f"[ImageMetadata] 保存元数据失败: {e}")
|
||||
|
||||
async def add_image(
|
||||
self,
|
||||
chat_id: str,
|
||||
role_name: str,
|
||||
chat_name: str,
|
||||
floor: int,
|
||||
filename: str,
|
||||
filepath: str,
|
||||
prompt: Optional[str] = None,
|
||||
negative_prompt: Optional[str] = None,
|
||||
seed: Optional[int] = None,
|
||||
model: Optional[str] = None,
|
||||
workflow_name: Optional[str] = None,
|
||||
task_id: Optional[str] = None,
|
||||
generation_time: Optional[float] = None,
|
||||
width: Optional[int] = None,
|
||||
height: Optional[int] = None,
|
||||
file_size: Optional[int] = None
|
||||
) -> ImageMetadata:
|
||||
"""
|
||||
添加图片元数据
|
||||
|
||||
Args:
|
||||
chat_id: 聊天ID
|
||||
role_name: 角色名称
|
||||
chat_name: 聊天名称
|
||||
floor: 楼层号
|
||||
filename: 文件名
|
||||
filepath: 文件相对路径
|
||||
prompt: 提示词
|
||||
negative_prompt: 负面提示词
|
||||
seed: 随机种子
|
||||
model: 使用的模型
|
||||
workflow_name: 工作流名称
|
||||
task_id: 任务ID
|
||||
generation_time: 生成耗时
|
||||
width: 图片宽度
|
||||
height: 图片高度
|
||||
file_size: 文件大小
|
||||
|
||||
Returns:
|
||||
ImageMetadata: 创建的元数据
|
||||
"""
|
||||
metadata_list = self._load_chat_metadata(chat_id)
|
||||
|
||||
# 计算 swipe_index
|
||||
same_floor_images = [m for m in metadata_list if m.floor == floor]
|
||||
swipe_index = len(same_floor_images)
|
||||
|
||||
# 如果这是该楼层的第一张图片,将其他图片的 isCurrentSwipe 设为 False
|
||||
if swipe_index == 0:
|
||||
for m in metadata_list:
|
||||
if m.floor == floor:
|
||||
m.isCurrentSwipe = False
|
||||
|
||||
metadata = ImageMetadata(
|
||||
id=str(uuid.uuid4()),
|
||||
chatId=chat_id,
|
||||
roleName=role_name,
|
||||
chatName=chat_name,
|
||||
floor=floor,
|
||||
filename=filename,
|
||||
filepath=filepath,
|
||||
prompt=prompt,
|
||||
negativePrompt=negative_prompt,
|
||||
seed=seed,
|
||||
model=model,
|
||||
workflowName=workflow_name,
|
||||
taskId=task_id,
|
||||
generationTime=generation_time,
|
||||
width=width,
|
||||
height=height,
|
||||
fileSize=file_size,
|
||||
swipeIndex=swipe_index,
|
||||
isCurrentSwipe=True
|
||||
)
|
||||
|
||||
metadata_list.append(metadata)
|
||||
self._save_chat_metadata(chat_id, metadata_list)
|
||||
|
||||
return metadata
|
||||
|
||||
async def get_images_by_chat(
|
||||
self,
|
||||
chat_id: str,
|
||||
floor: Optional[int] = None
|
||||
) -> List[ImageMetadata]:
|
||||
"""
|
||||
获取指定聊天的图片列表
|
||||
|
||||
Args:
|
||||
chat_id: 聊天ID
|
||||
floor: 楼层号(可选,用于过滤)
|
||||
|
||||
Returns:
|
||||
图片元数据列表
|
||||
"""
|
||||
metadata_list = self._load_chat_metadata(chat_id)
|
||||
|
||||
if floor is not None:
|
||||
metadata_list = [m for m in metadata_list if m.floor == floor]
|
||||
|
||||
# 按楼层和 swipe_index 排序
|
||||
metadata_list.sort(key=lambda m: (m.floor, m.swipeIndex))
|
||||
|
||||
return metadata_list
|
||||
|
||||
async def get_images_by_role(self, role_name: str) -> List[ImageMetadata]:
|
||||
"""获取指定角色的所有图片"""
|
||||
all_images = []
|
||||
|
||||
role_dir = self.metadata_dir / role_name
|
||||
if not role_dir.exists():
|
||||
return []
|
||||
|
||||
for chat_file in role_dir.glob("*.json"):
|
||||
chat_name = chat_file.stem
|
||||
chat_id = f"{role_name}/{chat_name}"
|
||||
images = self._load_chat_metadata(chat_id)
|
||||
all_images.extend(images)
|
||||
|
||||
# 按创建时间排序
|
||||
all_images.sort(key=lambda m: m.createdAt, reverse=True)
|
||||
|
||||
return all_images
|
||||
|
||||
async def delete_image(self, chat_id: str, image_id: str) -> bool:
|
||||
"""
|
||||
删除图片元数据(不删除实际文件)
|
||||
|
||||
Args:
|
||||
chat_id: 聊天ID
|
||||
image_id: 图片ID
|
||||
|
||||
Returns:
|
||||
bool: 是否成功删除
|
||||
"""
|
||||
metadata_list = self._load_chat_metadata(chat_id)
|
||||
|
||||
# 找到要删除的图片
|
||||
target_image = None
|
||||
for m in metadata_list:
|
||||
if m.id == image_id:
|
||||
target_image = m
|
||||
break
|
||||
|
||||
if not target_image:
|
||||
return False
|
||||
|
||||
floor = target_image.floor
|
||||
swipe_index = target_image.swipeIndex
|
||||
|
||||
# 删除该图片
|
||||
metadata_list = [m for m in metadata_list if m.id != image_id]
|
||||
|
||||
# 重新调整同一楼层其他图片的 swipe_index
|
||||
same_floor_images = [m for m in metadata_list if m.floor == floor]
|
||||
same_floor_images.sort(key=lambda m: m.swipeIndex)
|
||||
|
||||
for idx, m in enumerate(same_floor_images):
|
||||
m.swipeIndex = idx
|
||||
m.isCurrentSwipe = (idx == 0) # 第一个为当前显示
|
||||
|
||||
self._save_chat_metadata(chat_id, metadata_list)
|
||||
return True
|
||||
|
||||
async def clear_chat_images(self, chat_id: str) -> int:
|
||||
"""
|
||||
清空指定聊天的所有图片元数据
|
||||
|
||||
Args:
|
||||
chat_id: 聊天ID
|
||||
|
||||
Returns:
|
||||
int: 删除的图片数量
|
||||
"""
|
||||
metadata_list = self._load_chat_metadata(chat_id)
|
||||
count = len(metadata_list)
|
||||
|
||||
# 清空元数据文件
|
||||
file_path = self._get_chat_metadata_file(chat_id)
|
||||
if file_path.exists():
|
||||
file_path.unlink()
|
||||
|
||||
return count
|
||||
|
||||
async def set_current_swipe(self, chat_id: str, image_id: str) -> bool:
|
||||
"""
|
||||
设置某张图片为当前显示的 swipe
|
||||
|
||||
Args:
|
||||
chat_id: 聊天ID
|
||||
image_id: 图片ID
|
||||
|
||||
Returns:
|
||||
bool: 是否成功设置
|
||||
"""
|
||||
metadata_list = self._load_chat_metadata(chat_id)
|
||||
|
||||
target_image = None
|
||||
for m in metadata_list:
|
||||
if m.id == image_id:
|
||||
target_image = m
|
||||
break
|
||||
|
||||
if not target_image:
|
||||
return False
|
||||
|
||||
floor = target_image.floor
|
||||
|
||||
# 将同一楼层的所有图片设为非当前
|
||||
for m in metadata_list:
|
||||
if m.floor == floor:
|
||||
m.isCurrentSwipe = False
|
||||
|
||||
# 设置目标图片为当前
|
||||
target_image.isCurrentSwipe = True
|
||||
|
||||
self._save_chat_metadata(chat_id, metadata_list)
|
||||
return True
|
||||
|
||||
async def get_gallery_stats(self) -> Dict[str, Any]:
|
||||
"""
|
||||
获取画廊统计信息
|
||||
|
||||
Returns:
|
||||
统计信息字典
|
||||
"""
|
||||
stats = {
|
||||
"totalImages": 0,
|
||||
"byRole": {},
|
||||
"byChat": {}
|
||||
}
|
||||
|
||||
if not self.metadata_dir.exists():
|
||||
return stats
|
||||
|
||||
for role_dir in self.metadata_dir.iterdir():
|
||||
if not role_dir.is_dir():
|
||||
continue
|
||||
|
||||
role_name = role_dir.name
|
||||
role_count = 0
|
||||
|
||||
for chat_file in role_dir.glob("*.json"):
|
||||
chat_name = chat_file.stem
|
||||
chat_id = f"{role_name}/{chat_name}"
|
||||
images = self._load_chat_metadata(chat_id)
|
||||
|
||||
chat_count = len(images)
|
||||
role_count += chat_count
|
||||
stats["totalImages"] += chat_count
|
||||
|
||||
if chat_count > 0:
|
||||
stats["byChat"][chat_id] = chat_count
|
||||
|
||||
if role_count > 0:
|
||||
stats["byRole"][role_name] = role_count
|
||||
|
||||
return stats
|
||||
|
||||
def get_image_full_path(self, filepath: str) -> Path:
|
||||
"""获取图片的完整路径"""
|
||||
return self.images_dir / filepath
|
||||
|
||||
|
||||
# 全局实例
|
||||
image_metadata_service = ImageMetadataService()
|
||||
@@ -27,18 +27,35 @@ class LLMModelService:
|
||||
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'
|
||||
# 规范化 base_url:确保有协议前缀
|
||||
base_url = base_url.strip()
|
||||
if not base_url.startswith(('http://', 'https://')):
|
||||
base_url = 'https://' + base_url
|
||||
|
||||
response = requests.get(
|
||||
f"{base_url}/models",
|
||||
headers={
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
timeout=10
|
||||
)
|
||||
# 移除末尾的斜杠和常见 endpoint 路径
|
||||
base_url = base_url.rstrip('/')
|
||||
# 移除可能已经存在的 endpoint 路径
|
||||
for endpoint in ['/chat/completions', '/completions', '/embeddings', '/models']:
|
||||
if base_url.endswith(endpoint):
|
||||
base_url = base_url[:-len(endpoint)]
|
||||
break
|
||||
|
||||
# 调用 models API
|
||||
try:
|
||||
response = requests.get(
|
||||
f"{base_url}/models",
|
||||
headers={
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
timeout=10
|
||||
)
|
||||
except requests.exceptions.InvalidSchema as e:
|
||||
raise Exception(f"URL 格式错误: {base_url}/models - 请确保 URL 以 http:// 或 https:// 开头")
|
||||
except requests.exceptions.ConnectionError as e:
|
||||
raise Exception(f"无法连接到 API: {base_url}/models - 请检查网络连接和 API 地址")
|
||||
except requests.exceptions.Timeout as e:
|
||||
raise Exception(f"请求超时: {base_url}/models - 请检查网络连接")
|
||||
|
||||
if response.status_code != 200:
|
||||
raise Exception(f"HTTP {response.status_code}: {response.text}")
|
||||
@@ -46,14 +63,8 @@ class LLMModelService:
|
||||
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
|
||||
# 返回所有模型,不做过滤
|
||||
return models
|
||||
|
||||
except Exception as e:
|
||||
raise Exception(f"获取 OpenAI 模型列表失败: {str(e)}")
|
||||
@@ -132,6 +143,9 @@ class LLMModelService:
|
||||
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 'bigmodel' in api_url_lower or 'glm' in api_url_lower:
|
||||
# 智谱AI GLM - 兼容 OpenAI API
|
||||
return 'openai'
|
||||
elif 'siliconflow' in api_url_lower or 'silicon.cloud' in api_url_lower:
|
||||
# SiliconFlow 等兼容 OpenAI API 的服务
|
||||
return 'openai'
|
||||
|
||||
332
backend/services/regex_service.py
Normal file
332
backend/services/regex_service.py
Normal file
@@ -0,0 +1,332 @@
|
||||
"""
|
||||
正则规则服务(重构版 - 文件夹结构)
|
||||
|
||||
负责加载、管理和应用正则替换规则。
|
||||
使用文件夹结构组织规则,兼容 SillyTavern 格式。
|
||||
|
||||
文件结构:
|
||||
data/regex/
|
||||
├── global/ # 全局规则
|
||||
│ └── default.json
|
||||
├── characters/ # 角色卡绑定规则
|
||||
│ └── {characterName}.json
|
||||
└── presets/ # 预设绑定规则
|
||||
└── {presetName}.json
|
||||
"""
|
||||
import re
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Dict
|
||||
from uuid import uuid4
|
||||
|
||||
from core.config import settings
|
||||
from models.regex_rules import RegexRule, RegexRuleset, RegexScope, RegexPlacement, SubstituteMode
|
||||
from services.system_settings_service import system_settings_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RegexService:
|
||||
"""
|
||||
正则替换规则服务(文件夹结构版)
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.regex_base_path = settings.DATA_PATH / "regex"
|
||||
self.global_path = self.regex_base_path / "global"
|
||||
self.characters_path = self.regex_base_path / "characters"
|
||||
self.presets_path = self.regex_base_path / "presets"
|
||||
|
||||
# 内存缓存
|
||||
self.global_rulesets: Dict[str, RegexRuleset] = {}
|
||||
self.character_rulesets: Dict[str, RegexRuleset] = {} # key: characterName
|
||||
self.preset_rulesets: Dict[str, RegexRuleset] = {} # key: presetName
|
||||
|
||||
self._ensure_directories()
|
||||
self._load_all_rules()
|
||||
|
||||
def _ensure_directories(self):
|
||||
"""确保目录结构存在"""
|
||||
for path in [self.regex_base_path, self.global_path, self.characters_path, self.presets_path]:
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _load_all_rules(self):
|
||||
"""加载所有规则"""
|
||||
self._load_global_rules()
|
||||
self._load_character_rules()
|
||||
self._load_preset_rules()
|
||||
|
||||
def _load_global_rules(self):
|
||||
"""加载全局规则"""
|
||||
if not self.global_path.exists():
|
||||
return
|
||||
|
||||
for json_file in self.global_path.glob("*.json"):
|
||||
try:
|
||||
with open(json_file, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
if isinstance(data, list):
|
||||
# SillyTavern 格式
|
||||
ruleset = self._convert_sillytavern_format(data, json_file.stem)
|
||||
elif isinstance(data, dict) and 'rules' in data:
|
||||
# 我们的规则集格式
|
||||
ruleset = RegexRuleset(**data)
|
||||
else:
|
||||
logger.warning(f"未知的规则文件格式: {json_file}")
|
||||
continue
|
||||
|
||||
self.global_rulesets[ruleset.id] = ruleset
|
||||
logger.info(f"加载全局规则集: {ruleset.name} ({len(ruleset.rules)} 条规则)")
|
||||
except Exception as e:
|
||||
logger.error(f"加载全局规则失败 {json_file}: {e}")
|
||||
|
||||
def _load_character_rules(self):
|
||||
"""加载角色卡绑定规则"""
|
||||
if not self.characters_path.exists():
|
||||
return
|
||||
|
||||
for json_file in self.characters_path.glob("*.json"):
|
||||
try:
|
||||
character_name = json_file.stem
|
||||
with open(json_file, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
if isinstance(data, list):
|
||||
ruleset = self._convert_sillytavern_format(data, character_name, RegexScope.CHARACTER)
|
||||
elif isinstance(data, dict) and 'rules' in data:
|
||||
ruleset = RegexRuleset(**data)
|
||||
else:
|
||||
continue
|
||||
|
||||
# 确保所有规则的 scope 正确
|
||||
for rule in ruleset.rules:
|
||||
rule.scope = RegexScope.CHARACTER
|
||||
rule.characterName = character_name
|
||||
|
||||
self.character_rulesets[character_name] = ruleset
|
||||
logger.info(f"加载角色规则: {character_name} ({len(ruleset.rules)} 条规则)")
|
||||
except Exception as e:
|
||||
logger.error(f"加载角色规则失败 {json_file}: {e}")
|
||||
|
||||
def _load_preset_rules(self):
|
||||
"""加载预设绑定规则"""
|
||||
if not self.presets_path.exists():
|
||||
return
|
||||
|
||||
for json_file in self.presets_path.glob("*.json"):
|
||||
try:
|
||||
preset_name = json_file.stem
|
||||
with open(json_file, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
if isinstance(data, list):
|
||||
ruleset = self._convert_sillytavern_format(data, preset_name, RegexScope.PRESET)
|
||||
elif isinstance(data, dict) and 'rules' in data:
|
||||
ruleset = RegexRuleset(**data)
|
||||
else:
|
||||
continue
|
||||
|
||||
# 确保所有规则的 scope 正确
|
||||
for rule in ruleset.rules:
|
||||
rule.scope = RegexScope.PRESET
|
||||
rule.presetName = preset_name
|
||||
|
||||
self.preset_rulesets[preset_name] = ruleset
|
||||
logger.info(f"加载预设规则: {preset_name} ({len(ruleset.rules)} 条规则)")
|
||||
except Exception as e:
|
||||
logger.error(f"加载预设规则失败 {json_file}: {e}")
|
||||
|
||||
def _convert_sillytavern_format(
|
||||
self,
|
||||
st_rules: List[dict],
|
||||
name: str,
|
||||
scope: RegexScope = RegexScope.GLOBAL
|
||||
) -> RegexRuleset:
|
||||
"""将 SillyTavern 格式转换为内部格式"""
|
||||
rules = []
|
||||
for idx, st_rule in enumerate(st_rules):
|
||||
find_regex = st_rule.get('findRegex', '')
|
||||
pattern, flags = self._parse_st_regex(find_regex)
|
||||
|
||||
# 解析 placement(默认为 AI_OUTPUT)
|
||||
placement_data = st_rule.get('placement', [2])
|
||||
placement = [RegexPlacement(p) for p in placement_data]
|
||||
|
||||
rule = RegexRule(
|
||||
id=str(uuid4()),
|
||||
scriptName=st_rule.get('scriptName', f"{name} 规则 {idx + 1}"),
|
||||
findRegex=pattern,
|
||||
replaceString=st_rule.get('replaceString', ''),
|
||||
trimStrings=st_rule.get('trimStrings', []),
|
||||
placement=placement,
|
||||
substituteRegex=SubstituteMode(st_rule.get('substituteRegex', 0)),
|
||||
markdownOnly=st_rule.get('markdownOnly', False),
|
||||
promptOnly=st_rule.get('promptOnly', False),
|
||||
runOnEdit=st_rule.get('runOnEdit', True),
|
||||
minDepth=st_rule.get('minDepth', 0),
|
||||
maxDepth=st_rule.get('maxDepth'),
|
||||
scope=scope,
|
||||
characterName=name if scope == RegexScope.CHARACTER else None,
|
||||
presetName=name if scope == RegexScope.PRESET else None,
|
||||
disabled=st_rule.get('disabled', False),
|
||||
order=idx
|
||||
)
|
||||
rules.append(rule)
|
||||
|
||||
ruleset = RegexRuleset(
|
||||
id=str(uuid4()),
|
||||
name=f"{name} 规则集",
|
||||
description=f"从 SillyTavern 导入的规则",
|
||||
rules=rules,
|
||||
isSillyTavernFormat=True
|
||||
)
|
||||
|
||||
return ruleset
|
||||
|
||||
def _parse_st_regex(self, st_regex: str) -> tuple[str, str]:
|
||||
"""解析 SillyTavern 的正则表达式格式 /pattern/flags"""
|
||||
if st_regex.startswith('/') and st_regex.count('/') >= 2:
|
||||
parts = st_regex.split('/')
|
||||
pattern = '/'.join(parts[1:-1])
|
||||
flags = parts[-1] if len(parts) > 2 else ''
|
||||
return pattern, flags
|
||||
else:
|
||||
return st_regex, ''
|
||||
|
||||
def apply_rules_by_placement(
|
||||
self,
|
||||
text: str,
|
||||
placement: int,
|
||||
character_name: Optional[str] = None,
|
||||
preset_name: Optional[str] = None,
|
||||
message_depth: int = 0
|
||||
) -> str:
|
||||
"""
|
||||
根据 placement 应用正则规则
|
||||
|
||||
Args:
|
||||
text: 要处理的文本
|
||||
placement: 应用位置(0-5)
|
||||
character_name: 当前角色卡名称
|
||||
preset_name: 当前预设名称
|
||||
message_depth: 消息深度
|
||||
|
||||
Returns:
|
||||
处理后的文本
|
||||
"""
|
||||
rules = self.get_rules_for_context(character_name, preset_name)
|
||||
|
||||
result = text
|
||||
for rule in rules:
|
||||
# 检查此规则是否适用于当前 placement
|
||||
if placement not in [p.value for p in rule.placement]:
|
||||
continue
|
||||
|
||||
# 检查消息深度限制
|
||||
if message_depth < rule.minDepth:
|
||||
continue
|
||||
if rule.maxDepth is not None and message_depth > rule.maxDepth:
|
||||
continue
|
||||
|
||||
# 应用规则
|
||||
result = self._apply_single_rule(result, rule)
|
||||
|
||||
return result
|
||||
|
||||
def _apply_single_rule(self, text: str, rule: RegexRule) -> str:
|
||||
"""应用单条正则规则"""
|
||||
try:
|
||||
flags = 0
|
||||
if 'i' in rule.findRegex:
|
||||
flags |= re.IGNORECASE
|
||||
if 'm' in rule.findRegex:
|
||||
flags |= re.MULTILINE
|
||||
if 's' in rule.findRegex:
|
||||
flags |= re.DOTALL
|
||||
|
||||
pattern = rule.findRegex.replace('i', '').replace('m', '').replace('s', '')
|
||||
|
||||
if rule.substituteRegex == SubstituteMode.REPLACE_FIRST:
|
||||
result = re.sub(pattern, rule.replaceString, text, count=1, flags=flags)
|
||||
else:
|
||||
result = re.sub(pattern, rule.replaceString, text, flags=flags)
|
||||
|
||||
for trim_str in rule.trimStrings:
|
||||
result = result.replace(trim_str, '')
|
||||
|
||||
return result
|
||||
except re.error as e:
|
||||
logger.error(f"正则表达式错误 [{rule.scriptName}]: {e}")
|
||||
return text
|
||||
|
||||
def get_rules_for_context(
|
||||
self,
|
||||
character_name: Optional[str] = None,
|
||||
preset_name: Optional[str] = None
|
||||
) -> List[RegexRule]:
|
||||
"""
|
||||
根据上下文获取适用的规则列表
|
||||
|
||||
优先级:全局规则 + 角色规则 + 预设规则
|
||||
"""
|
||||
applicable_rules = []
|
||||
|
||||
# 1. 加载全局规则
|
||||
for ruleset in self.global_rulesets.values():
|
||||
for rule in ruleset.rules:
|
||||
if not rule.disabled:
|
||||
applicable_rules.append(rule)
|
||||
|
||||
# 2. 加载角色卡规则
|
||||
if character_name and character_name in self.character_rulesets:
|
||||
ruleset = self.character_rulesets[character_name]
|
||||
for rule in ruleset.rules:
|
||||
if not rule.disabled:
|
||||
applicable_rules.append(rule)
|
||||
|
||||
# 3. 加载预设规则
|
||||
if preset_name and preset_name in self.preset_rulesets:
|
||||
ruleset = self.preset_rulesets[preset_name]
|
||||
for rule in ruleset.rules:
|
||||
if not rule.disabled:
|
||||
applicable_rules.append(rule)
|
||||
|
||||
# 按 order 排序
|
||||
applicable_rules.sort(key=lambda r: r.order)
|
||||
|
||||
return applicable_rules
|
||||
|
||||
def save_ruleset(self, ruleset: RegexRuleset, scope: RegexScope, name: Optional[str] = None):
|
||||
"""保存规则集到文件"""
|
||||
if scope == RegexScope.GLOBAL:
|
||||
file_path = self.global_path / f"{ruleset.id}.json"
|
||||
elif scope == RegexScope.CHARACTER:
|
||||
file_path = self.characters_path / f"{name or 'unknown'}.json"
|
||||
elif scope == RegexScope.PRESET:
|
||||
file_path = self.presets_path / f"{name or 'unknown'}.json"
|
||||
else:
|
||||
raise ValueError(f"未知的作用域: {scope}")
|
||||
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(ruleset.dict(), f, ensure_ascii=False, indent=2)
|
||||
|
||||
logger.info(f"保存规则集到: {file_path}")
|
||||
|
||||
def delete_ruleset(self, scope: RegexScope, name: str):
|
||||
"""删除规则集"""
|
||||
if scope == RegexScope.CHARACTER:
|
||||
file_path = self.characters_path / f"{name}.json"
|
||||
elif scope == RegexScope.PRESET:
|
||||
file_path = self.presets_path / f"{name}.json"
|
||||
else:
|
||||
raise ValueError(f"不能删除全局规则集")
|
||||
|
||||
if file_path.exists():
|
||||
file_path.unlink()
|
||||
logger.info(f"删除规则集: {file_path}")
|
||||
|
||||
|
||||
# 全局实例
|
||||
regex_service = RegexService()
|
||||
95
backend/services/system_settings_service.py
Normal file
95
backend/services/system_settings_service.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""
|
||||
系统设置服务
|
||||
|
||||
负责加载、保存和管理全局系统设置。
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from core.config import settings
|
||||
from models.system_settings import SystemSettings, DEFAULT_SYSTEM_SETTINGS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SystemSettingsService:
|
||||
"""
|
||||
系统设置服务
|
||||
|
||||
提供设置的加载、保存和访问功能
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.settings_file = settings.SYSTEM_SETTINGS_FILE
|
||||
self._settings: Optional[SystemSettings] = None
|
||||
self._load_settings()
|
||||
|
||||
def _load_settings(self):
|
||||
"""从文件加载系统设置"""
|
||||
if self.settings_file.exists():
|
||||
try:
|
||||
with open(self.settings_file, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
self._settings = SystemSettings(**data)
|
||||
logger.info(f"加载系统设置成功")
|
||||
except Exception as e:
|
||||
logger.error(f"加载系统设置失败: {e}")
|
||||
self._settings = DEFAULT_SYSTEM_SETTINGS.copy()
|
||||
else:
|
||||
logger.info("系统设置文件不存在,使用默认设置")
|
||||
self._settings = DEFAULT_SYSTEM_SETTINGS.copy()
|
||||
self._save_settings()
|
||||
|
||||
def _save_settings(self):
|
||||
"""保存系统设置到文件"""
|
||||
try:
|
||||
# 确保父目录存在
|
||||
self.settings_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 写入文件
|
||||
with open(self.settings_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(self._settings.dict(), f, ensure_ascii=False, indent=2)
|
||||
|
||||
logger.info(f"系统设置已保存到 {self.settings_file}")
|
||||
except Exception as e:
|
||||
logger.error(f"保存系统设置失败: {e}")
|
||||
|
||||
@property
|
||||
def settings(self) -> SystemSettings:
|
||||
"""获取当前系统设置"""
|
||||
return self._settings
|
||||
|
||||
def update_thinking_tags(self, prefix: str, suffix: str):
|
||||
"""更新思考标签配置"""
|
||||
self._settings.thinkingTagPrefix = prefix
|
||||
self._settings.thinkingTagSuffix = suffix
|
||||
self._settings.updatedAt = int(__import__('time').time())
|
||||
self._save_settings()
|
||||
logger.info(f"思考标签已更新: {prefix} ... {suffix}")
|
||||
|
||||
def update_current_preset(self, preset_name: Optional[str]):
|
||||
"""更新当前选中的预设名称"""
|
||||
self._settings.currentPresetName = preset_name
|
||||
self._settings.updatedAt = int(__import__('time').time())
|
||||
self._save_settings()
|
||||
logger.info(f"当前预设已更新: {preset_name}")
|
||||
|
||||
def get_thinking_tag_pattern(self) -> str:
|
||||
"""获取思考标签的正则表达式模式"""
|
||||
prefix = self._settings.thinkingTagPrefix
|
||||
suffix = self._settings.thinkingTagSuffix
|
||||
|
||||
# 转义特殊字符
|
||||
import re
|
||||
escaped_prefix = re.escape(prefix)
|
||||
escaped_suffix = re.escape(suffix)
|
||||
|
||||
# 返回匹配思考内容的正则模式
|
||||
return f"{escaped_prefix}[\\s\\S]*?{escaped_suffix}"
|
||||
|
||||
|
||||
# 全局实例
|
||||
system_settings_service = SystemSettingsService()
|
||||
161
backend/services/task_queue_manager.py
Normal file
161
backend/services/task_queue_manager.py
Normal file
@@ -0,0 +1,161 @@
|
||||
"""
|
||||
任务队列管理器
|
||||
管理并行任务(生图、动态表格维护等)的状态和生命周期
|
||||
"""
|
||||
import asyncio
|
||||
from typing import Dict, List, Optional
|
||||
from enum import Enum
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class TaskStatus(Enum):
|
||||
"""任务状态枚举"""
|
||||
PENDING = "pending" # 等待中
|
||||
RUNNING = "running" # 进行中
|
||||
COMPLETED = "completed" # 已完成
|
||||
FAILED = "failed" # 失败
|
||||
CANCELLED = "cancelled" # 已取消
|
||||
|
||||
|
||||
class TaskType(Enum):
|
||||
"""任务类型枚举"""
|
||||
IMAGE_WORKFLOW = "image_workflow"
|
||||
DYNAMIC_TABLE = "dynamic_table"
|
||||
|
||||
|
||||
class TaskItem:
|
||||
"""任务项"""
|
||||
|
||||
def __init__(self, task_id: str, task_type: TaskType, chat_id: str):
|
||||
self.task_id = task_id
|
||||
self.task_type = task_type
|
||||
self.chat_id = chat_id
|
||||
self.status = TaskStatus.PENDING
|
||||
self.created_at = datetime.now()
|
||||
self.started_at = None
|
||||
self.completed_at = None
|
||||
self.error = None
|
||||
self.metadata = {} # 用于存储提示词、修改内容等
|
||||
|
||||
def to_dict(self):
|
||||
"""转换为字典格式(前端友好)"""
|
||||
return {
|
||||
"taskId": self.task_id,
|
||||
"taskType": self.task_type.value,
|
||||
"chatId": self.chat_id,
|
||||
"status": self.status.value,
|
||||
"createdAt": self.created_at.isoformat(),
|
||||
"startedAt": self.started_at.isoformat() if self.started_at else None,
|
||||
"completedAt": self.completed_at.isoformat() if self.completed_at else None,
|
||||
"error": self.error,
|
||||
"metadata": self.metadata
|
||||
}
|
||||
|
||||
|
||||
class TaskQueueManager:
|
||||
"""
|
||||
全局任务队列管理器
|
||||
|
||||
功能:
|
||||
- 管理所有并行任务的生命周期
|
||||
- 支持按聊天ID查询任务
|
||||
- 支持取消任务
|
||||
- 自动清理已完成的任务
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.tasks: Dict[str, TaskItem] = {}
|
||||
self.chat_tasks: Dict[str, List[str]] = {} # chat_id -> [task_ids]
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def add_task(self, task_id: str, task_type: TaskType, chat_id: str) -> TaskItem:
|
||||
"""添加任务到队列"""
|
||||
async with self._lock:
|
||||
task = TaskItem(task_id, task_type, chat_id)
|
||||
self.tasks[task_id] = task
|
||||
|
||||
if chat_id not in self.chat_tasks:
|
||||
self.chat_tasks[chat_id] = []
|
||||
self.chat_tasks[chat_id].append(task_id)
|
||||
|
||||
return task
|
||||
|
||||
async def start_task(self, task_id: str):
|
||||
"""标记任务开始执行"""
|
||||
async with self._lock:
|
||||
if task_id in self.tasks:
|
||||
self.tasks[task_id].status = TaskStatus.RUNNING
|
||||
self.tasks[task_id].started_at = datetime.now()
|
||||
|
||||
async def complete_task(self, task_id: str, metadata: dict = None):
|
||||
"""标记任务完成"""
|
||||
async with self._lock:
|
||||
if task_id in self.tasks:
|
||||
self.tasks[task_id].status = TaskStatus.COMPLETED
|
||||
self.tasks[task_id].completed_at = datetime.now()
|
||||
if metadata:
|
||||
self.tasks[task_id].metadata.update(metadata)
|
||||
|
||||
async def fail_task(self, task_id: str, error: str):
|
||||
"""标记任务失败"""
|
||||
async with self._lock:
|
||||
if task_id in self.tasks:
|
||||
self.tasks[task_id].status = TaskStatus.FAILED
|
||||
self.tasks[task_id].completed_at = datetime.now()
|
||||
self.tasks[task_id].error = error
|
||||
|
||||
async def cancel_task(self, task_id: str) -> bool:
|
||||
"""
|
||||
取消任务
|
||||
|
||||
Returns:
|
||||
bool: 是否成功取消
|
||||
"""
|
||||
async with self._lock:
|
||||
if task_id in self.tasks:
|
||||
task = self.tasks[task_id]
|
||||
if task.status in [TaskStatus.PENDING, TaskStatus.RUNNING]:
|
||||
task.status = TaskStatus.CANCELLED
|
||||
task.completed_at = datetime.now()
|
||||
return True
|
||||
return False
|
||||
|
||||
async def get_chat_tasks(self, chat_id: str, include_completed: bool = False) -> List[dict]:
|
||||
"""
|
||||
获取某个聊天的所有任务
|
||||
|
||||
Args:
|
||||
chat_id: 聊天ID
|
||||
include_completed: 是否包含已完成的任务
|
||||
|
||||
Returns:
|
||||
List[dict]: 任务列表
|
||||
"""
|
||||
async with self._lock:
|
||||
task_ids = self.chat_tasks.get(chat_id, [])
|
||||
tasks = []
|
||||
for task_id in task_ids:
|
||||
if task_id in self.tasks:
|
||||
task = self.tasks[task_id]
|
||||
# 根据参数决定是否包含已完成的任务
|
||||
if include_completed or task.status in [TaskStatus.PENDING, TaskStatus.RUNNING]:
|
||||
tasks.append(task.to_dict())
|
||||
return tasks
|
||||
|
||||
async def cleanup_completed_tasks(self, chat_id: str):
|
||||
"""清理已完成的任务"""
|
||||
async with self._lock:
|
||||
if chat_id in self.chat_tasks:
|
||||
task_ids = self.chat_tasks[chat_id]
|
||||
completed_ids = [
|
||||
tid for tid in task_ids
|
||||
if tid in self.tasks and
|
||||
self.tasks[tid].status in [TaskStatus.COMPLETED, TaskStatus.FAILED, TaskStatus.CANCELLED]
|
||||
]
|
||||
for tid in completed_ids:
|
||||
del self.tasks[tid]
|
||||
self.chat_tasks[chat_id].remove(tid)
|
||||
|
||||
|
||||
# 全局实例
|
||||
task_queue_manager = TaskQueueManager()
|
||||
427
backend/services/token_usage_service.py
Normal file
427
backend/services/token_usage_service.py
Normal file
@@ -0,0 +1,427 @@
|
||||
"""
|
||||
Token 使用统计服务
|
||||
|
||||
负责记录、查询和分析 LLM 调用的 token 使用情况
|
||||
数据持久化到 data/token_usage 目录,按月份组织
|
||||
采用双层存储:
|
||||
1. JSONL 文件 - 详细记录(按月存储)
|
||||
2. 索引文件 - 快速聚合统计(按 API URL、日期等维度)
|
||||
"""
|
||||
import json
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional, Any
|
||||
from datetime import datetime
|
||||
from collections import defaultdict
|
||||
|
||||
try:
|
||||
from backend.models.internal import TokenUsageRecord, TokenUsageStatus
|
||||
from backend.core.config import settings
|
||||
except ImportError:
|
||||
from models.internal import TokenUsageRecord, TokenUsageStatus
|
||||
from core.config import settings
|
||||
|
||||
|
||||
class TokenUsageService:
|
||||
"""
|
||||
Token 使用统计服务
|
||||
|
||||
功能:
|
||||
- 记录每次 LLM 调用的 token 使用情况
|
||||
- 按月份、日期、角色、聊天、API URL 维度统计
|
||||
- 支持中断和失败标记
|
||||
- 数据持久化到文件系统(JSONL + 索引)
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.token_usage_dir = settings.DATA_PATH / "token_usage"
|
||||
self.token_usage_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# ✅ 索引文件目录 - 用于快速聚合查询
|
||||
self.index_dir = self.token_usage_dir / "indexes"
|
||||
self.index_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _get_month_file(self, year: int, month: int) -> Path:
|
||||
"""获取指定月份的统计文件路径"""
|
||||
month_dir = self.token_usage_dir / f"{year}"
|
||||
month_dir.mkdir(parents=True, exist_ok=True)
|
||||
return month_dir / f"{month:02d}.jsonl"
|
||||
|
||||
def _load_month_records(self, year: int, month: int) -> List[TokenUsageRecord]:
|
||||
"""加载指定月份的所有记录"""
|
||||
file_path = self._get_month_file(year, month)
|
||||
|
||||
if not file_path.exists():
|
||||
return []
|
||||
|
||||
records = []
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
for line in f:
|
||||
if line.strip():
|
||||
data = json.loads(line)
|
||||
records.append(TokenUsageRecord(**data))
|
||||
except Exception as e:
|
||||
print(f"[TokenUsage] 加载记录失败: {e}")
|
||||
|
||||
return records
|
||||
|
||||
def _save_record(self, record: TokenUsageRecord):
|
||||
"""保存单条记录到对应的月份文件"""
|
||||
dt = datetime.fromtimestamp(record.timestamp)
|
||||
file_path = self._get_month_file(dt.year, dt.month)
|
||||
|
||||
try:
|
||||
with open(file_path, 'a', encoding='utf-8') as f:
|
||||
f.write(json.dumps(record.model_dump(), ensure_ascii=False) + '\n')
|
||||
|
||||
# ✅ 同时更新索引文件(用于快速查询)
|
||||
self._update_indexes(record)
|
||||
except Exception as e:
|
||||
print(f"[TokenUsage] 保存记录失败: {e}")
|
||||
|
||||
def _update_indexes(self, record: TokenUsageRecord):
|
||||
"""
|
||||
更新索引文件 - 实现高效的按维度聚合查询
|
||||
|
||||
索引结构:
|
||||
- indexes/api_urls.json - 按 API URL 聚合
|
||||
- indexes/daily/{year}-{month}.json - 按日聚合
|
||||
"""
|
||||
dt = datetime.fromtimestamp(record.timestamp)
|
||||
|
||||
# 1. 更新 API URL 索引
|
||||
if record.apiUrl:
|
||||
api_url_index = self.index_dir / "api_urls.json"
|
||||
self._update_api_url_index(api_url_index, record)
|
||||
|
||||
# 2. 更新每日索引
|
||||
daily_index = self.index_dir / "daily" / f"{dt.year}-{dt.month:02d}.json"
|
||||
daily_index.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._update_daily_index(daily_index, record)
|
||||
|
||||
def _update_api_url_index(self, index_file: Path, record: TokenUsageRecord):
|
||||
"""更新 API URL 索引文件"""
|
||||
index_data = {}
|
||||
|
||||
# 加载现有索引
|
||||
if index_file.exists():
|
||||
try:
|
||||
with open(index_file, 'r', encoding='utf-8') as f:
|
||||
index_data = json.load(f)
|
||||
except:
|
||||
index_data = {}
|
||||
|
||||
# 更新统计
|
||||
api_url = record.apiUrl
|
||||
if api_url not in index_data:
|
||||
index_data[api_url] = {
|
||||
"totalPromptTokens": 0,
|
||||
"totalCompletionTokens": 0,
|
||||
"totalTokens": 0,
|
||||
"count": 0,
|
||||
"firstUsed": record.timestamp,
|
||||
"lastUsed": record.timestamp
|
||||
}
|
||||
|
||||
stats = index_data[api_url]
|
||||
stats["totalPromptTokens"] += record.promptTokens
|
||||
stats["totalCompletionTokens"] += record.completionTokens
|
||||
stats["totalTokens"] += record.totalTokens
|
||||
stats["count"] += 1
|
||||
stats["lastUsed"] = max(stats["lastUsed"], record.timestamp)
|
||||
stats["firstUsed"] = min(stats["firstUsed"], record.timestamp)
|
||||
|
||||
# 保存索引
|
||||
with open(index_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(index_data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
def _update_daily_index(self, index_file: Path, record: TokenUsageRecord):
|
||||
"""更新每日索引文件"""
|
||||
dt = datetime.fromtimestamp(record.timestamp)
|
||||
day_key = f"{dt.year}-{dt.month:02d}-{dt.day:02d}"
|
||||
|
||||
index_data = {}
|
||||
|
||||
# 加载现有索引
|
||||
if index_file.exists():
|
||||
try:
|
||||
with open(index_file, 'r', encoding='utf-8') as f:
|
||||
index_data = json.load(f)
|
||||
except:
|
||||
index_data = {}
|
||||
|
||||
# 更新统计
|
||||
if day_key not in index_data:
|
||||
index_data[day_key] = {
|
||||
"promptTokens": 0,
|
||||
"completionTokens": 0,
|
||||
"totalTokens": 0,
|
||||
"count": 0
|
||||
}
|
||||
|
||||
stats = index_data[day_key]
|
||||
stats["promptTokens"] += record.promptTokens
|
||||
stats["completionTokens"] += record.completionTokens
|
||||
stats["totalTokens"] += record.totalTokens
|
||||
stats["count"] += 1
|
||||
|
||||
# 保存索引
|
||||
with open(index_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(index_data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
async def record_usage(
|
||||
self,
|
||||
chat_id: str,
|
||||
role_name: str,
|
||||
chat_name: str,
|
||||
prompt_tokens: int,
|
||||
completion_tokens: int,
|
||||
total_tokens: int,
|
||||
status: TokenUsageStatus = TokenUsageStatus.COMPLETED,
|
||||
message_id: Optional[str] = None,
|
||||
floor: Optional[int] = None,
|
||||
error_message: Optional[str] = None,
|
||||
duration: Optional[float] = None,
|
||||
model: Optional[str] = None,
|
||||
api_provider: Optional[str] = None,
|
||||
api_url: Optional[str] = None
|
||||
) -> TokenUsageRecord:
|
||||
"""
|
||||
记录一次 LLM 调用的 token 使用情况
|
||||
|
||||
Args:
|
||||
chat_id: 聊天ID
|
||||
role_name: 角色名称
|
||||
chat_name: 聊天名称
|
||||
prompt_tokens: 输入 token 数
|
||||
completion_tokens: 输出 token 数
|
||||
total_tokens: 总 token 数
|
||||
status: 请求状态
|
||||
message_id: 关联的消息ID
|
||||
floor: 楼层号
|
||||
error_message: 错误信息
|
||||
duration: 请求耗时
|
||||
model: 使用的模型
|
||||
api_provider: API 提供商
|
||||
api_url: API URL地址
|
||||
|
||||
Returns:
|
||||
TokenUsageRecord: 创建的记录
|
||||
"""
|
||||
record = TokenUsageRecord(
|
||||
id=str(uuid.uuid4()),
|
||||
chatId=chat_id,
|
||||
roleName=role_name,
|
||||
chatName=chat_name,
|
||||
messageId=message_id,
|
||||
floor=floor,
|
||||
promptTokens=prompt_tokens,
|
||||
completionTokens=completion_tokens,
|
||||
totalTokens=total_tokens,
|
||||
status=status,
|
||||
errorMessage=error_message,
|
||||
duration=duration,
|
||||
model=model,
|
||||
apiProvider=api_provider,
|
||||
apiUrl=api_url
|
||||
)
|
||||
|
||||
self._save_record(record)
|
||||
return record
|
||||
|
||||
async def get_stats_by_month(
|
||||
self,
|
||||
year: int,
|
||||
month: int,
|
||||
role_name: Optional[str] = None,
|
||||
chat_name: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
获取指定月份的统计数据
|
||||
|
||||
Args:
|
||||
year: 年份
|
||||
month: 月份
|
||||
role_name: 角色名称(可选,用于过滤)
|
||||
chat_name: 聊天名称(可选,用于过滤)
|
||||
|
||||
Returns:
|
||||
统计数据字典
|
||||
"""
|
||||
records = self._load_month_records(year, month)
|
||||
|
||||
# 过滤
|
||||
if role_name:
|
||||
records = [r for r in records if r.roleName == role_name]
|
||||
if chat_name:
|
||||
records = [r for r in records if r.chatName == chat_name]
|
||||
|
||||
# 统计
|
||||
total_prompt = sum(r.promptTokens for r in records)
|
||||
total_completion = sum(r.completionTokens for r in records)
|
||||
total_tokens = sum(r.totalTokens for r in records)
|
||||
|
||||
completed_count = sum(1 for r in records if r.status == TokenUsageStatus.COMPLETED)
|
||||
interrupted_count = sum(1 for r in records if r.status == TokenUsageStatus.INTERRUPTED)
|
||||
failed_count = sum(1 for r in records if r.status == TokenUsageStatus.FAILED)
|
||||
|
||||
# 按日期分组
|
||||
daily_stats = defaultdict(lambda: {
|
||||
"promptTokens": 0,
|
||||
"completionTokens": 0,
|
||||
"totalTokens": 0,
|
||||
"count": 0
|
||||
})
|
||||
|
||||
for r in records:
|
||||
dt = datetime.fromtimestamp(r.timestamp)
|
||||
day_key = f"{dt.year}-{dt.month:02d}-{dt.day:02d}"
|
||||
daily_stats[day_key]["promptTokens"] += r.promptTokens
|
||||
daily_stats[day_key]["completionTokens"] += r.completionTokens
|
||||
daily_stats[day_key]["totalTokens"] += r.totalTokens
|
||||
daily_stats[day_key]["count"] += 1
|
||||
|
||||
# 按角色分组
|
||||
role_stats = defaultdict(lambda: {
|
||||
"promptTokens": 0,
|
||||
"completionTokens": 0,
|
||||
"totalTokens": 0,
|
||||
"count": 0
|
||||
})
|
||||
|
||||
for r in records:
|
||||
role_stats[r.roleName]["promptTokens"] += r.promptTokens
|
||||
role_stats[r.roleName]["completionTokens"] += r.completionTokens
|
||||
role_stats[r.roleName]["totalTokens"] += r.totalTokens
|
||||
role_stats[r.roleName]["count"] += 1
|
||||
|
||||
# 按聊天分组
|
||||
chat_stats = defaultdict(lambda: {
|
||||
"promptTokens": 0,
|
||||
"completionTokens": 0,
|
||||
"totalTokens": 0,
|
||||
"count": 0
|
||||
})
|
||||
|
||||
for r in records:
|
||||
chat_key = f"{r.roleName}/{r.chatName}"
|
||||
chat_stats[chat_key]["promptTokens"] += r.promptTokens
|
||||
chat_stats[chat_key]["completionTokens"] += r.completionTokens
|
||||
chat_stats[chat_key]["totalTokens"] += r.totalTokens
|
||||
chat_stats[chat_key]["count"] += 1
|
||||
|
||||
# ✅ 按 API URL 分组
|
||||
api_url_stats = defaultdict(lambda: {
|
||||
"promptTokens": 0,
|
||||
"completionTokens": 0,
|
||||
"totalTokens": 0,
|
||||
"count": 0
|
||||
})
|
||||
|
||||
for r in records:
|
||||
if r.apiUrl:
|
||||
api_url_stats[r.apiUrl]["promptTokens"] += r.promptTokens
|
||||
api_url_stats[r.apiUrl]["completionTokens"] += r.completionTokens
|
||||
api_url_stats[r.apiUrl]["totalTokens"] += r.totalTokens
|
||||
api_url_stats[r.apiUrl]["count"] += 1
|
||||
|
||||
return {
|
||||
"year": year,
|
||||
"month": month,
|
||||
"totalRecords": len(records),
|
||||
"totalPromptTokens": total_prompt,
|
||||
"totalCompletionTokens": total_completion,
|
||||
"totalTokens": total_tokens,
|
||||
"completedCount": completed_count,
|
||||
"interruptedCount": interrupted_count,
|
||||
"failedCount": failed_count,
|
||||
"dailyStats": dict(daily_stats),
|
||||
"roleStats": dict(role_stats),
|
||||
"chatStats": dict(chat_stats),
|
||||
"apiUrlStats": dict(api_url_stats), # ✅ 新增
|
||||
"records": [r.model_dump() for r in records[:100]] # 最近100条记录
|
||||
}
|
||||
|
||||
async def list_months(self) -> List[Dict[str, int]]:
|
||||
"""列出所有有数据的月份"""
|
||||
months = []
|
||||
|
||||
if not self.token_usage_dir.exists():
|
||||
return months
|
||||
|
||||
for year_dir in sorted(self.token_usage_dir.iterdir()):
|
||||
if year_dir.is_dir() and year_dir.name.isdigit():
|
||||
year = int(year_dir.name)
|
||||
for month_file in sorted(year_dir.glob("*.jsonl")):
|
||||
month = int(month_file.stem)
|
||||
months.append({"year": year, "month": month})
|
||||
|
||||
return months
|
||||
|
||||
async def get_api_url_stats(self) -> Dict[str, Any]:
|
||||
"""
|
||||
✅ 获取按 API URL 分组的统计数据(从索引文件快速读取)
|
||||
|
||||
Returns:
|
||||
{api_url: {totalPromptTokens, totalCompletionTokens, totalTokens, count, firstUsed, lastUsed}}
|
||||
"""
|
||||
api_url_index = self.index_dir / "api_urls.json"
|
||||
|
||||
if not api_url_index.exists():
|
||||
return {}
|
||||
|
||||
try:
|
||||
with open(api_url_index, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
print(f"[TokenUsage] 读取 API URL 索引失败: {e}")
|
||||
return {}
|
||||
|
||||
async def get_daily_stats(self, year: int, month: int) -> Dict[str, Any]:
|
||||
"""
|
||||
✅ 获取指定月份的每日统计数据(从索引文件快速读取)
|
||||
|
||||
Args:
|
||||
year: 年份
|
||||
month: 月份
|
||||
|
||||
Returns:
|
||||
{day_key: {promptTokens, completionTokens, totalTokens, count}}
|
||||
"""
|
||||
daily_index = self.index_dir / "daily" / f"{year}-{month:02d}.json"
|
||||
|
||||
if not daily_index.exists():
|
||||
return {}
|
||||
|
||||
try:
|
||||
with open(daily_index, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
print(f"[TokenUsage] 读取每日索引失败: {e}")
|
||||
return {}
|
||||
|
||||
async def get_available_roles(self, year: int, month: int) -> List[str]:
|
||||
"""获取指定月份有数据的角色列表"""
|
||||
records = self._load_month_records(year, month)
|
||||
roles = set(r.roleName for r in records)
|
||||
return sorted(list(roles))
|
||||
|
||||
async def get_available_chats(
|
||||
self,
|
||||
year: int,
|
||||
month: int,
|
||||
role_name: Optional[str] = None
|
||||
) -> List[str]:
|
||||
"""获取指定月份有数据的聊天列表"""
|
||||
records = self._load_month_records(year, month)
|
||||
|
||||
if role_name:
|
||||
records = [r for r in records if r.roleName == role_name]
|
||||
|
||||
chats = set(f"{r.roleName}/{r.chatName}" for r in records)
|
||||
return sorted(list(chats))
|
||||
|
||||
|
||||
# 全局实例
|
||||
token_usage_service = TokenUsageService()
|
||||
@@ -4,9 +4,12 @@ LLM 客户端工具
|
||||
提供统一的 LLM 接口,支持多种模型提供商。
|
||||
使用 LangChain 的 ChatModel 抽象,简化不同厂商 API 的调用。
|
||||
"""
|
||||
from typing import Optional
|
||||
from typing import Optional, List, Dict, Any, AsyncGenerator
|
||||
from langchain_core.language_models.chat_models import BaseChatModel
|
||||
from langchain_core.messages import BaseMessage, HumanMessage, SystemMessage, AIMessage
|
||||
from langchain_core.callbacks import AsyncCallbackHandler
|
||||
from core.config import settings
|
||||
import time
|
||||
|
||||
|
||||
def get_llm(
|
||||
@@ -86,3 +89,249 @@ def get_creative_llm(provider: str = "openai") -> BaseChatModel:
|
||||
def get_streaming_llm(provider: str = "openai") -> BaseChatModel:
|
||||
"""获取支持流式输出的 LLM"""
|
||||
return get_llm(provider, streaming=True)
|
||||
|
||||
|
||||
class TokenUsageCallbackHandler(AsyncCallbackHandler):
|
||||
"""
|
||||
Token 使用回调处理器
|
||||
|
||||
用于捕获 LLM 调用的 token 使用情况
|
||||
"""
|
||||
def __init__(self):
|
||||
self.prompt_tokens = 0
|
||||
self.completion_tokens = 0
|
||||
self.total_tokens = 0
|
||||
self.response_content = ""
|
||||
|
||||
async def on_llm_start(self, serialized: Dict[str, Any], prompts: List[str] = None, **kwargs):
|
||||
"""LLM 开始时的回调"""
|
||||
pass
|
||||
|
||||
async def on_llm_end(self, response, **kwargs):
|
||||
"""LLM 结束时的回调,获取 token 统计"""
|
||||
try:
|
||||
# 从 response 中提取 token 信息
|
||||
if hasattr(response, 'llm_output') and response.llm_output:
|
||||
token_usage = response.llm_output.get('token_usage', {})
|
||||
self.prompt_tokens = token_usage.get('prompt_tokens', 0)
|
||||
self.completion_tokens = token_usage.get('completion_tokens', 0)
|
||||
self.total_tokens = token_usage.get('total_tokens', 0)
|
||||
except Exception as e:
|
||||
print(f"[TokenUsageCallback] 提取 token 信息失败: {e}")
|
||||
|
||||
async def on_llm_new_token(self, token: str, **kwargs):
|
||||
"""每个新 token 的回调(流式输出)"""
|
||||
self.response_content += token
|
||||
|
||||
|
||||
class LLMClient:
|
||||
"""
|
||||
LLM 客户端封装类
|
||||
|
||||
提供统一的异步接口,支持自定义API配置、流式输出和 token 统计
|
||||
"""
|
||||
|
||||
async def chat_completion(
|
||||
self,
|
||||
messages: List[BaseMessage],
|
||||
api_url: str,
|
||||
api_key: str,
|
||||
model: str = "gpt-3.5-turbo",
|
||||
temperature: float = 1.0,
|
||||
max_tokens: int = 500,
|
||||
request_timeout: int = 60,
|
||||
stream: bool = False,
|
||||
**kwargs
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
调用 LLM API 生成回复
|
||||
|
||||
Args:
|
||||
messages: LangChain 消息列表
|
||||
api_url: API 地址
|
||||
api_key: API 密钥
|
||||
model: 模型名称
|
||||
temperature: 温度参数
|
||||
max_tokens: 最大 token 数
|
||||
request_timeout: 请求超时时间(秒)
|
||||
stream: 是否启用流式输出
|
||||
**kwargs: 其他参数
|
||||
|
||||
Returns:
|
||||
OpenAI 格式的响应字典,包含 token 使用信息
|
||||
"""
|
||||
try:
|
||||
from langchain_openai import ChatOpenAI
|
||||
|
||||
# 创建回调处理器
|
||||
callback_handler = TokenUsageCallbackHandler()
|
||||
|
||||
# 创建自定义的 ChatOpenAI 实例
|
||||
llm = ChatOpenAI(
|
||||
model=model,
|
||||
temperature=temperature,
|
||||
api_key=api_key,
|
||||
base_url=api_url if api_url else None,
|
||||
max_tokens=max_tokens,
|
||||
streaming=stream,
|
||||
callbacks=[callback_handler],
|
||||
request_timeout=request_timeout, # ✅ 设置超时时间
|
||||
**kwargs
|
||||
)
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
if stream:
|
||||
# 流式模式
|
||||
full_content = ""
|
||||
async for chunk in llm.astream(messages):
|
||||
if hasattr(chunk, 'content'):
|
||||
full_content += chunk.content
|
||||
|
||||
duration = time.time() - start_time
|
||||
|
||||
return {
|
||||
"choices": [
|
||||
{
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": full_content
|
||||
}
|
||||
}
|
||||
],
|
||||
"usage": {
|
||||
"prompt_tokens": callback_handler.prompt_tokens,
|
||||
"completion_tokens": callback_handler.completion_tokens,
|
||||
"total_tokens": callback_handler.total_tokens
|
||||
},
|
||||
"duration": duration
|
||||
}
|
||||
else:
|
||||
# 非流式模式
|
||||
response = await llm.ainvoke(messages)
|
||||
duration = time.time() - start_time
|
||||
|
||||
return {
|
||||
"choices": [
|
||||
{
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": response.content
|
||||
}
|
||||
}
|
||||
],
|
||||
"usage": {
|
||||
"prompt_tokens": callback_handler.prompt_tokens,
|
||||
"completion_tokens": callback_handler.completion_tokens,
|
||||
"total_tokens": callback_handler.total_tokens
|
||||
},
|
||||
"duration": duration
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
print(f"[LLMClient] 调用失败: {e}")
|
||||
raise
|
||||
|
||||
async def stream_chat(
|
||||
self,
|
||||
messages: List[BaseMessage],
|
||||
api_url: str,
|
||||
api_key: str,
|
||||
model: str = "gpt-3.5-turbo",
|
||||
temperature: float = 1.0,
|
||||
max_tokens: int = 500,
|
||||
request_timeout: int = 60,
|
||||
**kwargs
|
||||
) -> AsyncGenerator[Dict[str, Any], None]:
|
||||
"""
|
||||
流式调用 LLM API
|
||||
|
||||
Args:
|
||||
messages: LangChain 消息列表
|
||||
api_url: API 地址
|
||||
api_key: API 密钥
|
||||
model: 模型名称
|
||||
temperature: 温度参数
|
||||
max_tokens: 最大 token 数
|
||||
request_timeout: 请求超时时间(秒)
|
||||
**kwargs: 其他参数
|
||||
|
||||
Yields:
|
||||
包含 token 片段的字典
|
||||
"""
|
||||
try:
|
||||
from langchain_openai import ChatOpenAI
|
||||
|
||||
print(f"\n[LLMClient] 🔧 创建 ChatOpenAI 实例")
|
||||
print(f" - Model: {model}")
|
||||
print(f" - API URL: {api_url[:50]}..." if len(api_url) > 50 else f" - API URL: {api_url}")
|
||||
print(f" - Temperature: {temperature}")
|
||||
print(f" - Max Tokens: {max_tokens}")
|
||||
print(f" - Request Timeout: {request_timeout}s")
|
||||
|
||||
# 创建回调处理器
|
||||
callback_handler = TokenUsageCallbackHandler()
|
||||
|
||||
# 创建自定义的 ChatOpenAI 实例
|
||||
llm = ChatOpenAI(
|
||||
model=model,
|
||||
temperature=temperature,
|
||||
api_key=api_key,
|
||||
base_url=api_url if api_url else None,
|
||||
max_tokens=max_tokens,
|
||||
streaming=True,
|
||||
callbacks=[callback_handler],
|
||||
request_timeout=request_timeout, # ✅ 设置超时时间
|
||||
**kwargs
|
||||
)
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
print(f"[LLMClient] 🚀 开始流式请求...")
|
||||
print(f" - Messages 数量: {len(messages)}")
|
||||
if messages:
|
||||
first_msg_role = getattr(messages[0], 'role', 'unknown')
|
||||
first_msg_preview = str(getattr(messages[0], 'content', ''))[:50]
|
||||
print(f" - 第一条消息: [{first_msg_role}] {first_msg_preview}...")
|
||||
|
||||
chunk_count = 0
|
||||
# 流式输出
|
||||
async for chunk in llm.astream(messages):
|
||||
if hasattr(chunk, 'content') and chunk.content:
|
||||
chunk_count += 1
|
||||
|
||||
# 第一个 chunk 时记录
|
||||
if chunk_count == 1:
|
||||
first_chunk_time = time.time()
|
||||
print(f"[LLMClient] ✨ 收到第一个 chunk (耗时: {first_chunk_time - start_time:.2f}s)")
|
||||
|
||||
yield {
|
||||
"type": "chunk",
|
||||
"content": chunk.content
|
||||
}
|
||||
|
||||
duration = time.time() - start_time
|
||||
|
||||
print(f"[LLMClient] ✅ 流式请求完成")
|
||||
print(f" - 总 Chunks: {chunk_count}")
|
||||
print(f" - 耗时: {duration:.2f}秒")
|
||||
print(f" - Prompt Tokens: {callback_handler.prompt_tokens}")
|
||||
print(f" - Completion Tokens: {callback_handler.completion_tokens}")
|
||||
print(f" - Total Tokens: {callback_handler.total_tokens}\n")
|
||||
|
||||
# 最后发送 token 使用信息
|
||||
yield {
|
||||
"type": "usage",
|
||||
"usage": {
|
||||
"prompt_tokens": callback_handler.prompt_tokens,
|
||||
"completion_tokens": callback_handler.completion_tokens,
|
||||
"total_tokens": callback_handler.total_tokens
|
||||
},
|
||||
"duration": duration
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n[LLMClient] ❌ 流式调用失败: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
raise
|
||||
|
||||
Reference in New Issue
Block a user