完成请求推送,但组装mes还有问题

This commit is contained in:
2026-05-04 00:33:29 +08:00
parent 7fc9e10c99
commit 2050a30a52
93 changed files with 9499 additions and 2719 deletions

View File

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

View File

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

View 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}")
# 不抛出异常,避免影响主流程

View File

@@ -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):
"""创建新聊天"""

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

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

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