Upload 26 files
Browse files- Dockerfile +37 -0
- LICENSE +21 -0
- app/api/admin/manage.py +588 -0
- app/api/v1/chat.py +87 -0
- app/api/v1/images.py +49 -0
- app/api/v1/models.py +153 -0
- app/core/auth.py +53 -0
- app/core/config.py +32 -0
- app/core/exception.py +120 -0
- app/core/logger.py +74 -0
- app/models/grok_models.py +108 -0
- app/models/openai_schema.py +115 -0
- app/services/grok/client.py +217 -0
- app/services/grok/image_cache.py +151 -0
- app/services/grok/processer.py +260 -0
- app/services/grok/statsig.py +44 -0
- app/services/grok/token.py +388 -0
- app/services/grok/upload.py +145 -0
- app/template/admin.html +493 -0
- app/template/login.html +180 -0
- data/setting.toml +14 -0
- data/temp/image.temp +0 -0
- data/token.json +4 -0
- docker-compose.yml +11 -0
- main.py +53 -0
- requirements.txt +9 -0
Dockerfile
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim AS base
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
# 安装依赖阶段
|
| 6 |
+
FROM base AS dependencies
|
| 7 |
+
|
| 8 |
+
# 安装运行时需要的系统依赖
|
| 9 |
+
RUN apt-get update && \
|
| 10 |
+
apt-get install -y --no-install-recommends \
|
| 11 |
+
gcc \
|
| 12 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 13 |
+
|
| 14 |
+
# 复制并安装 Python 依赖
|
| 15 |
+
COPY requirements.txt .
|
| 16 |
+
RUN pip install --no-cache-dir --upgrade pip && \
|
| 17 |
+
pip install --no-cache-dir -r requirements.txt
|
| 18 |
+
|
| 19 |
+
# 最终运行阶段
|
| 20 |
+
FROM python:3.11-slim AS runtime
|
| 21 |
+
|
| 22 |
+
WORKDIR /app
|
| 23 |
+
|
| 24 |
+
# 复制必要的 Python 依赖
|
| 25 |
+
COPY --from=dependencies /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages
|
| 26 |
+
COPY --from=dependencies /usr/local/bin /usr/local/bin
|
| 27 |
+
|
| 28 |
+
# 复制应用代码
|
| 29 |
+
COPY . .
|
| 30 |
+
|
| 31 |
+
# 创建必要的目录和文件
|
| 32 |
+
RUN mkdir -p /app/logs /app/data/temp && \
|
| 33 |
+
echo '{"ssoNormal": {}, "ssoSuper": {}}' > /app/data/token.json
|
| 34 |
+
|
| 35 |
+
EXPOSE 8000
|
| 36 |
+
|
| 37 |
+
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2025 Chenyme
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in all
|
| 13 |
+
copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|
app/api/admin/manage.py
ADDED
|
@@ -0,0 +1,588 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
管理接口模块
|
| 3 |
+
|
| 4 |
+
提供Token管理功能,包括登录验证、Token增删查等操作。
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import secrets
|
| 8 |
+
from typing import Dict, Any, List, Optional
|
| 9 |
+
from datetime import datetime, timedelta
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
from fastapi import APIRouter, HTTPException, Depends, Header
|
| 12 |
+
from fastapi.responses import HTMLResponse
|
| 13 |
+
from pydantic import BaseModel
|
| 14 |
+
|
| 15 |
+
from app.core.config import setting
|
| 16 |
+
from app.core.logger import logger
|
| 17 |
+
from app.services.grok.token import token_manager
|
| 18 |
+
from app.models.grok_models import TokenType
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
# 创建路由器
|
| 22 |
+
router = APIRouter(tags=["管理"])
|
| 23 |
+
|
| 24 |
+
# 常量定义
|
| 25 |
+
STATIC_DIR = Path(__file__).parents[2] / "template"
|
| 26 |
+
TEMP_DIR = Path(__file__).parents[3] / "data" / "temp"
|
| 27 |
+
SESSION_EXPIRE_HOURS = 24
|
| 28 |
+
BYTES_PER_KB = 1024
|
| 29 |
+
BYTES_PER_MB = 1024 * 1024
|
| 30 |
+
|
| 31 |
+
# 简单的会话存储
|
| 32 |
+
_sessions: Dict[str, datetime] = {}
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
# === 请求/响应模型 ===
|
| 36 |
+
|
| 37 |
+
class LoginRequest(BaseModel):
|
| 38 |
+
"""登录请求"""
|
| 39 |
+
username: str
|
| 40 |
+
password: str
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
class LoginResponse(BaseModel):
|
| 44 |
+
"""登录响应"""
|
| 45 |
+
success: bool
|
| 46 |
+
token: Optional[str] = None
|
| 47 |
+
message: str
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
class AddTokensRequest(BaseModel):
|
| 51 |
+
"""批量添加Token请求"""
|
| 52 |
+
tokens: List[str]
|
| 53 |
+
token_type: str # "sso" 或 "ssoSuper"
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
class DeleteTokensRequest(BaseModel):
|
| 57 |
+
"""批量删除Token请求"""
|
| 58 |
+
tokens: List[str]
|
| 59 |
+
token_type: str # "sso" 或 "ssoSuper"
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
class TokenInfo(BaseModel):
|
| 63 |
+
"""Token信息"""
|
| 64 |
+
token: str
|
| 65 |
+
token_type: str
|
| 66 |
+
created_time: Optional[int] = None
|
| 67 |
+
remaining_queries: int
|
| 68 |
+
heavy_remaining_queries: int
|
| 69 |
+
status: str # "未使用"、"限流中"、"失效"、"正常"
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
class TokenListResponse(BaseModel):
|
| 73 |
+
"""Token列表响应"""
|
| 74 |
+
success: bool
|
| 75 |
+
data: List[TokenInfo]
|
| 76 |
+
total: int
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
# === 辅助函数 ===
|
| 80 |
+
|
| 81 |
+
def validate_token_type(token_type_str: str) -> TokenType:
|
| 82 |
+
"""验证并转换Token类型字符串为枚举"""
|
| 83 |
+
if token_type_str not in ["sso", "ssoSuper"]:
|
| 84 |
+
raise HTTPException(
|
| 85 |
+
status_code=400,
|
| 86 |
+
detail={"error": "无效的Token类型,必须是 'sso' 或 'ssoSuper'", "code": "INVALID_TYPE"}
|
| 87 |
+
)
|
| 88 |
+
return TokenType.NORMAL if token_type_str == "sso" else TokenType.SUPER
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def parse_created_time(created_time) -> Optional[int]:
|
| 92 |
+
"""解析创建时间,统一处理不同格式"""
|
| 93 |
+
if isinstance(created_time, str):
|
| 94 |
+
return int(created_time) if created_time else None
|
| 95 |
+
elif isinstance(created_time, int):
|
| 96 |
+
return created_time
|
| 97 |
+
return None
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def calculate_token_stats(tokens: Dict[str, Any], token_type: str) -> Dict[str, int]:
|
| 101 |
+
"""计算Token统计信息"""
|
| 102 |
+
total = len(tokens)
|
| 103 |
+
expired = sum(1 for t in tokens.values() if t.get("status") == "expired")
|
| 104 |
+
|
| 105 |
+
if token_type == "normal":
|
| 106 |
+
unused = sum(1 for t in tokens.values()
|
| 107 |
+
if t.get("status") != "expired" and t.get("remainingQueries", -1) == -1)
|
| 108 |
+
limited = sum(1 for t in tokens.values()
|
| 109 |
+
if t.get("status") != "expired" and t.get("remainingQueries", -1) == 0)
|
| 110 |
+
active = sum(1 for t in tokens.values()
|
| 111 |
+
if t.get("status") != "expired" and t.get("remainingQueries", -1) > 0)
|
| 112 |
+
else: # super token
|
| 113 |
+
unused = sum(1 for t in tokens.values()
|
| 114 |
+
if t.get("status") != "expired" and
|
| 115 |
+
t.get("remainingQueries", -1) == -1 and t.get("heavyremainingQueries", -1) == -1)
|
| 116 |
+
limited = sum(1 for t in tokens.values()
|
| 117 |
+
if t.get("status") != "expired" and
|
| 118 |
+
(t.get("remainingQueries", -1) == 0 or t.get("heavyremainingQueries", -1) == 0))
|
| 119 |
+
active = sum(1 for t in tokens.values()
|
| 120 |
+
if t.get("status") != "expired" and
|
| 121 |
+
(t.get("remainingQueries", -1) > 0 or t.get("heavyremainingQueries", -1) > 0))
|
| 122 |
+
|
| 123 |
+
return {
|
| 124 |
+
"total": total,
|
| 125 |
+
"unused": unused,
|
| 126 |
+
"limited": limited,
|
| 127 |
+
"expired": expired,
|
| 128 |
+
"active": active
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def verify_admin_session(authorization: Optional[str] = Header(None)) -> bool:
|
| 133 |
+
"""验证管理员会话"""
|
| 134 |
+
if not authorization or not authorization.startswith("Bearer "):
|
| 135 |
+
raise HTTPException(
|
| 136 |
+
status_code=401,
|
| 137 |
+
detail={"error": "未授权访问", "code": "UNAUTHORIZED"}
|
| 138 |
+
)
|
| 139 |
+
|
| 140 |
+
token = authorization[7:] # 移除 "Bearer " 前缀
|
| 141 |
+
|
| 142 |
+
# 检查token是否存在且未过期
|
| 143 |
+
if token not in _sessions:
|
| 144 |
+
raise HTTPException(
|
| 145 |
+
status_code=401,
|
| 146 |
+
detail={"error": "会话已过期或无效", "code": "SESSION_INVALID"}
|
| 147 |
+
)
|
| 148 |
+
|
| 149 |
+
# 检查会话是否过期(24小时)
|
| 150 |
+
if datetime.now() > _sessions[token]:
|
| 151 |
+
del _sessions[token]
|
| 152 |
+
raise HTTPException(
|
| 153 |
+
status_code=401,
|
| 154 |
+
detail={"error": "会话已过期", "code": "SESSION_EXPIRED"}
|
| 155 |
+
)
|
| 156 |
+
|
| 157 |
+
return True
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def get_token_status(token_data: Dict[str, Any], token_type: str) -> str:
|
| 161 |
+
"""获取Token状态"""
|
| 162 |
+
# 首先检查是否失效(来自 token.json 的 status 字段)
|
| 163 |
+
if token_data.get("status") == "expired":
|
| 164 |
+
return "失效"
|
| 165 |
+
|
| 166 |
+
# 获取剩余次数
|
| 167 |
+
remaining_queries = token_data.get("remainingQueries", -1)
|
| 168 |
+
heavy_remaining = token_data.get("heavyremainingQueries", -1)
|
| 169 |
+
|
| 170 |
+
# 根据token类型选择正确的字段
|
| 171 |
+
if token_type == "ssoSuper":
|
| 172 |
+
# Super token 可能使用 heavy 模型
|
| 173 |
+
relevant_remaining = max(remaining_queries, heavy_remaining)
|
| 174 |
+
else:
|
| 175 |
+
# 普通token主要看 remaining_queries
|
| 176 |
+
relevant_remaining = remaining_queries
|
| 177 |
+
|
| 178 |
+
if relevant_remaining == -1:
|
| 179 |
+
return "未使用"
|
| 180 |
+
elif relevant_remaining == 0:
|
| 181 |
+
return "限流中"
|
| 182 |
+
else:
|
| 183 |
+
return "正常"
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
# === 页面路由 ===
|
| 187 |
+
|
| 188 |
+
@router.get("/login", response_class=HTMLResponse)
|
| 189 |
+
async def login_page():
|
| 190 |
+
"""登录页面"""
|
| 191 |
+
login_html = STATIC_DIR / "login.html"
|
| 192 |
+
if login_html.exists():
|
| 193 |
+
return login_html.read_text(encoding="utf-8")
|
| 194 |
+
raise HTTPException(status_code=404, detail="登录页面不存在")
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
@router.get("/manage", response_class=HTMLResponse)
|
| 198 |
+
async def manage_page():
|
| 199 |
+
"""管理页面"""
|
| 200 |
+
admin_html = STATIC_DIR / "admin.html"
|
| 201 |
+
if admin_html.exists():
|
| 202 |
+
return admin_html.read_text(encoding="utf-8")
|
| 203 |
+
raise HTTPException(status_code=404, detail="管理页面不存在")
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
# === API端点 ===
|
| 207 |
+
|
| 208 |
+
@router.post("/api/login", response_model=LoginResponse)
|
| 209 |
+
async def admin_login(request: LoginRequest) -> LoginResponse:
|
| 210 |
+
"""
|
| 211 |
+
管理员登录
|
| 212 |
+
|
| 213 |
+
验证用户名和密码,成功后返回会话token。
|
| 214 |
+
"""
|
| 215 |
+
try:
|
| 216 |
+
logger.debug(f"[Admin] 管理员登录尝试 - 用户名: {request.username}")
|
| 217 |
+
|
| 218 |
+
# 验证用户名和密码
|
| 219 |
+
expected_username = setting.global_config.get("admin_username", "")
|
| 220 |
+
expected_password = setting.global_config.get("admin_password", "")
|
| 221 |
+
|
| 222 |
+
if request.username != expected_username or request.password != expected_password:
|
| 223 |
+
logger.warning(f"[Admin] 登录失败: 用户名或密码错误 - 用户名: {request.username}")
|
| 224 |
+
return LoginResponse(
|
| 225 |
+
success=False,
|
| 226 |
+
message="用户名或密码错误"
|
| 227 |
+
)
|
| 228 |
+
|
| 229 |
+
# 生成会话token
|
| 230 |
+
session_token = secrets.token_urlsafe(32)
|
| 231 |
+
|
| 232 |
+
# 设置会话过期时间
|
| 233 |
+
expire_time = datetime.now() + timedelta(hours=SESSION_EXPIRE_HOURS)
|
| 234 |
+
_sessions[session_token] = expire_time
|
| 235 |
+
|
| 236 |
+
logger.debug(f"[Admin] 管理员登录成功 - 用户名: {request.username}")
|
| 237 |
+
|
| 238 |
+
return LoginResponse(
|
| 239 |
+
success=True,
|
| 240 |
+
token=session_token,
|
| 241 |
+
message="登录成功"
|
| 242 |
+
)
|
| 243 |
+
|
| 244 |
+
except Exception as e:
|
| 245 |
+
logger.error(f"[Admin] 登录处理异常 - 用户名: {request.username}, 错误: {str(e)}")
|
| 246 |
+
raise HTTPException(
|
| 247 |
+
status_code=500,
|
| 248 |
+
detail={"error": f"登录失败: {str(e)}", "code": "LOGIN_ERROR"}
|
| 249 |
+
)
|
| 250 |
+
|
| 251 |
+
|
| 252 |
+
@router.post("/api/logout")
|
| 253 |
+
async def admin_logout(authenticated: bool = Depends(verify_admin_session),
|
| 254 |
+
authorization: Optional[str] = Header(None)) -> Dict[str, Any]:
|
| 255 |
+
"""
|
| 256 |
+
管理员登出
|
| 257 |
+
|
| 258 |
+
清除会话token。
|
| 259 |
+
"""
|
| 260 |
+
try:
|
| 261 |
+
if authorization and authorization.startswith("Bearer "):
|
| 262 |
+
token = authorization[7:]
|
| 263 |
+
if token in _sessions:
|
| 264 |
+
del _sessions[token]
|
| 265 |
+
logger.debug("[Admin] 管理员登出成功")
|
| 266 |
+
return {"success": True, "message": "登出成功"}
|
| 267 |
+
|
| 268 |
+
logger.warning("[Admin] 登出失败: 无效或缺失的会话Token")
|
| 269 |
+
return {"success": False, "message": "无效的会话"}
|
| 270 |
+
|
| 271 |
+
except Exception as e:
|
| 272 |
+
logger.error(f"[Admin] 登出处理异常 - 错误: {str(e)}")
|
| 273 |
+
raise HTTPException(
|
| 274 |
+
status_code=500,
|
| 275 |
+
detail={"error": f"登出失败: {str(e)}", "code": "LOGOUT_ERROR"}
|
| 276 |
+
)
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
@router.get("/api/tokens", response_model=TokenListResponse)
|
| 280 |
+
async def list_tokens(authenticated: bool = Depends(verify_admin_session)) -> TokenListResponse:
|
| 281 |
+
"""
|
| 282 |
+
获取所有Token列表
|
| 283 |
+
|
| 284 |
+
返回系统中所有Token及其状态信息。
|
| 285 |
+
"""
|
| 286 |
+
try:
|
| 287 |
+
logger.debug("[Admin] 开始获取Token列表")
|
| 288 |
+
|
| 289 |
+
all_tokens_data = token_manager.get_tokens()
|
| 290 |
+
token_list: List[TokenInfo] = []
|
| 291 |
+
|
| 292 |
+
# 处理普通Token
|
| 293 |
+
normal_tokens = all_tokens_data.get(TokenType.NORMAL.value, {})
|
| 294 |
+
for token, data in normal_tokens.items():
|
| 295 |
+
token_list.append(TokenInfo(
|
| 296 |
+
token=token,
|
| 297 |
+
token_type="sso",
|
| 298 |
+
created_time=parse_created_time(data.get("createdTime")),
|
| 299 |
+
remaining_queries=data.get("remainingQueries", -1),
|
| 300 |
+
heavy_remaining_queries=data.get("heavyremainingQueries", -1),
|
| 301 |
+
status=get_token_status(data, "sso")
|
| 302 |
+
))
|
| 303 |
+
|
| 304 |
+
# 处理Super Token
|
| 305 |
+
super_tokens = all_tokens_data.get(TokenType.SUPER.value, {})
|
| 306 |
+
for token, data in super_tokens.items():
|
| 307 |
+
token_list.append(TokenInfo(
|
| 308 |
+
token=token,
|
| 309 |
+
token_type="ssoSuper",
|
| 310 |
+
created_time=parse_created_time(data.get("createdTime")),
|
| 311 |
+
remaining_queries=data.get("remainingQueries", -1),
|
| 312 |
+
heavy_remaining_queries=data.get("heavyremainingQueries", -1),
|
| 313 |
+
status=get_token_status(data, "ssoSuper")
|
| 314 |
+
))
|
| 315 |
+
|
| 316 |
+
normal_count = len(normal_tokens)
|
| 317 |
+
super_count = len(super_tokens)
|
| 318 |
+
total_count = len(token_list)
|
| 319 |
+
|
| 320 |
+
logger.debug(f"[Admin] Token列表获取成功 - 普通Token: {normal_count}, Super Token: {super_count}, 总计: {total_count}")
|
| 321 |
+
|
| 322 |
+
return TokenListResponse(
|
| 323 |
+
success=True,
|
| 324 |
+
data=token_list,
|
| 325 |
+
total=total_count
|
| 326 |
+
)
|
| 327 |
+
|
| 328 |
+
except Exception as e:
|
| 329 |
+
logger.error(f"[Admin] 获取Token列表异常 - 错误: {str(e)}")
|
| 330 |
+
raise HTTPException(
|
| 331 |
+
status_code=500,
|
| 332 |
+
detail={"error": f"获取Token列表失败: {str(e)}", "code": "LIST_ERROR"}
|
| 333 |
+
)
|
| 334 |
+
|
| 335 |
+
|
| 336 |
+
@router.post("/api/tokens/add")
|
| 337 |
+
async def add_tokens(request: AddTokensRequest,
|
| 338 |
+
authenticated: bool = Depends(verify_admin_session)) -> Dict[str, Any]:
|
| 339 |
+
"""
|
| 340 |
+
批量添加Token
|
| 341 |
+
|
| 342 |
+
支持添加普通Token(sso)和Super Token(ssoSuper)。
|
| 343 |
+
"""
|
| 344 |
+
try:
|
| 345 |
+
logger.debug(f"[Admin] 批量添加Token - 类型: {request.token_type}, 数量: {len(request.tokens)}")
|
| 346 |
+
|
| 347 |
+
# 验证并转换token类型
|
| 348 |
+
token_type = validate_token_type(request.token_type)
|
| 349 |
+
|
| 350 |
+
# 添加Token
|
| 351 |
+
await token_manager.add_token(request.tokens, token_type)
|
| 352 |
+
|
| 353 |
+
logger.debug(f"[Admin] Token添加成功 - 类型: {request.token_type}, 数量: {len(request.tokens)}")
|
| 354 |
+
|
| 355 |
+
return {
|
| 356 |
+
"success": True,
|
| 357 |
+
"message": f"成功添加 {len(request.tokens)} 个Token",
|
| 358 |
+
"count": len(request.tokens)
|
| 359 |
+
}
|
| 360 |
+
|
| 361 |
+
except HTTPException:
|
| 362 |
+
raise
|
| 363 |
+
except Exception as e:
|
| 364 |
+
logger.error(f"[Admin] Token添加异常 - 类型: {request.token_type}, 数量: {len(request.tokens)}, 错误: {str(e)}")
|
| 365 |
+
raise HTTPException(
|
| 366 |
+
status_code=500,
|
| 367 |
+
detail={"error": f"添加Token失败: {str(e)}", "code": "ADD_ERROR"}
|
| 368 |
+
)
|
| 369 |
+
|
| 370 |
+
|
| 371 |
+
@router.post("/api/tokens/delete")
|
| 372 |
+
async def delete_tokens(request: DeleteTokensRequest,
|
| 373 |
+
authenticated: bool = Depends(verify_admin_session)) -> Dict[str, Any]:
|
| 374 |
+
"""
|
| 375 |
+
批量删除Token
|
| 376 |
+
|
| 377 |
+
支持删除普通Token(sso)和Super Token(ssoSuper)。
|
| 378 |
+
"""
|
| 379 |
+
try:
|
| 380 |
+
logger.debug(f"[Admin] 批量删除Token - 类型: {request.token_type}, 数量: {len(request.tokens)}")
|
| 381 |
+
|
| 382 |
+
# 验证并转换token类型
|
| 383 |
+
token_type = validate_token_type(request.token_type)
|
| 384 |
+
|
| 385 |
+
# 删除Token
|
| 386 |
+
await token_manager.delete_token(request.tokens, token_type)
|
| 387 |
+
|
| 388 |
+
logger.debug(f"[Admin] Token删除成功 - 类型: {request.token_type}, 数量: {len(request.tokens)}")
|
| 389 |
+
|
| 390 |
+
return {
|
| 391 |
+
"success": True,
|
| 392 |
+
"message": f"成功删除 {len(request.tokens)} 个Token",
|
| 393 |
+
"count": len(request.tokens)
|
| 394 |
+
}
|
| 395 |
+
|
| 396 |
+
except HTTPException:
|
| 397 |
+
raise
|
| 398 |
+
except Exception as e:
|
| 399 |
+
logger.error(f"[Admin] Token删除异常 - 类型: {request.token_type}, 数量: {len(request.tokens)}, 错误: {str(e)}")
|
| 400 |
+
raise HTTPException(
|
| 401 |
+
status_code=500,
|
| 402 |
+
detail={"error": f"删除Token失败: {str(e)}", "code": "DELETE_ERROR"}
|
| 403 |
+
)
|
| 404 |
+
|
| 405 |
+
|
| 406 |
+
@router.get("/api/settings")
|
| 407 |
+
async def get_settings(authenticated: bool = Depends(verify_admin_session)) -> Dict[str, Any]:
|
| 408 |
+
"""获取全局配置"""
|
| 409 |
+
try:
|
| 410 |
+
logger.debug("[Admin] 获取全局配置")
|
| 411 |
+
return {
|
| 412 |
+
"success": True,
|
| 413 |
+
"data": {
|
| 414 |
+
"global": setting.global_config,
|
| 415 |
+
"grok": setting.grok_config
|
| 416 |
+
}
|
| 417 |
+
}
|
| 418 |
+
except Exception as e:
|
| 419 |
+
logger.error(f"[Admin] 获取配置失败: {str(e)}")
|
| 420 |
+
raise HTTPException(status_code=500, detail={"error": f"获取配置失败: {str(e)}", "code": "GET_SETTINGS_ERROR"})
|
| 421 |
+
|
| 422 |
+
|
| 423 |
+
class UpdateSettingsRequest(BaseModel):
|
| 424 |
+
"""更新配置请求"""
|
| 425 |
+
global_config: Optional[Dict[str, Any]] = None
|
| 426 |
+
grok_config: Optional[Dict[str, Any]] = None
|
| 427 |
+
|
| 428 |
+
|
| 429 |
+
@router.post("/api/settings")
|
| 430 |
+
async def update_settings(request: UpdateSettingsRequest, authenticated: bool = Depends(verify_admin_session)) -> Dict[str, Any]:
|
| 431 |
+
"""更新全局配置"""
|
| 432 |
+
try:
|
| 433 |
+
import toml
|
| 434 |
+
import aiofiles
|
| 435 |
+
logger.debug("[Admin] 更新全局配置")
|
| 436 |
+
|
| 437 |
+
# 异步读取现有配置
|
| 438 |
+
async with aiofiles.open(setting.config_path, "r", encoding="utf-8") as f:
|
| 439 |
+
content = await f.read()
|
| 440 |
+
config = toml.loads(content)
|
| 441 |
+
|
| 442 |
+
# 更新配置
|
| 443 |
+
if request.global_config:
|
| 444 |
+
config["global"].update(request.global_config)
|
| 445 |
+
if request.grok_config:
|
| 446 |
+
config["grok"].update(request.grok_config)
|
| 447 |
+
|
| 448 |
+
# 异步写回配置文件
|
| 449 |
+
async with aiofiles.open(setting.config_path, "w", encoding="utf-8") as f:
|
| 450 |
+
await f.write(toml.dumps(config))
|
| 451 |
+
|
| 452 |
+
# 重新加载配置
|
| 453 |
+
setting.global_config = setting.load("global")
|
| 454 |
+
setting.grok_config = setting.load("grok")
|
| 455 |
+
|
| 456 |
+
logger.debug("[Admin] 配置更新成功")
|
| 457 |
+
return {"success": True, "message": "配置更新成功"}
|
| 458 |
+
except Exception as e:
|
| 459 |
+
logger.error(f"[Admin] 更新配置失败: {str(e)}")
|
| 460 |
+
raise HTTPException(status_code=500, detail={"error": f"更新配置失败: {str(e)}", "code": "UPDATE_SETTINGS_ERROR"})
|
| 461 |
+
|
| 462 |
+
|
| 463 |
+
def _calculate_dir_size(directory: Path) -> int:
|
| 464 |
+
"""计算目录中所有文件的大小(字节)"""
|
| 465 |
+
total_size = 0
|
| 466 |
+
for file_path in directory.iterdir():
|
| 467 |
+
if file_path.is_file():
|
| 468 |
+
try:
|
| 469 |
+
total_size += file_path.stat().st_size
|
| 470 |
+
except Exception as e:
|
| 471 |
+
logger.warning(f"[Admin] 无法获取文件大小: {file_path.name}, 错误: {str(e)}")
|
| 472 |
+
return total_size
|
| 473 |
+
|
| 474 |
+
|
| 475 |
+
def _format_size(size_bytes: int) -> str:
|
| 476 |
+
"""格式化字节大小为可读字符串"""
|
| 477 |
+
size_mb = size_bytes / BYTES_PER_MB
|
| 478 |
+
if size_mb < 1:
|
| 479 |
+
size_kb = size_bytes / BYTES_PER_KB
|
| 480 |
+
return f"{size_kb:.1f} KB"
|
| 481 |
+
return f"{size_mb:.1f} MB"
|
| 482 |
+
|
| 483 |
+
|
| 484 |
+
@router.get("/api/cache/size")
|
| 485 |
+
async def get_cache_size(authenticated: bool = Depends(verify_admin_session)) -> Dict[str, Any]:
|
| 486 |
+
"""获取缓存大小"""
|
| 487 |
+
try:
|
| 488 |
+
logger.debug("[Admin] 开始获取缓存大小")
|
| 489 |
+
|
| 490 |
+
if not TEMP_DIR.exists():
|
| 491 |
+
logger.warning(f"[Admin] 缓存目录不存在: {TEMP_DIR}")
|
| 492 |
+
return {"success": True, "data": {"size": "0 MB"}}
|
| 493 |
+
|
| 494 |
+
# 计算目录大小
|
| 495 |
+
total_size = _calculate_dir_size(TEMP_DIR)
|
| 496 |
+
size_str = _format_size(total_size)
|
| 497 |
+
|
| 498 |
+
logger.debug(f"[Admin] 缓存大小获取完成 - 大小: {size_str}")
|
| 499 |
+
return {"success": True, "data": {"size": size_str}}
|
| 500 |
+
|
| 501 |
+
except Exception as e:
|
| 502 |
+
logger.error(f"[Admin] 获取缓存大小异常 - 错误: {str(e)}")
|
| 503 |
+
raise HTTPException(
|
| 504 |
+
status_code=500,
|
| 505 |
+
detail={"error": f"获取缓存大小失败: {str(e)}", "code": "CACHE_SIZE_ERROR"}
|
| 506 |
+
)
|
| 507 |
+
|
| 508 |
+
|
| 509 |
+
@router.post("/api/cache/clear")
|
| 510 |
+
async def clear_cache(authenticated: bool = Depends(verify_admin_session)) -> Dict[str, Any]:
|
| 511 |
+
"""清理缓存 - 删除所有临时文件"""
|
| 512 |
+
try:
|
| 513 |
+
logger.debug("[Admin] 开始清理缓存")
|
| 514 |
+
|
| 515 |
+
if not TEMP_DIR.exists():
|
| 516 |
+
logger.warning(f"[Admin] 缓存目录不存在: {TEMP_DIR}")
|
| 517 |
+
return {
|
| 518 |
+
"success": True,
|
| 519 |
+
"message": "缓存目录不存在,无需清理",
|
| 520 |
+
"data": {"deleted_count": 0}
|
| 521 |
+
}
|
| 522 |
+
|
| 523 |
+
# 删除所有文件
|
| 524 |
+
deleted_count = 0
|
| 525 |
+
for file_path in TEMP_DIR.iterdir():
|
| 526 |
+
if file_path.is_file():
|
| 527 |
+
try:
|
| 528 |
+
file_path.unlink()
|
| 529 |
+
deleted_count += 1
|
| 530 |
+
logger.debug(f"[Admin] 删除缓存文件: {file_path.name}")
|
| 531 |
+
except Exception as e:
|
| 532 |
+
logger.error(f"[Admin] 删除缓存文件失败: {file_path.name}, 错误: {str(e)}")
|
| 533 |
+
|
| 534 |
+
logger.debug(f"[Admin] 缓存清理完成 - 删除文件数量: {deleted_count}")
|
| 535 |
+
return {
|
| 536 |
+
"success": True,
|
| 537 |
+
"message": f"成功清理缓存,删除 {deleted_count} 个文件",
|
| 538 |
+
"data": {"deleted_count": deleted_count}
|
| 539 |
+
}
|
| 540 |
+
|
| 541 |
+
except Exception as e:
|
| 542 |
+
logger.error(f"[Admin] 清理缓存异常 - 错误: {str(e)}")
|
| 543 |
+
raise HTTPException(
|
| 544 |
+
status_code=500,
|
| 545 |
+
detail={"error": f"清理缓存失败: {str(e)}", "code": "CACHE_CLEAR_ERROR"}
|
| 546 |
+
)
|
| 547 |
+
|
| 548 |
+
|
| 549 |
+
@router.get("/api/stats")
|
| 550 |
+
async def get_stats(authenticated: bool = Depends(verify_admin_session)) -> Dict[str, Any]:
|
| 551 |
+
"""
|
| 552 |
+
获取统计信息
|
| 553 |
+
|
| 554 |
+
返回Token的统计数据。
|
| 555 |
+
"""
|
| 556 |
+
try:
|
| 557 |
+
logger.debug("[Admin] 开始获取统计信息")
|
| 558 |
+
|
| 559 |
+
all_tokens_data = token_manager.get_tokens()
|
| 560 |
+
|
| 561 |
+
# 统计普通Token
|
| 562 |
+
normal_tokens = all_tokens_data.get(TokenType.NORMAL.value, {})
|
| 563 |
+
normal_stats = calculate_token_stats(normal_tokens, "normal")
|
| 564 |
+
|
| 565 |
+
# 统计Super Token
|
| 566 |
+
super_tokens = all_tokens_data.get(TokenType.SUPER.value, {})
|
| 567 |
+
super_stats = calculate_token_stats(super_tokens, "super")
|
| 568 |
+
|
| 569 |
+
total_count = normal_stats["total"] + super_stats["total"]
|
| 570 |
+
|
| 571 |
+
stats = {
|
| 572 |
+
"success": True,
|
| 573 |
+
"data": {
|
| 574 |
+
"normal": normal_stats,
|
| 575 |
+
"super": super_stats,
|
| 576 |
+
"total": total_count
|
| 577 |
+
}
|
| 578 |
+
}
|
| 579 |
+
|
| 580 |
+
logger.debug(f"[Admin] 统计信息获取成功 - 普通Token: {normal_stats['total']}, Super Token: {super_stats['total']}, 总计: {total_count}")
|
| 581 |
+
return stats
|
| 582 |
+
|
| 583 |
+
except Exception as e:
|
| 584 |
+
logger.error(f"[Admin] 获取统计信息异常 - 错误: {str(e)}")
|
| 585 |
+
raise HTTPException(
|
| 586 |
+
status_code=500,
|
| 587 |
+
detail={"error": f"获取统计信息失败: {str(e)}", "code": "STATS_ERROR"}
|
| 588 |
+
)
|
app/api/v1/chat.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# -*- coding: utf-8 -*-
|
| 2 |
+
"""
|
| 3 |
+
聊天API路由模块
|
| 4 |
+
|
| 5 |
+
提供OpenAI兼容的聊天API接口,支持与Grok模型的交互。
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 9 |
+
from typing import Optional
|
| 10 |
+
from fastapi.responses import StreamingResponse
|
| 11 |
+
|
| 12 |
+
from app.core.auth import auth_manager
|
| 13 |
+
from app.core.exception import GrokApiException
|
| 14 |
+
from app.core.logger import logger
|
| 15 |
+
from app.services.grok.client import GrokClient
|
| 16 |
+
from app.models.openai_schema import OpenAIChatRequest
|
| 17 |
+
|
| 18 |
+
# 聊天路由
|
| 19 |
+
router = APIRouter(prefix="/chat", tags=["聊天"])
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
@router.post("/completions", response_model=None)
|
| 23 |
+
async def chat_completions(
|
| 24 |
+
request: OpenAIChatRequest,
|
| 25 |
+
authenticated: Optional[str] = Depends(auth_manager.verify)
|
| 26 |
+
):
|
| 27 |
+
"""
|
| 28 |
+
创建聊天补全
|
| 29 |
+
|
| 30 |
+
兼容OpenAI聊天API的端点,支持流式和非流式响应。
|
| 31 |
+
|
| 32 |
+
Args:
|
| 33 |
+
request: OpenAI格式的聊天请求
|
| 34 |
+
authenticated: 认证状态(由依赖注入)
|
| 35 |
+
|
| 36 |
+
Returns:
|
| 37 |
+
OpenAIChatCompletionResponse: 非流式响应
|
| 38 |
+
StreamingResponse: 流式响应
|
| 39 |
+
|
| 40 |
+
Raises:
|
| 41 |
+
HTTPException: 当请求处理失败时
|
| 42 |
+
"""
|
| 43 |
+
try:
|
| 44 |
+
logger.info(f"[Chat] 聊天请求 - 模型: {request.model}")
|
| 45 |
+
|
| 46 |
+
# 调用Grok客户端处理请求
|
| 47 |
+
result = await GrokClient.openai_to_grok(request.model_dump())
|
| 48 |
+
|
| 49 |
+
# 如果是流式响应,GrokClient已经返回了Iterator,直接包装为StreamingResponse
|
| 50 |
+
if request.stream:
|
| 51 |
+
return StreamingResponse(
|
| 52 |
+
content=result,
|
| 53 |
+
media_type="text/event-stream",
|
| 54 |
+
headers={
|
| 55 |
+
"Cache-Control": "no-cache",
|
| 56 |
+
"Connection": "keep-alive",
|
| 57 |
+
"X-Accel-Buffering": "no"
|
| 58 |
+
}
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
# 非流式响应直接返回
|
| 62 |
+
return result
|
| 63 |
+
|
| 64 |
+
except GrokApiException as e:
|
| 65 |
+
logger.error(f"[Chat] Grok API错误: {str(e)}", extra={"details": e.details})
|
| 66 |
+
raise HTTPException(
|
| 67 |
+
status_code=500,
|
| 68 |
+
detail={
|
| 69 |
+
"error": {
|
| 70 |
+
"message": str(e),
|
| 71 |
+
"type": e.error_code or "grok_api_error",
|
| 72 |
+
"code": e.error_code or "unknown"
|
| 73 |
+
}
|
| 74 |
+
}
|
| 75 |
+
)
|
| 76 |
+
except Exception as e:
|
| 77 |
+
logger.error(f"[Chat] 聊天请求处理失败: {str(e)}", exc_info=True)
|
| 78 |
+
raise HTTPException(
|
| 79 |
+
status_code=500,
|
| 80 |
+
detail={
|
| 81 |
+
"error": {
|
| 82 |
+
"message": "服务器内部错误",
|
| 83 |
+
"type": "internal_error",
|
| 84 |
+
"code": "internal_server_error"
|
| 85 |
+
}
|
| 86 |
+
}
|
| 87 |
+
)
|
app/api/v1/images.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""图片服务API路由"""
|
| 2 |
+
|
| 3 |
+
from fastapi import APIRouter, HTTPException
|
| 4 |
+
from fastapi.responses import FileResponse
|
| 5 |
+
|
| 6 |
+
from app.core.logger import logger
|
| 7 |
+
from app.services.grok.image_cache import image_cache_service
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
router = APIRouter()
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
@router.get("/images/{img_path:path}")
|
| 14 |
+
async def get_image(img_path: str):
|
| 15 |
+
"""获取缓存的图片
|
| 16 |
+
|
| 17 |
+
Args:
|
| 18 |
+
img_path: 图片路径,格式如 users-xxx-generated-xxx-image.jpg
|
| 19 |
+
|
| 20 |
+
Returns:
|
| 21 |
+
图片文件响应
|
| 22 |
+
"""
|
| 23 |
+
try:
|
| 24 |
+
# 将路径转换回原始格式(短横线转斜杠)
|
| 25 |
+
original_path = "/" + img_path.replace('-', '/')
|
| 26 |
+
|
| 27 |
+
# 检查缓存是否存在
|
| 28 |
+
cache_path = image_cache_service.get_cached_image(original_path)
|
| 29 |
+
|
| 30 |
+
if cache_path and cache_path.exists():
|
| 31 |
+
logger.debug(f"[ImageAPI] 返回缓存图片: {cache_path}")
|
| 32 |
+
return FileResponse(
|
| 33 |
+
path=str(cache_path),
|
| 34 |
+
media_type="image/jpeg",
|
| 35 |
+
headers={
|
| 36 |
+
"Cache-Control": "public, max-age=86400",
|
| 37 |
+
"Access-Control-Allow-Origin": "*"
|
| 38 |
+
}
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
# 图片不存在
|
| 42 |
+
logger.warning(f"[ImageAPI] 图片未找到: {original_path}")
|
| 43 |
+
raise HTTPException(status_code=404, detail="Image not found")
|
| 44 |
+
|
| 45 |
+
except HTTPException:
|
| 46 |
+
raise
|
| 47 |
+
except Exception as e:
|
| 48 |
+
logger.error(f"[ImageAPI] 获取图片失败: {e}")
|
| 49 |
+
raise HTTPException(status_code=500, detail=str(e))
|
app/api/v1/models.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
模型接口模块
|
| 3 |
+
|
| 4 |
+
提供 OpenAI 兼容的 /v1/models 端点,返回系统支持的所有模型列表。
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import time
|
| 8 |
+
from typing import Dict, Any, List, Optional
|
| 9 |
+
from fastapi import APIRouter, HTTPException, Depends
|
| 10 |
+
|
| 11 |
+
from app.models.grok_models import Models
|
| 12 |
+
from app.core.auth import auth_manager
|
| 13 |
+
from app.core.logger import logger
|
| 14 |
+
|
| 15 |
+
# 配置日志
|
| 16 |
+
|
| 17 |
+
# 创建路由器
|
| 18 |
+
router = APIRouter(tags=["模型"])
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@router.get("/models")
|
| 22 |
+
async def list_models(authenticated: Optional[str] = Depends(auth_manager.verify)) -> Dict[str, Any]:
|
| 23 |
+
"""
|
| 24 |
+
获取可用模型列表
|
| 25 |
+
|
| 26 |
+
返回 OpenAI 兼容的模型列表格式,包含系统支持的所有 Grok 模型的详细信息。
|
| 27 |
+
|
| 28 |
+
Args:
|
| 29 |
+
authenticated: 认证状态(由依赖注入)
|
| 30 |
+
|
| 31 |
+
Returns:
|
| 32 |
+
Dict[str, Any]: 包含模型列表的响应数据
|
| 33 |
+
"""
|
| 34 |
+
try:
|
| 35 |
+
logger.debug("[Models] 请求获取模型列表")
|
| 36 |
+
|
| 37 |
+
# 获取当前时间戳
|
| 38 |
+
current_timestamp = int(time.time())
|
| 39 |
+
|
| 40 |
+
# 构建模型数据列表
|
| 41 |
+
model_data: List[Dict[str, Any]] = []
|
| 42 |
+
|
| 43 |
+
for model in Models:
|
| 44 |
+
model_id = model.value
|
| 45 |
+
config = Models.get_model_info(model_id)
|
| 46 |
+
|
| 47 |
+
# 基础信息
|
| 48 |
+
model_info = {
|
| 49 |
+
"id": model_id,
|
| 50 |
+
"object": "model",
|
| 51 |
+
"created": current_timestamp,
|
| 52 |
+
"owned_by": "x-ai",
|
| 53 |
+
"display_name": config.get("display_name", model_id),
|
| 54 |
+
"description": config.get("description", ""),
|
| 55 |
+
"raw_model_path": config.get("raw_model_path", f"xai/{model_id}"),
|
| 56 |
+
"default_temperature": config.get("default_temperature", 1.0),
|
| 57 |
+
"default_max_output_tokens": config.get("default_max_output_tokens", 8192),
|
| 58 |
+
"supported_max_output_tokens": config.get("supported_max_output_tokens", 131072),
|
| 59 |
+
"default_top_p": config.get("default_top_p", 0.95)
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
model_data.append(model_info)
|
| 63 |
+
|
| 64 |
+
# 构建响应
|
| 65 |
+
response = {
|
| 66 |
+
"object": "list",
|
| 67 |
+
"data": model_data
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
logger.debug(f"[Models] 成功返回 {len(model_data)} 个模型")
|
| 71 |
+
return response
|
| 72 |
+
|
| 73 |
+
except Exception as e:
|
| 74 |
+
logger.error(f"[Models] 获取模型列表时发生错误: {str(e)}")
|
| 75 |
+
raise HTTPException(
|
| 76 |
+
status_code=500,
|
| 77 |
+
detail={
|
| 78 |
+
"error": {
|
| 79 |
+
"message": f"Failed to retrieve models: {str(e)}",
|
| 80 |
+
"type": "internal_error",
|
| 81 |
+
"code": "model_list_error"
|
| 82 |
+
}
|
| 83 |
+
}
|
| 84 |
+
)
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
@router.get("/models/{model_id}")
|
| 88 |
+
async def get_model(model_id: str, authenticated: Optional[str] = Depends(auth_manager.verify)) -> Dict[str, Any]:
|
| 89 |
+
"""
|
| 90 |
+
获取特定模型信息
|
| 91 |
+
|
| 92 |
+
Args:
|
| 93 |
+
model_id (str): 模型ID
|
| 94 |
+
authenticated: 认证状态(由依赖注入)
|
| 95 |
+
|
| 96 |
+
Returns:
|
| 97 |
+
Dict[str, Any]: 模型详细信息
|
| 98 |
+
"""
|
| 99 |
+
try:
|
| 100 |
+
logger.debug(f"[Models] 请求获取模型信息: {model_id}")
|
| 101 |
+
|
| 102 |
+
# 验证模型是否存在
|
| 103 |
+
if not Models.is_valid_model(model_id):
|
| 104 |
+
logger.warning(f"[Models] 请求的模型不存在: {model_id}")
|
| 105 |
+
raise HTTPException(
|
| 106 |
+
status_code=404,
|
| 107 |
+
detail={
|
| 108 |
+
"error": {
|
| 109 |
+
"message": f"Model '{model_id}' not found",
|
| 110 |
+
"type": "invalid_request_error",
|
| 111 |
+
"code": "model_not_found"
|
| 112 |
+
}
|
| 113 |
+
}
|
| 114 |
+
)
|
| 115 |
+
|
| 116 |
+
# 获取当前时间戳
|
| 117 |
+
current_timestamp = int(time.time())
|
| 118 |
+
|
| 119 |
+
# 获取模型配置
|
| 120 |
+
config = Models.get_model_info(model_id)
|
| 121 |
+
|
| 122 |
+
# 构建模型信息
|
| 123 |
+
model_info = {
|
| 124 |
+
"id": model_id,
|
| 125 |
+
"object": "model",
|
| 126 |
+
"created": current_timestamp,
|
| 127 |
+
"owned_by": "x-ai",
|
| 128 |
+
"display_name": config.get("display_name", model_id),
|
| 129 |
+
"description": config.get("description", ""),
|
| 130 |
+
"raw_model_path": config.get("raw_model_path", f"xai/{model_id}"),
|
| 131 |
+
"default_temperature": config.get("default_temperature", 1.0),
|
| 132 |
+
"default_max_output_tokens": config.get("default_max_output_tokens", 8192),
|
| 133 |
+
"supported_max_output_tokens": config.get("supported_max_output_tokens", 131072),
|
| 134 |
+
"default_top_p": config.get("default_top_p", 0.95)
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
logger.debug(f"[Models] 成功返回模型信息: {model_id}")
|
| 138 |
+
return model_info
|
| 139 |
+
|
| 140 |
+
except HTTPException:
|
| 141 |
+
raise
|
| 142 |
+
except Exception as e:
|
| 143 |
+
logger.error(f"[Models] 获取模型信息时发生错误: {str(e)}")
|
| 144 |
+
raise HTTPException(
|
| 145 |
+
status_code=500,
|
| 146 |
+
detail={
|
| 147 |
+
"error": {
|
| 148 |
+
"message": f"Failed to retrieve model: {str(e)}",
|
| 149 |
+
"type": "internal_error",
|
| 150 |
+
"code": "model_retrieve_error"
|
| 151 |
+
}
|
| 152 |
+
}
|
| 153 |
+
)
|
app/core/auth.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""认证模块"""
|
| 2 |
+
|
| 3 |
+
from typing import Optional
|
| 4 |
+
from fastapi import Depends, HTTPException
|
| 5 |
+
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
| 6 |
+
from app.core.config import setting
|
| 7 |
+
from app.core.logger import logger
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
security = HTTPBearer(auto_error=False)
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class AuthManager:
|
| 14 |
+
"""认证管理器"""
|
| 15 |
+
|
| 16 |
+
@staticmethod
|
| 17 |
+
def verify(credentials: Optional[HTTPAuthorizationCredentials] = Depends(security)) -> Optional[str]:
|
| 18 |
+
"""验证认证令牌"""
|
| 19 |
+
api_key = setting.grok_config.get("api_key")
|
| 20 |
+
|
| 21 |
+
if not api_key:
|
| 22 |
+
logger.debug("[Auth] 未设置API_KEY,跳过验证。")
|
| 23 |
+
return credentials.credentials if credentials else None
|
| 24 |
+
|
| 25 |
+
if not credentials:
|
| 26 |
+
raise HTTPException(
|
| 27 |
+
status_code=401,
|
| 28 |
+
detail={
|
| 29 |
+
"error": {
|
| 30 |
+
"message": "缺少认证令牌",
|
| 31 |
+
"type": "authentication_error",
|
| 32 |
+
"code": "missing_token"
|
| 33 |
+
}
|
| 34 |
+
}
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
if credentials.credentials != api_key:
|
| 38 |
+
raise HTTPException(
|
| 39 |
+
status_code=401,
|
| 40 |
+
detail={
|
| 41 |
+
"error": {
|
| 42 |
+
"message": f"令牌无效,长度: {len(credentials.credentials)}",
|
| 43 |
+
"type": "authentication_error",
|
| 44 |
+
"code": "invalid_token"
|
| 45 |
+
}
|
| 46 |
+
}
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
logger.debug("[Auth] 令牌认证成功。")
|
| 50 |
+
return credentials.credentials
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
auth_manager = AuthManager()
|
app/core/config.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""配置管理器"""
|
| 2 |
+
|
| 3 |
+
import toml
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
from typing import Dict, Any
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class ConfigManager:
|
| 9 |
+
"""配置管理器"""
|
| 10 |
+
|
| 11 |
+
def __init__(self) -> None:
|
| 12 |
+
"""初始化"""
|
| 13 |
+
|
| 14 |
+
# 加载环境变量
|
| 15 |
+
self.config_path: Path = Path(__file__).parents[2] / "data" / "setting.toml"
|
| 16 |
+
self.global_config: Dict[str, Any] = self.load("global")
|
| 17 |
+
self.grok_config: Dict[str, Any] = self.load("grok")
|
| 18 |
+
|
| 19 |
+
def load(self, section: str) -> Dict[str, Any]:
|
| 20 |
+
"""配置加载器"""
|
| 21 |
+
try:
|
| 22 |
+
with open(self.config_path, "r", encoding="utf-8") as f:
|
| 23 |
+
return toml.load(f)[section]
|
| 24 |
+
except Exception as e:
|
| 25 |
+
raise Exception(f"[Setting] 配置加载失败: {e}")
|
| 26 |
+
|
| 27 |
+
# 全局设置
|
| 28 |
+
setting = ConfigManager()
|
| 29 |
+
|
| 30 |
+
if __name__ == "__main__":
|
| 31 |
+
print(setting.global_config)
|
| 32 |
+
print(setting.grok_config)
|
app/core/exception.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""异常处理器"""
|
| 2 |
+
|
| 3 |
+
from fastapi import Request, status
|
| 4 |
+
from fastapi.responses import JSONResponse
|
| 5 |
+
from fastapi.exceptions import RequestValidationError
|
| 6 |
+
from starlette.exceptions import HTTPException as StarletteHTTPException
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class GrokApiException(Exception):
|
| 10 |
+
"""Grok API 业务异常"""
|
| 11 |
+
|
| 12 |
+
def __init__(self, message: str, error_code: str = None, details: dict = None):
|
| 13 |
+
self.message = message
|
| 14 |
+
self.error_code = error_code
|
| 15 |
+
self.details = details or {}
|
| 16 |
+
super().__init__(self.message)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def build_error_response(message: str, error_type: str, code: str = None, param: str = None) -> dict:
|
| 20 |
+
"""构建OpenAI兼容的错误响应"""
|
| 21 |
+
error = {
|
| 22 |
+
"message": message,
|
| 23 |
+
"type": error_type,
|
| 24 |
+
}
|
| 25 |
+
if code:
|
| 26 |
+
error["code"] = code
|
| 27 |
+
if param:
|
| 28 |
+
error["param"] = param
|
| 29 |
+
|
| 30 |
+
return {"error": error}
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
async def http_exception_handler(_: Request, exc: StarletteHTTPException) -> JSONResponse:
|
| 34 |
+
"""处理HTTP异常"""
|
| 35 |
+
error_map = {
|
| 36 |
+
400: ("invalid_request_error", "请求格式错误或缺少必填参数。"),
|
| 37 |
+
401: ("invalid_request_error", "令牌认证失败。"),
|
| 38 |
+
403: ("permission_error", "没有权限访问此资源。"),
|
| 39 |
+
404: ("invalid_request_error", "请求的资源不存在。"),
|
| 40 |
+
429: ("rate_limit_error", "请求频率超出限制,请稍后再试。"),
|
| 41 |
+
500: ("api_error", "内部服务器错误。"),
|
| 42 |
+
503: ("api_error", "服务暂时不可用。"),
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
error_type, default_message = error_map.get(exc.status_code, ("api_error", str(exc.detail)))
|
| 46 |
+
message = str(exc.detail) if exc.detail else default_message
|
| 47 |
+
|
| 48 |
+
return JSONResponse(
|
| 49 |
+
status_code=exc.status_code,
|
| 50 |
+
content=build_error_response(message, error_type)
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
async def validation_exception_handler(_: Request, exc: RequestValidationError) -> JSONResponse:
|
| 55 |
+
"""处理验证错误"""
|
| 56 |
+
errors = exc.errors()
|
| 57 |
+
param = errors[0]["loc"][-1] if errors and errors[0].get("loc") else None
|
| 58 |
+
message = errors[0]["msg"] if errors and errors[0].get("msg") else "请求参数错误。"
|
| 59 |
+
|
| 60 |
+
return JSONResponse(
|
| 61 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 62 |
+
content=build_error_response(message, "invalid_request_error", param=param)
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
async def grok_api_exception_handler(_: Request, exc: GrokApiException) -> JSONResponse:
|
| 67 |
+
"""处理Grok API业务异常"""
|
| 68 |
+
# 根据错误码映射HTTP状态码
|
| 69 |
+
status_code_map = {
|
| 70 |
+
"NO_AUTH_TOKEN": status.HTTP_401_UNAUTHORIZED,
|
| 71 |
+
"INVALID_TOKEN": status.HTTP_401_UNAUTHORIZED,
|
| 72 |
+
"HTTP_ERROR": status.HTTP_502_BAD_GATEWAY,
|
| 73 |
+
"NETWORK_ERROR": status.HTTP_503_SERVICE_UNAVAILABLE,
|
| 74 |
+
"JSON_ERROR": status.HTTP_502_BAD_GATEWAY,
|
| 75 |
+
"API_ERROR": status.HTTP_502_BAD_GATEWAY,
|
| 76 |
+
"STREAM_ERROR": status.HTTP_502_BAD_GATEWAY,
|
| 77 |
+
"NO_RESPONSE": status.HTTP_502_BAD_GATEWAY,
|
| 78 |
+
"TOKEN_SAVE_ERROR": status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 79 |
+
"NO_AVAILABLE_TOKEN": status.HTTP_503_SERVICE_UNAVAILABLE,
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
http_status = status_code_map.get(exc.error_code, status.HTTP_500_INTERNAL_SERVER_ERROR)
|
| 83 |
+
error_type_map = {
|
| 84 |
+
"NO_AUTH_TOKEN": "authentication_error",
|
| 85 |
+
"INVALID_TOKEN": "authentication_error",
|
| 86 |
+
"HTTP_ERROR": "api_error",
|
| 87 |
+
"NETWORK_ERROR": "api_error",
|
| 88 |
+
"JSON_ERROR": "api_error",
|
| 89 |
+
"API_ERROR": "api_error",
|
| 90 |
+
"STREAM_ERROR": "api_error",
|
| 91 |
+
"NO_RESPONSE": "api_error",
|
| 92 |
+
"TOKEN_SAVE_ERROR": "api_error",
|
| 93 |
+
"NO_AVAILABLE_TOKEN": "api_error",
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
error_type = error_type_map.get(exc.error_code, "api_error")
|
| 97 |
+
|
| 98 |
+
return JSONResponse(
|
| 99 |
+
status_code=http_status,
|
| 100 |
+
content=build_error_response(exc.message, error_type, exc.error_code)
|
| 101 |
+
)
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
async def global_exception_handler(_: Request) -> JSONResponse:
|
| 105 |
+
"""处理未捕获异常"""
|
| 106 |
+
return JSONResponse(
|
| 107 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 108 |
+
content=build_error_response(
|
| 109 |
+
"服务器遇到意外错误,请重试。",
|
| 110 |
+
"api_error"
|
| 111 |
+
)
|
| 112 |
+
)
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def register_exception_handlers(app) -> None:
|
| 116 |
+
"""注册OpenAI兼容的异常处理器"""
|
| 117 |
+
app.add_exception_handler(StarletteHTTPException, http_exception_handler)
|
| 118 |
+
app.add_exception_handler(RequestValidationError, validation_exception_handler)
|
| 119 |
+
app.add_exception_handler(GrokApiException, grok_api_exception_handler)
|
| 120 |
+
app.add_exception_handler(Exception, global_exception_handler)
|
app/core/logger.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""全局日志模块"""
|
| 2 |
+
|
| 3 |
+
import sys
|
| 4 |
+
import logging
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from logging.handlers import RotatingFileHandler
|
| 7 |
+
from app.core.config import setting
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class LoggerManager:
|
| 11 |
+
"""日志管理器"""
|
| 12 |
+
|
| 13 |
+
_initialized = False
|
| 14 |
+
|
| 15 |
+
def __init__(self):
|
| 16 |
+
"""初始化日志"""
|
| 17 |
+
if LoggerManager._initialized:
|
| 18 |
+
return
|
| 19 |
+
|
| 20 |
+
# 日志配置
|
| 21 |
+
log_dir = Path(__file__).parents[2] / "logs"
|
| 22 |
+
log_dir.mkdir(exist_ok=True)
|
| 23 |
+
log_level = setting.global_config.get("log_level", "INFO").upper()
|
| 24 |
+
log_format = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
| 25 |
+
log_file = log_dir / "app.log"
|
| 26 |
+
|
| 27 |
+
# 配置根日志器
|
| 28 |
+
self.logger = logging.getLogger()
|
| 29 |
+
self.logger.setLevel(log_level)
|
| 30 |
+
|
| 31 |
+
# 避免重复添加处理器
|
| 32 |
+
if self.logger.handlers:
|
| 33 |
+
return
|
| 34 |
+
|
| 35 |
+
# 控制台处理器
|
| 36 |
+
console_handler = logging.StreamHandler(sys.stdout)
|
| 37 |
+
console_handler.setLevel(log_level)
|
| 38 |
+
console_handler.setFormatter(logging.Formatter(log_format))
|
| 39 |
+
|
| 40 |
+
# 文件处理器
|
| 41 |
+
file_handler = RotatingFileHandler(
|
| 42 |
+
log_file, maxBytes=10 * 1024 * 1024, backupCount=5, encoding="utf-8"
|
| 43 |
+
)
|
| 44 |
+
file_handler.setLevel(log_level)
|
| 45 |
+
file_handler.setFormatter(logging.Formatter(log_format))
|
| 46 |
+
|
| 47 |
+
self.logger.addHandler(console_handler)
|
| 48 |
+
self.logger.addHandler(file_handler)
|
| 49 |
+
|
| 50 |
+
LoggerManager._initialized = True
|
| 51 |
+
|
| 52 |
+
def debug(self, msg: str) -> None:
|
| 53 |
+
"""调试日志"""
|
| 54 |
+
self.logger.debug(msg)
|
| 55 |
+
|
| 56 |
+
def info(self, msg: str) -> None:
|
| 57 |
+
"""信息日志"""
|
| 58 |
+
self.logger.info(msg)
|
| 59 |
+
|
| 60 |
+
def warning(self, msg: str) -> None:
|
| 61 |
+
"""警告日志"""
|
| 62 |
+
self.logger.warning(msg)
|
| 63 |
+
|
| 64 |
+
def error(self, msg: str) -> None:
|
| 65 |
+
"""错误日志"""
|
| 66 |
+
self.logger.error(msg)
|
| 67 |
+
|
| 68 |
+
def critical(self, msg: str) -> None:
|
| 69 |
+
"""严重错误日志"""
|
| 70 |
+
self.logger.critical(msg)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
# 全局日志器实例
|
| 74 |
+
logger = LoggerManager()
|
app/models/grok_models.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from enum import Enum
|
| 2 |
+
|
| 3 |
+
# 模型配置字典
|
| 4 |
+
_MODEL_CONFIG = {
|
| 5 |
+
"grok-3-fast": {
|
| 6 |
+
"grok_model": ("grok-3", "MODEL_MODE_FAST"),
|
| 7 |
+
"rate_limit_model": "grok-3",
|
| 8 |
+
"cost": {"type": "low_cost", "multiplier": 1, "description": "计1次调用"},
|
| 9 |
+
"requires_super": False,
|
| 10 |
+
"display_name": "Grok 3 Fast",
|
| 11 |
+
"description": "Fast and efficient Grok 3 model",
|
| 12 |
+
"raw_model_path": "xai/grok-3",
|
| 13 |
+
"default_temperature": 1.0,
|
| 14 |
+
"default_max_output_tokens": 8192,
|
| 15 |
+
"supported_max_output_tokens": 131072,
|
| 16 |
+
"default_top_p": 0.95
|
| 17 |
+
},
|
| 18 |
+
"grok-4-fast": {
|
| 19 |
+
"grok_model": ("grok-4-mini-thinking-tahoe", "MODEL_MODE_GROK_4_MINI_THINKING"),
|
| 20 |
+
"rate_limit_model": "grok-4-mini-thinking-tahoe",
|
| 21 |
+
"cost": {"type": "low_cost", "multiplier": 1, "description": "计1次调用"},
|
| 22 |
+
"requires_super": False,
|
| 23 |
+
"display_name": "Grok 4 Fast",
|
| 24 |
+
"description": "Fast version of Grok 4 with mini thinking capabilities",
|
| 25 |
+
"raw_model_path": "xai/grok-4-mini-thinking-tahoe",
|
| 26 |
+
"default_temperature": 1.0,
|
| 27 |
+
"default_max_output_tokens": 8192,
|
| 28 |
+
"supported_max_output_tokens": 131072,
|
| 29 |
+
"default_top_p": 0.95
|
| 30 |
+
},
|
| 31 |
+
"grok-4-fast-expert": {
|
| 32 |
+
"grok_model": ("grok-4-mini-thinking-tahoe", "MODEL_MODE_EXPERT"),
|
| 33 |
+
"rate_limit_model": "grok-4-mini-thinking-tahoe",
|
| 34 |
+
"cost": {"type": "high_cost", "multiplier": 4, "description": "计4次调用"},
|
| 35 |
+
"requires_super": False,
|
| 36 |
+
"display_name": "Grok 4 Fast Expert",
|
| 37 |
+
"description": "Expert mode of Grok 4 Fast with enhanced reasoning",
|
| 38 |
+
"raw_model_path": "xai/grok-4-mini-thinking-tahoe",
|
| 39 |
+
"default_temperature": 1.0,
|
| 40 |
+
"default_max_output_tokens": 32768,
|
| 41 |
+
"supported_max_output_tokens": 131072,
|
| 42 |
+
"default_top_p": 0.95
|
| 43 |
+
},
|
| 44 |
+
"grok-4-expert": {
|
| 45 |
+
"grok_model": ("grok-4", "MODEL_MODE_EXPERT"),
|
| 46 |
+
"rate_limit_model": "grok-4",
|
| 47 |
+
"cost": {"type": "high_cost", "multiplier": 4, "description": "计4次调用"},
|
| 48 |
+
"requires_super": False,
|
| 49 |
+
"display_name": "Grok 4 Expert",
|
| 50 |
+
"description": "Full Grok 4 model with expert mode capabilities",
|
| 51 |
+
"raw_model_path": "xai/grok-4",
|
| 52 |
+
"default_temperature": 1.0,
|
| 53 |
+
"default_max_output_tokens": 32768,
|
| 54 |
+
"supported_max_output_tokens": 131072,
|
| 55 |
+
"default_top_p": 0.95
|
| 56 |
+
},
|
| 57 |
+
"grok-4-heavy": {
|
| 58 |
+
"grok_model": ("grok-4-heavy", "MODEL_MODE_HEAVY"),
|
| 59 |
+
"rate_limit_model": "grok-4-heavy",
|
| 60 |
+
"cost": {"type": "independent", "multiplier": 1, "description": "独立计费,只有Super用户可用"},
|
| 61 |
+
"requires_super": True,
|
| 62 |
+
"display_name": "Grok 4 Heavy",
|
| 63 |
+
"description": "Most powerful Grok 4 model with heavy computational capabilities. Requires Super Token for access.",
|
| 64 |
+
"raw_model_path": "xai/grok-4-heavy",
|
| 65 |
+
"default_temperature": 1.0,
|
| 66 |
+
"default_max_output_tokens": 65536,
|
| 67 |
+
"supported_max_output_tokens": 131072,
|
| 68 |
+
"default_top_p": 0.95
|
| 69 |
+
}
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
class TokenType(Enum):
|
| 73 |
+
"""Token类型枚举"""
|
| 74 |
+
NORMAL = "ssoNormal" # 普通用户Token
|
| 75 |
+
SUPER = "ssoSuper" # 超级用户Token
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
class Models(Enum):
|
| 79 |
+
"""支持的模型枚举"""
|
| 80 |
+
GROK_3_FAST = "grok-3-fast"
|
| 81 |
+
GROK_4_FAST = "grok-4-fast"
|
| 82 |
+
GROK_4_FAST_EXPERT = "grok-4-fast-expert"
|
| 83 |
+
GROK_4_EXPERT = "grok-4-expert"
|
| 84 |
+
GROK_4_HEAVY = "grok-4-heavy"
|
| 85 |
+
|
| 86 |
+
@classmethod
|
| 87 |
+
def get_model_info(cls, model: str) -> dict:
|
| 88 |
+
"""获取模型的完整配置信息"""
|
| 89 |
+
return _MODEL_CONFIG.get(model, {})
|
| 90 |
+
|
| 91 |
+
@classmethod
|
| 92 |
+
def is_valid_model(cls, model: str) -> bool:
|
| 93 |
+
"""检查模型是否有效"""
|
| 94 |
+
return model in _MODEL_CONFIG
|
| 95 |
+
|
| 96 |
+
@classmethod
|
| 97 |
+
def to_grok(cls, model: str) -> tuple[str, str]:
|
| 98 |
+
"""转换为Grok内部模型名和模式类型"""
|
| 99 |
+
config = _MODEL_CONFIG.get(model)
|
| 100 |
+
if config:
|
| 101 |
+
return config["grok_model"]
|
| 102 |
+
return model, "MODEL_MODE_FAST"
|
| 103 |
+
|
| 104 |
+
@classmethod
|
| 105 |
+
def to_rate_limit(cls, model: str) -> str:
|
| 106 |
+
"""转换为速率限制接口模型名"""
|
| 107 |
+
config = _MODEL_CONFIG.get(model)
|
| 108 |
+
return config["rate_limit_model"] if config else model
|
app/models/openai_schema.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""OpenAI 请求-响应模型"""
|
| 2 |
+
|
| 3 |
+
from fastapi import HTTPException
|
| 4 |
+
from typing import Optional, List, Union, Dict, Any
|
| 5 |
+
from pydantic import BaseModel, Field, field_validator
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class OpenAIChatRequest(BaseModel):
|
| 9 |
+
"""OpenAI 聊天请求模型"""
|
| 10 |
+
|
| 11 |
+
model: str = Field(..., description="模型名称", min_length=1)
|
| 12 |
+
messages: List[Dict[str, Any]] = Field(..., description="消息列表", min_length=1)
|
| 13 |
+
stream: bool = Field(False, description="启用流式响应")
|
| 14 |
+
temperature: Optional[float] = Field(0.7, ge=0, le=2, description="采样温度")
|
| 15 |
+
max_tokens: Optional[int] = Field(None, ge=1, le=100000, description="最大Token数")
|
| 16 |
+
top_p: Optional[float] = Field(1.0, ge=0, le=1, description="采样参数")
|
| 17 |
+
|
| 18 |
+
@field_validator('messages')
|
| 19 |
+
@classmethod
|
| 20 |
+
def validate_messages(cls, v):
|
| 21 |
+
"""验证消息格式"""
|
| 22 |
+
if not v:
|
| 23 |
+
raise HTTPException(
|
| 24 |
+
status_code=400,
|
| 25 |
+
detail="消息列表不能为空"
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
for msg in v:
|
| 29 |
+
if not isinstance(msg, dict):
|
| 30 |
+
raise HTTPException(
|
| 31 |
+
status_code=400,
|
| 32 |
+
detail="每个消息必须是一个字典"
|
| 33 |
+
)
|
| 34 |
+
if 'role' not in msg:
|
| 35 |
+
raise HTTPException(
|
| 36 |
+
status_code=400,
|
| 37 |
+
detail="消息缺少必填字段 'role'"
|
| 38 |
+
)
|
| 39 |
+
if 'content' not in msg:
|
| 40 |
+
raise HTTPException(
|
| 41 |
+
status_code=400,
|
| 42 |
+
detail="消息缺少必填字段 'content'"
|
| 43 |
+
)
|
| 44 |
+
if msg['role'] not in ['system', 'user', 'assistant']:
|
| 45 |
+
raise HTTPException(
|
| 46 |
+
status_code=400,
|
| 47 |
+
detail=f"无效的角色 '{msg['role']}', 必须是 'system', 'user' 或 'assistant'"
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
return v
|
| 51 |
+
|
| 52 |
+
@field_validator('model')
|
| 53 |
+
@classmethod
|
| 54 |
+
def validate_model(cls, v):
|
| 55 |
+
"""验证模型名称"""
|
| 56 |
+
allowed_models = [
|
| 57 |
+
'grok-3-fast', 'grok-4-fast', 'grok-4-fast-expert',
|
| 58 |
+
'grok-4-expert', 'grok-4-heavy'
|
| 59 |
+
]
|
| 60 |
+
if v not in allowed_models:
|
| 61 |
+
raise HTTPException(
|
| 62 |
+
status_code=400,
|
| 63 |
+
detail=f"不支持的模型 '{v}', 支持的模型: {', '.join(allowed_models)}"
|
| 64 |
+
)
|
| 65 |
+
return v
|
| 66 |
+
|
| 67 |
+
class OpenAIChatCompletionMessage(BaseModel):
|
| 68 |
+
"""聊天完成消息"""
|
| 69 |
+
role: str = Field(..., description="角色")
|
| 70 |
+
content: str = Field(..., description="消息内容")
|
| 71 |
+
reference_id: Optional[str] = Field(None, description="参考ID")
|
| 72 |
+
annotations: Optional[List[str]] = Field(None, description="注释")
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
class OpenAIChatCompletionChoice(BaseModel):
|
| 76 |
+
"""聊天完成选项"""
|
| 77 |
+
index: int = Field(..., description="选项索引")
|
| 78 |
+
message: OpenAIChatCompletionMessage = Field(..., description="响应消息")
|
| 79 |
+
logprobs: Optional[float] = Field(None, description="对数概率")
|
| 80 |
+
finish_reason: str = Field("stop", description="完成原因")
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
class OpenAIChatCompletionResponse(BaseModel):
|
| 84 |
+
"""聊天完成响应"""
|
| 85 |
+
id: str = Field(..., description="响应ID")
|
| 86 |
+
object: str = Field("chat.completion", description="对象类型")
|
| 87 |
+
created: int = Field(..., description="创建时间戳")
|
| 88 |
+
model: str = Field(..., description="使用的模型")
|
| 89 |
+
choices: List[OpenAIChatCompletionChoice] = Field(..., description="响应选项")
|
| 90 |
+
usage: Optional[Dict[str, Any]] = Field(None, description="令牌使用")
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
class OpenAIChatCompletionChunkMessage(BaseModel):
|
| 94 |
+
"""流式响应消息片段"""
|
| 95 |
+
role: str = Field(..., description="角色")
|
| 96 |
+
content: str = Field(..., description="消息内容")
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
class OpenAIChatCompletionChunkChoice(BaseModel):
|
| 100 |
+
"""流式响应选项"""
|
| 101 |
+
index: int = Field(..., description="选项索引")
|
| 102 |
+
delta: Optional[Union[Dict[str, Any], OpenAIChatCompletionChunkMessage]] = Field(
|
| 103 |
+
None, description="Delta数据"
|
| 104 |
+
)
|
| 105 |
+
finish_reason: Optional[str] = Field(None, description="完成原因")
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
class OpenAIChatCompletionChunkResponse(BaseModel):
|
| 109 |
+
"""流式聊天完成响应"""
|
| 110 |
+
id: str = Field(..., description="响应ID")
|
| 111 |
+
object: str = Field("chat.completion.chunk", description="对象类型")
|
| 112 |
+
created: int = Field(..., description="创建时间戳")
|
| 113 |
+
model: str = Field(..., description="使用的模型")
|
| 114 |
+
system_fingerprint: Optional[str] = Field(None, description="系统指纹")
|
| 115 |
+
choices: List[OpenAIChatCompletionChunkChoice] = Field(..., description="响应选项")
|
app/services/grok/client.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Grok API 客户端模块"""
|
| 2 |
+
|
| 3 |
+
import asyncio
|
| 4 |
+
import json
|
| 5 |
+
from typing import Dict, List, Tuple, Any
|
| 6 |
+
from curl_cffi import requests as curl_requests
|
| 7 |
+
|
| 8 |
+
from app.core.config import setting
|
| 9 |
+
from app.core.logger import logger
|
| 10 |
+
from app.models.grok_models import Models
|
| 11 |
+
from app.services.grok.processer import GrokResponseProcessor
|
| 12 |
+
from app.services.grok.statsig import get_dynamic_headers
|
| 13 |
+
from app.services.grok.token import token_manager
|
| 14 |
+
from app.services.grok.upload import ImageUploadManager
|
| 15 |
+
from app.core.exception import GrokApiException
|
| 16 |
+
|
| 17 |
+
# 常量定义
|
| 18 |
+
GROK_API_ENDPOINT = "https://grok.com/rest/app-chat/conversations/new"
|
| 19 |
+
REQUEST_TIMEOUT = 120
|
| 20 |
+
IMPERSONATE_BROWSER = "chrome133a"
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class GrokClient:
|
| 24 |
+
"""Grok API 客户端"""
|
| 25 |
+
|
| 26 |
+
@staticmethod
|
| 27 |
+
async def openai_to_grok(openai_request: dict):
|
| 28 |
+
"""转换OpenAI请求为Grok请求并处理响应"""
|
| 29 |
+
model = openai_request["model"]
|
| 30 |
+
messages = openai_request["messages"]
|
| 31 |
+
stream = openai_request.get("stream", False)
|
| 32 |
+
|
| 33 |
+
logger.debug(f"[Client] 处理请求 - 模型:{model}, 消息数:{len(messages)}, 流式:{stream}")
|
| 34 |
+
|
| 35 |
+
# 提取消息内容和图片URL
|
| 36 |
+
content, image_urls = GrokClient._extract_content(messages)
|
| 37 |
+
|
| 38 |
+
# 获取认证令牌和模型信息
|
| 39 |
+
auth_token = token_manager.get_token(model)
|
| 40 |
+
model_name, model_mode = Models.to_grok(model)
|
| 41 |
+
|
| 42 |
+
# 上传图片并获取附件ID列表
|
| 43 |
+
image_attachments = await GrokClient._upload_imgs(image_urls, auth_token)
|
| 44 |
+
|
| 45 |
+
# 构建Grok请求载荷
|
| 46 |
+
payload = GrokClient._build_payload(content, model_name, model_mode, image_attachments)
|
| 47 |
+
|
| 48 |
+
return await GrokClient._send_request(payload, auth_token, model, stream)
|
| 49 |
+
|
| 50 |
+
@staticmethod
|
| 51 |
+
def _extract_content(messages: List[Dict]) -> Tuple[str, List[str]]:
|
| 52 |
+
"""提取消息内容和图片URL"""
|
| 53 |
+
content_parts = []
|
| 54 |
+
image_urls = []
|
| 55 |
+
|
| 56 |
+
for msg in messages:
|
| 57 |
+
msg_content = msg.get("content", "")
|
| 58 |
+
|
| 59 |
+
# 处理复杂消息格式(包含文本和图片)
|
| 60 |
+
if isinstance(msg_content, list):
|
| 61 |
+
for item in msg_content:
|
| 62 |
+
item_type = item.get("type")
|
| 63 |
+
if item_type == "text":
|
| 64 |
+
content_parts.append(item.get("text", ""))
|
| 65 |
+
elif item_type == "image_url":
|
| 66 |
+
url = item.get("image_url", {}).get("url", "")
|
| 67 |
+
if url:
|
| 68 |
+
image_urls.append(url)
|
| 69 |
+
# 处理纯文本消息
|
| 70 |
+
else:
|
| 71 |
+
content_parts.append(msg_content)
|
| 72 |
+
|
| 73 |
+
return "".join(content_parts), image_urls
|
| 74 |
+
|
| 75 |
+
@staticmethod
|
| 76 |
+
async def _upload_imgs(image_urls: List[str], auth_token: str) -> List[str]:
|
| 77 |
+
"""上传图片并返回附件ID列表"""
|
| 78 |
+
image_attachments = []
|
| 79 |
+
# 并发上传所有图片
|
| 80 |
+
tasks = [ImageUploadManager.upload(url, auth_token) for url in image_urls]
|
| 81 |
+
results = await asyncio.gather(*tasks, return_exceptions=True)
|
| 82 |
+
|
| 83 |
+
for url, result in zip(image_urls, results):
|
| 84 |
+
if isinstance(result, Exception):
|
| 85 |
+
logger.warning(f"[Client] 图片上传失败: {url}, 错误: {result}")
|
| 86 |
+
elif result:
|
| 87 |
+
image_attachments.append(result)
|
| 88 |
+
|
| 89 |
+
return image_attachments
|
| 90 |
+
|
| 91 |
+
@staticmethod
|
| 92 |
+
def _build_payload(content: str, model_name: str, model_mode: str, image_attachments: List[str]) -> Dict[str, Any]:
|
| 93 |
+
"""构建Grok API请求载荷"""
|
| 94 |
+
return {
|
| 95 |
+
"temporary": setting.grok_config.get("temporary", True),
|
| 96 |
+
"modelName": model_name,
|
| 97 |
+
"message": content,
|
| 98 |
+
"fileAttachments": image_attachments,
|
| 99 |
+
"imageAttachments": [],
|
| 100 |
+
"disableSearch": False,
|
| 101 |
+
"enableImageGeneration": True,
|
| 102 |
+
"returnImageBytes": False,
|
| 103 |
+
"returnRawGrokInXaiRequest": False,
|
| 104 |
+
"enableImageStreaming": True,
|
| 105 |
+
"imageGenerationCount": 2,
|
| 106 |
+
"forceConcise": False,
|
| 107 |
+
"toolOverrides": {},
|
| 108 |
+
"enableSideBySide": True,
|
| 109 |
+
"sendFinalMetadata": True,
|
| 110 |
+
"isReasoning": False,
|
| 111 |
+
"webpageUrls": [],
|
| 112 |
+
"disableTextFollowUps": True,
|
| 113 |
+
"responseMetadata": {"requestModelDetails": {"modelId": model_name}},
|
| 114 |
+
"disableMemory": False,
|
| 115 |
+
"forceSideBySide": False,
|
| 116 |
+
"modelMode": model_mode,
|
| 117 |
+
"isAsyncChat": False
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
@staticmethod
|
| 121 |
+
async def _send_request(payload: dict, auth_token: str, model: str, stream: bool):
|
| 122 |
+
"""发送HTTP请求到Grok API"""
|
| 123 |
+
# 验证认证令牌
|
| 124 |
+
if not auth_token:
|
| 125 |
+
raise GrokApiException("认证令牌缺失", "NO_AUTH_TOKEN")
|
| 126 |
+
|
| 127 |
+
try:
|
| 128 |
+
# 构建请求头和代理
|
| 129 |
+
headers = GrokClient._build_headers(auth_token)
|
| 130 |
+
proxies = GrokClient._get_proxy()
|
| 131 |
+
|
| 132 |
+
# 在线程池中执行同步HTTP请求,���免阻塞事件循环
|
| 133 |
+
response = await asyncio.to_thread(
|
| 134 |
+
curl_requests.post,
|
| 135 |
+
GROK_API_ENDPOINT,
|
| 136 |
+
headers=headers,
|
| 137 |
+
data=json.dumps(payload),
|
| 138 |
+
impersonate=IMPERSONATE_BROWSER,
|
| 139 |
+
timeout=REQUEST_TIMEOUT,
|
| 140 |
+
stream=True,
|
| 141 |
+
**proxies
|
| 142 |
+
)
|
| 143 |
+
|
| 144 |
+
logger.debug(f"[Client] API响应状态码: {response.status_code}")
|
| 145 |
+
|
| 146 |
+
# 处理非成功响应
|
| 147 |
+
if response.status_code != 200:
|
| 148 |
+
GrokClient._handle_error(response, auth_token)
|
| 149 |
+
|
| 150 |
+
# 请求成功,重置失败计数
|
| 151 |
+
asyncio.create_task(token_manager.reset_failure(auth_token))
|
| 152 |
+
|
| 153 |
+
# 处理并返回响应
|
| 154 |
+
return await GrokClient._process_response(response, auth_token, model, stream)
|
| 155 |
+
|
| 156 |
+
except curl_requests.RequestsError as e:
|
| 157 |
+
raise GrokApiException(f"网络错误: {e}", "NETWORK_ERROR") from e
|
| 158 |
+
except json.JSONDecodeError as e:
|
| 159 |
+
raise GrokApiException(f"JSON解析错误: {e}", "JSON_ERROR") from e
|
| 160 |
+
|
| 161 |
+
@staticmethod
|
| 162 |
+
def _build_headers(auth_token: str) -> Dict[str, str]:
|
| 163 |
+
"""构建请求头"""
|
| 164 |
+
headers = get_dynamic_headers("/rest/app-chat/conversations/new")
|
| 165 |
+
|
| 166 |
+
# 构建Cookie
|
| 167 |
+
cf_clearance = setting.grok_config.get("cf_clearance", "")
|
| 168 |
+
headers["Cookie"] = f"{auth_token};{cf_clearance}" if cf_clearance else auth_token
|
| 169 |
+
|
| 170 |
+
return headers
|
| 171 |
+
|
| 172 |
+
@staticmethod
|
| 173 |
+
def _get_proxy() -> Dict[str, str]:
|
| 174 |
+
"""获取代理配置"""
|
| 175 |
+
proxy_url = setting.grok_config.get("proxy_url", "")
|
| 176 |
+
if proxy_url:
|
| 177 |
+
return {"http": proxy_url, "https": proxy_url}
|
| 178 |
+
return {}
|
| 179 |
+
|
| 180 |
+
@staticmethod
|
| 181 |
+
def _handle_error(response, auth_token: str):
|
| 182 |
+
"""处理错误响应"""
|
| 183 |
+
try:
|
| 184 |
+
error_data = response.json()
|
| 185 |
+
error_message = str(error_data)
|
| 186 |
+
except Exception:
|
| 187 |
+
error_data = response.text
|
| 188 |
+
error_message = error_data[:200] if error_data else "未知错误"
|
| 189 |
+
|
| 190 |
+
# 记录Token失败
|
| 191 |
+
asyncio.create_task(token_manager.record_failure(auth_token, response.status_code, error_message))
|
| 192 |
+
|
| 193 |
+
raise GrokApiException(
|
| 194 |
+
f"请求失败: {response.status_code} - {error_message}",
|
| 195 |
+
"HTTP_ERROR",
|
| 196 |
+
{"status": response.status_code, "data": error_data}
|
| 197 |
+
)
|
| 198 |
+
|
| 199 |
+
@staticmethod
|
| 200 |
+
async def _process_response(response, auth_token: str, model: str, stream: bool):
|
| 201 |
+
"""处理API响应"""
|
| 202 |
+
if stream:
|
| 203 |
+
result = GrokResponseProcessor.process_stream(response, auth_token)
|
| 204 |
+
asyncio.create_task(GrokClient._update_rate_limits(auth_token, model))
|
| 205 |
+
else:
|
| 206 |
+
result = await GrokResponseProcessor.process_normal(response, auth_token)
|
| 207 |
+
asyncio.create_task(GrokClient._update_rate_limits(auth_token, model))
|
| 208 |
+
|
| 209 |
+
return result
|
| 210 |
+
|
| 211 |
+
@staticmethod
|
| 212 |
+
async def _update_rate_limits(auth_token: str, model: str):
|
| 213 |
+
"""异步更新速率限制信息"""
|
| 214 |
+
try:
|
| 215 |
+
await token_manager.check_limits(auth_token, model)
|
| 216 |
+
except Exception as e:
|
| 217 |
+
logger.error(f"[Client] 更新速率限制失败: {e}")
|
app/services/grok/image_cache.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""图片缓存服务模块"""
|
| 2 |
+
|
| 3 |
+
import asyncio
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
from typing import Optional
|
| 6 |
+
from curl_cffi.requests import AsyncSession
|
| 7 |
+
|
| 8 |
+
from app.core.config import setting
|
| 9 |
+
from app.core.logger import logger
|
| 10 |
+
from app.services.grok.statsig import get_dynamic_headers
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class ImageCacheService:
|
| 14 |
+
"""图片缓存服务"""
|
| 15 |
+
|
| 16 |
+
def __init__(self):
|
| 17 |
+
self.cache_dir = Path("data/temp")
|
| 18 |
+
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
| 19 |
+
|
| 20 |
+
@staticmethod
|
| 21 |
+
def _get_cache_filename(image_path: str) -> str:
|
| 22 |
+
"""将图片路径转换为缓存文件名"""
|
| 23 |
+
# 移除开头的斜杠并替换所有斜杠为短横线
|
| 24 |
+
filename = image_path.lstrip('/').replace('/', '-')
|
| 25 |
+
return filename
|
| 26 |
+
|
| 27 |
+
def _get_cache_path(self, image_path: str) -> Path:
|
| 28 |
+
"""获取缓存文件的完整路径"""
|
| 29 |
+
filename = self._get_cache_filename(image_path)
|
| 30 |
+
return self.cache_dir / filename
|
| 31 |
+
|
| 32 |
+
async def download_image(self, image_path: str, auth_token: str) -> Optional[Path]:
|
| 33 |
+
"""下载并缓存图片
|
| 34 |
+
|
| 35 |
+
Args:
|
| 36 |
+
image_path: 图片路径,如 /users/xxx/generated/xxx/image.jpg
|
| 37 |
+
auth_token: 认证令牌
|
| 38 |
+
|
| 39 |
+
Returns:
|
| 40 |
+
缓存文件路径,下载失败返回 None
|
| 41 |
+
"""
|
| 42 |
+
cache_path = self._get_cache_path(image_path)
|
| 43 |
+
|
| 44 |
+
if cache_path.exists():
|
| 45 |
+
logger.debug(f"[ImageCache] 图片已缓存: {cache_path}")
|
| 46 |
+
return cache_path
|
| 47 |
+
|
| 48 |
+
image_url = f"https://assets.grok.com{image_path}"
|
| 49 |
+
|
| 50 |
+
try:
|
| 51 |
+
# 构建 Cookie
|
| 52 |
+
cf_clearance = setting.grok_config.get("cf_clearance", "")
|
| 53 |
+
cookie = f"{auth_token};{cf_clearance}" if cf_clearance else auth_token
|
| 54 |
+
|
| 55 |
+
# 构建请求头
|
| 56 |
+
headers = {
|
| 57 |
+
**get_dynamic_headers(pathname=image_path),
|
| 58 |
+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
|
| 59 |
+
"Sec-Fetch-Dest": "document",
|
| 60 |
+
"Sec-Fetch-Mode": "navigate",
|
| 61 |
+
"Sec-Fetch-Site": "same-site",
|
| 62 |
+
"Sec-Fetch-User": "?1",
|
| 63 |
+
"Upgrade-Insecure-Requests": "1",
|
| 64 |
+
"Referer": "https://grok.com/",
|
| 65 |
+
"Cookie": cookie
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
# 代理配置
|
| 69 |
+
proxy_url = setting.grok_config.get("proxy_url")
|
| 70 |
+
proxies = {"http": proxy_url, "https": proxy_url} if proxy_url else None
|
| 71 |
+
|
| 72 |
+
async with AsyncSession() as session:
|
| 73 |
+
logger.debug(f"[ImageCache] 开始下载图片: {image_url}")
|
| 74 |
+
response = await session.get(
|
| 75 |
+
image_url,
|
| 76 |
+
headers=headers,
|
| 77 |
+
proxies=proxies,
|
| 78 |
+
timeout=30.0,
|
| 79 |
+
allow_redirects=True,
|
| 80 |
+
impersonate="chrome133a"
|
| 81 |
+
)
|
| 82 |
+
response.raise_for_status()
|
| 83 |
+
|
| 84 |
+
cache_path.write_bytes(response.content)
|
| 85 |
+
logger.debug(f"[ImageCache] 图片已缓存: {cache_path} ({len(response.content)} bytes)")
|
| 86 |
+
|
| 87 |
+
asyncio.create_task(self.cleanup_cache())
|
| 88 |
+
|
| 89 |
+
return cache_path
|
| 90 |
+
|
| 91 |
+
except Exception as e:
|
| 92 |
+
logger.error(f"[ImageCache] 下载图片失败: {e}")
|
| 93 |
+
return None
|
| 94 |
+
|
| 95 |
+
def get_cached_image(self, image_path: str) -> Optional[Path]:
|
| 96 |
+
"""获取缓存的图片路径
|
| 97 |
+
|
| 98 |
+
Args:
|
| 99 |
+
image_path: 图片路径
|
| 100 |
+
|
| 101 |
+
Returns:
|
| 102 |
+
缓存文件路径,不存在返回 None
|
| 103 |
+
"""
|
| 104 |
+
cache_path = self._get_cache_path(image_path)
|
| 105 |
+
return cache_path if cache_path.exists() else None
|
| 106 |
+
|
| 107 |
+
async def cleanup_cache(self):
|
| 108 |
+
"""清理缓存目录,确保不超过配置的大小限制"""
|
| 109 |
+
try:
|
| 110 |
+
# 获取配置的最大缓存大小(MB)
|
| 111 |
+
max_size_mb = setting.global_config.get("temp_max_size_mb", 500)
|
| 112 |
+
max_size_bytes = max_size_mb * 1024 * 1024
|
| 113 |
+
|
| 114 |
+
# 获取所有缓存文件及其大小和修改时间
|
| 115 |
+
files = []
|
| 116 |
+
total_size = 0
|
| 117 |
+
|
| 118 |
+
for file_path in self.cache_dir.glob("*"):
|
| 119 |
+
if file_path.is_file():
|
| 120 |
+
size = file_path.stat().st_size
|
| 121 |
+
mtime = file_path.stat().st_mtime
|
| 122 |
+
files.append((file_path, size, mtime))
|
| 123 |
+
total_size += size
|
| 124 |
+
|
| 125 |
+
# 如果总大小未超限,无需清理
|
| 126 |
+
if total_size <= max_size_bytes:
|
| 127 |
+
logger.debug(f"[ImageCache] 缓存大小 {total_size / 1024 / 1024:.2f}MB,未超限")
|
| 128 |
+
return
|
| 129 |
+
|
| 130 |
+
logger.info(f"[ImageCache] 缓存大小 {total_size / 1024 / 1024:.2f}MB 超过限制 {max_size_mb}MB,开始清理")
|
| 131 |
+
|
| 132 |
+
# 按修改时间排序(最旧的在前)
|
| 133 |
+
files.sort(key=lambda x: x[2])
|
| 134 |
+
|
| 135 |
+
# 删除最旧的文件直到总大小低于限制
|
| 136 |
+
for file_path, size, _ in files:
|
| 137 |
+
if total_size <= max_size_bytes:
|
| 138 |
+
break
|
| 139 |
+
|
| 140 |
+
file_path.unlink()
|
| 141 |
+
total_size -= size
|
| 142 |
+
logger.debug(f"[ImageCache] 已删除缓存文件: {file_path}")
|
| 143 |
+
|
| 144 |
+
logger.info(f"[ImageCache] 缓存清理完成,当前大小 {total_size / 1024 / 1024:.2f}MB")
|
| 145 |
+
|
| 146 |
+
except Exception as e:
|
| 147 |
+
logger.error(f"[ImageCache] 清理缓存失败: {e}")
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
# 全局实例
|
| 151 |
+
image_cache_service = ImageCacheService()
|
app/services/grok/processer.py
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Grok API 响应处理器模块"""
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import uuid
|
| 5 |
+
import time
|
| 6 |
+
from typing import Iterator
|
| 7 |
+
|
| 8 |
+
from app.core.config import setting
|
| 9 |
+
from app.core.exception import GrokApiException
|
| 10 |
+
from app.core.logger import logger
|
| 11 |
+
from app.models.openai_schema import (
|
| 12 |
+
OpenAIChatCompletionResponse,
|
| 13 |
+
OpenAIChatCompletionChoice,
|
| 14 |
+
OpenAIChatCompletionMessage,
|
| 15 |
+
OpenAIChatCompletionChunkResponse,
|
| 16 |
+
OpenAIChatCompletionChunkChoice,
|
| 17 |
+
OpenAIChatCompletionChunkMessage
|
| 18 |
+
)
|
| 19 |
+
from app.services.grok.image_cache import image_cache_service
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class GrokResponseProcessor:
|
| 23 |
+
"""Grok API 响应处理器"""
|
| 24 |
+
|
| 25 |
+
@staticmethod
|
| 26 |
+
async def process_normal(response, auth_token: str) -> OpenAIChatCompletionResponse:
|
| 27 |
+
"""处理非流式响应"""
|
| 28 |
+
try:
|
| 29 |
+
for chunk in response.iter_lines():
|
| 30 |
+
if not chunk:
|
| 31 |
+
continue
|
| 32 |
+
|
| 33 |
+
data = json.loads(chunk.decode("utf-8"))
|
| 34 |
+
|
| 35 |
+
# 错误检查
|
| 36 |
+
if error := data.get("error"):
|
| 37 |
+
raise GrokApiException(
|
| 38 |
+
f"API错误: {error.get('message', '未知错误')}",
|
| 39 |
+
"API_ERROR",
|
| 40 |
+
{"code": error.get("code")}
|
| 41 |
+
)
|
| 42 |
+
|
| 43 |
+
# 提取模型响应
|
| 44 |
+
model_response = data.get("result", {}).get("response", {}).get("modelResponse")
|
| 45 |
+
if not model_response:
|
| 46 |
+
continue
|
| 47 |
+
|
| 48 |
+
# 检查 modelResponse 中的错误
|
| 49 |
+
if error_msg := model_response.get("error"):
|
| 50 |
+
raise GrokApiException(
|
| 51 |
+
f"模型响应错误: {error_msg}",
|
| 52 |
+
"MODEL_ERROR"
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
# 构建响应内容
|
| 56 |
+
model = model_response.get("model")
|
| 57 |
+
content = model_response.get("message", "")
|
| 58 |
+
|
| 59 |
+
# 添加生成的图片
|
| 60 |
+
if images := model_response.get("generatedImageUrls"):
|
| 61 |
+
for img in images:
|
| 62 |
+
try:
|
| 63 |
+
cache_path = await image_cache_service.download_image(f"/{img}", auth_token)
|
| 64 |
+
if cache_path:
|
| 65 |
+
img_path = img.replace('/', '-')
|
| 66 |
+
base_url = setting.global_config.get("base_url", "")
|
| 67 |
+
img_url = f"{base_url}/images/{img_path}" if base_url else f"/images/{img_path}"
|
| 68 |
+
content += f"\n"
|
| 69 |
+
else:
|
| 70 |
+
content += f"\n"
|
| 71 |
+
except Exception as e:
|
| 72 |
+
logger.warning(f"[Processor] 缓存图片失败: {e}")
|
| 73 |
+
content += f"\n"
|
| 74 |
+
|
| 75 |
+
# 返回OpenAI格式响应
|
| 76 |
+
result = OpenAIChatCompletionResponse(
|
| 77 |
+
id=f"chatcmpl-{uuid.uuid4()}",
|
| 78 |
+
object="chat.completion",
|
| 79 |
+
created=int(time.time()),
|
| 80 |
+
model=model,
|
| 81 |
+
choices=[OpenAIChatCompletionChoice(
|
| 82 |
+
index=0,
|
| 83 |
+
message=OpenAIChatCompletionMessage(
|
| 84 |
+
role="assistant",
|
| 85 |
+
content=content
|
| 86 |
+
),
|
| 87 |
+
finish_reason="stop"
|
| 88 |
+
)],
|
| 89 |
+
usage=None
|
| 90 |
+
)
|
| 91 |
+
response.close()
|
| 92 |
+
return result
|
| 93 |
+
|
| 94 |
+
raise GrokApiException("无响应数据", "NO_RESPONSE")
|
| 95 |
+
|
| 96 |
+
except json.JSONDecodeError as e:
|
| 97 |
+
raise GrokApiException(f"JSON解析失败: {e}", "JSON_ERROR") from e
|
| 98 |
+
finally:
|
| 99 |
+
# 确保响应对象被关闭
|
| 100 |
+
if hasattr(response, 'close'):
|
| 101 |
+
response.close()
|
| 102 |
+
|
| 103 |
+
@staticmethod
|
| 104 |
+
async def process_stream(response, auth_token: str) -> Iterator[str]:
|
| 105 |
+
"""处理流式响应"""
|
| 106 |
+
is_image = False
|
| 107 |
+
is_thinking = False
|
| 108 |
+
thinking_finished = False
|
| 109 |
+
chunk_index = 0
|
| 110 |
+
model = None
|
| 111 |
+
filtered_tags = setting.grok_config.get("filtered_tags", "").split(",")
|
| 112 |
+
|
| 113 |
+
def make_chunk(content: str, finish: str = None):
|
| 114 |
+
"""生成OpenAI格式的响应块"""
|
| 115 |
+
chunk_data = OpenAIChatCompletionChunkResponse(
|
| 116 |
+
id=f"chatcmpl-{uuid.uuid4()}",
|
| 117 |
+
created=int(time.time()),
|
| 118 |
+
model=model or "grok-4-mini-thinking-tahoe",
|
| 119 |
+
choices=[OpenAIChatCompletionChunkChoice(
|
| 120 |
+
index=chunk_index,
|
| 121 |
+
delta=OpenAIChatCompletionChunkMessage(
|
| 122 |
+
role="assistant",
|
| 123 |
+
content=content
|
| 124 |
+
) if content else {},
|
| 125 |
+
finish_reason=finish
|
| 126 |
+
)]
|
| 127 |
+
).model_dump()
|
| 128 |
+
# SSE 格式返回
|
| 129 |
+
return f"data: {json.dumps(chunk_data)}\n\n"
|
| 130 |
+
|
| 131 |
+
try:
|
| 132 |
+
for chunk in response.iter_lines():
|
| 133 |
+
logger.debug(f"[Processor] 接收到数据块: {len(chunk)} bytes")
|
| 134 |
+
if not chunk:
|
| 135 |
+
continue
|
| 136 |
+
|
| 137 |
+
try:
|
| 138 |
+
data = json.loads(chunk.decode("utf-8"))
|
| 139 |
+
|
| 140 |
+
# 错误检查
|
| 141 |
+
if error := data.get("error"):
|
| 142 |
+
error_msg = error.get('message', '未知错误')
|
| 143 |
+
logger.error(f"[Processor] Grok API返回错误: {error_msg}")
|
| 144 |
+
yield make_chunk(f"Error: {error_msg}", "stop")
|
| 145 |
+
yield "data: [DONE]\n\n"
|
| 146 |
+
return
|
| 147 |
+
|
| 148 |
+
# 提取响应数据
|
| 149 |
+
grok_resp = data.get("result", {}).get("response", {})
|
| 150 |
+
logger.debug(f"[Processor] 解析响应数据: {len(grok_resp)} 字段")
|
| 151 |
+
if not grok_resp:
|
| 152 |
+
continue
|
| 153 |
+
|
| 154 |
+
# 更新模型名称
|
| 155 |
+
if user_resp := grok_resp.get("userResponse"):
|
| 156 |
+
if m := user_resp.get("model"):
|
| 157 |
+
model = m
|
| 158 |
+
|
| 159 |
+
# 检查生成模式
|
| 160 |
+
if grok_resp.get("imageAttachmentInfo"):
|
| 161 |
+
is_image = True
|
| 162 |
+
|
| 163 |
+
# 获取token
|
| 164 |
+
token = grok_resp.get("token", "")
|
| 165 |
+
|
| 166 |
+
# 图片模式
|
| 167 |
+
if is_image:
|
| 168 |
+
if model_resp := grok_resp.get("modelResponse"):
|
| 169 |
+
# 生成图片链接并缓存
|
| 170 |
+
content = ""
|
| 171 |
+
for img in model_resp.get("generatedImageUrls", []):
|
| 172 |
+
try:
|
| 173 |
+
# 异步下载并缓存图片
|
| 174 |
+
await image_cache_service.download_image(f"/{img}", auth_token)
|
| 175 |
+
# 使用本地缓存路径
|
| 176 |
+
img_path = img.replace('/', '-')
|
| 177 |
+
base_url = setting.global_config.get("base_url", "")
|
| 178 |
+
img_url = f"{base_url}/images/{img_path}" if base_url else f"/images/{img_path}"
|
| 179 |
+
content += f"\n"
|
| 180 |
+
except Exception as e:
|
| 181 |
+
logger.warning(f"[Processor] 缓存图片失败: {e}")
|
| 182 |
+
content += f"\n"
|
| 183 |
+
yield make_chunk(content.strip(), "stop")
|
| 184 |
+
return
|
| 185 |
+
elif token:
|
| 186 |
+
yield make_chunk(token)
|
| 187 |
+
chunk_index += 1
|
| 188 |
+
|
| 189 |
+
# 对话模式
|
| 190 |
+
else:
|
| 191 |
+
# 过滤 list 格式的 token
|
| 192 |
+
if isinstance(token, list):
|
| 193 |
+
continue
|
| 194 |
+
|
| 195 |
+
# 过滤特定标签
|
| 196 |
+
if any(tag in token for tag in filtered_tags if token):
|
| 197 |
+
continue
|
| 198 |
+
|
| 199 |
+
# 获取当前状态
|
| 200 |
+
current_is_thinking = grok_resp.get("isThinking", False)
|
| 201 |
+
message_tag = grok_resp.get("messageTag")
|
| 202 |
+
|
| 203 |
+
# 跳过后续的 thinking
|
| 204 |
+
if thinking_finished and current_is_thinking:
|
| 205 |
+
continue
|
| 206 |
+
|
| 207 |
+
# 检查 toolUsageCardId
|
| 208 |
+
if grok_resp.get("toolUsageCardId"):
|
| 209 |
+
if web_search := grok_resp.get("webSearchResults"):
|
| 210 |
+
if current_is_thinking:
|
| 211 |
+
# 添加搜索结果到 token
|
| 212 |
+
for result in web_search.get("results", []):
|
| 213 |
+
title = result.get("title", "")
|
| 214 |
+
url = result.get("url", "")
|
| 215 |
+
preview = result.get("preview", "")
|
| 216 |
+
preview_clean = preview.replace("\n", "") if isinstance(preview, str) else ""
|
| 217 |
+
token += f'\n- [{title}]({url} "{preview_clean}")'
|
| 218 |
+
token += "\n"
|
| 219 |
+
else:
|
| 220 |
+
# 有 webSearchResults 但 isThinking 为 false
|
| 221 |
+
continue
|
| 222 |
+
else:
|
| 223 |
+
# 没有 webSearchResults
|
| 224 |
+
continue
|
| 225 |
+
|
| 226 |
+
if token:
|
| 227 |
+
content = token
|
| 228 |
+
|
| 229 |
+
# header 在 token 后换行
|
| 230 |
+
if message_tag == "header":
|
| 231 |
+
content = f"\n\n{token}\n\n"
|
| 232 |
+
|
| 233 |
+
# is_thinking 状态切换
|
| 234 |
+
if not is_thinking and current_is_thinking:
|
| 235 |
+
content = f"<think>\n{content}"
|
| 236 |
+
elif is_thinking and not current_is_thinking:
|
| 237 |
+
content = f"\n</think>\n{content}"
|
| 238 |
+
thinking_finished = True
|
| 239 |
+
|
| 240 |
+
yield make_chunk(content)
|
| 241 |
+
chunk_index += 1
|
| 242 |
+
is_thinking = current_is_thinking
|
| 243 |
+
|
| 244 |
+
except (json.JSONDecodeError, UnicodeDecodeError) as e:
|
| 245 |
+
logger.warning(f"[Processor] 解析chunk失败: {e}")
|
| 246 |
+
continue
|
| 247 |
+
except Exception as e:
|
| 248 |
+
logger.warning(f"[Processor] 处理chunk出错: {e}")
|
| 249 |
+
continue
|
| 250 |
+
|
| 251 |
+
# 发送结束块
|
| 252 |
+
yield make_chunk("", "stop")
|
| 253 |
+
# 发送流结束标记
|
| 254 |
+
yield "data: [DONE]\n\n"
|
| 255 |
+
|
| 256 |
+
except Exception as e:
|
| 257 |
+
logger.error(f"[Processor] 流式处理严重错误: {e}")
|
| 258 |
+
yield make_chunk(f"处理错误: {e}", "error")
|
| 259 |
+
# 发送流结束标记
|
| 260 |
+
yield "data: [DONE]\n\n"
|
app/services/grok/statsig.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Grok 请求头管理模块"""
|
| 2 |
+
|
| 3 |
+
import uuid
|
| 4 |
+
from typing import Dict
|
| 5 |
+
|
| 6 |
+
from app.core.config import setting
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def get_dynamic_headers(pathname: str = "/rest/app-chat/conversations/new") -> Dict[str, str]:
|
| 10 |
+
"""获取请求头
|
| 11 |
+
|
| 12 |
+
Args:
|
| 13 |
+
pathname: 请求路径
|
| 14 |
+
|
| 15 |
+
Returns:
|
| 16 |
+
请求头字典
|
| 17 |
+
"""
|
| 18 |
+
# 获取配置的 x-statsig-id
|
| 19 |
+
statsig_id = setting.grok_config.get("x_statsig_id")
|
| 20 |
+
if not statsig_id:
|
| 21 |
+
raise ValueError("配置文件中未设置 x_statsig_id")
|
| 22 |
+
|
| 23 |
+
# 构建基础请求头
|
| 24 |
+
headers = {
|
| 25 |
+
"Accept": "*/*",
|
| 26 |
+
"Accept-Language": "zh-CN,zh;q=0.9",
|
| 27 |
+
"Accept-Encoding": "gzip, deflate, br, zstd",
|
| 28 |
+
"Content-Type": "application/json" if "upload-file" not in pathname else "text/plain;charset=UTF-8",
|
| 29 |
+
"Connection": "keep-alive",
|
| 30 |
+
"Origin": "https://grok.com",
|
| 31 |
+
"Priority": "u=1, i",
|
| 32 |
+
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36",
|
| 33 |
+
"Sec-Ch-Ua": '"Not(A:Brand";v="99", "Google Chrome";v="133", "Chromium";v="133"',
|
| 34 |
+
"Sec-Ch-Ua-Mobile": "?0",
|
| 35 |
+
"Sec-Ch-Ua-Platform": '"macOS"',
|
| 36 |
+
"Sec-Fetch-Dest": "empty",
|
| 37 |
+
"Sec-Fetch-Mode": "cors",
|
| 38 |
+
"Sec-Fetch-Site": "same-origin",
|
| 39 |
+
"Baggage": "sentry-public_key=b311e0f2690c81f25e2c4cf6d4f7ce1c",
|
| 40 |
+
"x-statsig-id": statsig_id,
|
| 41 |
+
"x-xai-request-id": str(uuid.uuid4())
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
return headers
|
app/services/grok/token.py
ADDED
|
@@ -0,0 +1,388 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Grok Token 管理器模块"""
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import time
|
| 5 |
+
import asyncio
|
| 6 |
+
import aiofiles
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from curl_cffi.requests import AsyncSession
|
| 9 |
+
from typing import Dict, Any, Optional, Tuple
|
| 10 |
+
|
| 11 |
+
from app.models.grok_models import TokenType, Models
|
| 12 |
+
from app.core.exception import GrokApiException
|
| 13 |
+
from app.core.logger import logger
|
| 14 |
+
from app.core.config import setting
|
| 15 |
+
from app.services.grok.statsig import get_dynamic_headers
|
| 16 |
+
|
| 17 |
+
# 常量定义
|
| 18 |
+
RATE_LIMIT_ENDPOINT = "https://grok.com/rest/rate-limits"
|
| 19 |
+
REQUEST_TIMEOUT = 30
|
| 20 |
+
IMPERSONATE_BROWSER = "chrome133a"
|
| 21 |
+
MAX_FAILURE_COUNT = 3
|
| 22 |
+
TOKEN_INVALID_CODE = 401 # SSO Token失效
|
| 23 |
+
STATSIG_INVALID_CODE = 403 # x-statsig-id失效
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class GrokTokenManager:
|
| 27 |
+
"""
|
| 28 |
+
Grok Token管理器
|
| 29 |
+
|
| 30 |
+
单例模式的Token管理器,负责:
|
| 31 |
+
- Token文件的读写操作
|
| 32 |
+
- Token负载均衡
|
| 33 |
+
- Token状态管理
|
| 34 |
+
- 支持普通Token和Super Token
|
| 35 |
+
"""
|
| 36 |
+
|
| 37 |
+
_instance: Optional['GrokTokenManager'] = None
|
| 38 |
+
_lock = asyncio.Lock()
|
| 39 |
+
|
| 40 |
+
def __new__(cls) -> 'GrokTokenManager':
|
| 41 |
+
"""单例模式实现"""
|
| 42 |
+
if cls._instance is None:
|
| 43 |
+
cls._instance = super().__new__(cls)
|
| 44 |
+
return cls._instance
|
| 45 |
+
|
| 46 |
+
def __init__(self):
|
| 47 |
+
"""初始化Token管理器"""
|
| 48 |
+
if hasattr(self, '_initialized'):
|
| 49 |
+
return
|
| 50 |
+
|
| 51 |
+
self.token_file = Path(__file__).parents[3] / "data" / "token.json"
|
| 52 |
+
self._file_lock = asyncio.Lock()
|
| 53 |
+
self.token_file.parent.mkdir(parents=True, exist_ok=True)
|
| 54 |
+
|
| 55 |
+
# 同步加载初始数据
|
| 56 |
+
self._load_data()
|
| 57 |
+
self._initialized = True
|
| 58 |
+
|
| 59 |
+
logger.debug(f"[Token] 管理器初始化完成,文件: {self.token_file}")
|
| 60 |
+
|
| 61 |
+
def _load_data(self) -> None:
|
| 62 |
+
"""同步加载Token数据(仅用于初始化)"""
|
| 63 |
+
default_data = {
|
| 64 |
+
TokenType.NORMAL.value: {},
|
| 65 |
+
TokenType.SUPER.value: {}
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
try:
|
| 69 |
+
if self.token_file.exists():
|
| 70 |
+
with open(self.token_file, "r", encoding="utf-8") as f:
|
| 71 |
+
self.token_data = json.load(f)
|
| 72 |
+
else:
|
| 73 |
+
self.token_data = default_data
|
| 74 |
+
logger.debug("[Token] 创建新的Token数据文件")
|
| 75 |
+
except (json.JSONDecodeError, IOError) as e:
|
| 76 |
+
logger.error(f"[Token] 加载Token数据失败: {str(e)}")
|
| 77 |
+
self.token_data = default_data
|
| 78 |
+
|
| 79 |
+
async def _save_data(self) -> None:
|
| 80 |
+
"""异步保存Token数据到文件"""
|
| 81 |
+
try:
|
| 82 |
+
async with self._file_lock:
|
| 83 |
+
async with aiofiles.open(self.token_file, "w", encoding="utf-8") as f:
|
| 84 |
+
await f.write(json.dumps(self.token_data, indent=2, ensure_ascii=False))
|
| 85 |
+
except IOError as e:
|
| 86 |
+
logger.error(f"[Token] 保存Token数据失败: {str(e)}")
|
| 87 |
+
raise GrokApiException(
|
| 88 |
+
f"Token数据保存失败: {str(e)}",
|
| 89 |
+
"TOKEN_SAVE_ERROR",
|
| 90 |
+
{"file_path": str(self.token_file)}
|
| 91 |
+
)
|
| 92 |
+
|
| 93 |
+
@staticmethod
|
| 94 |
+
def _extract_sso(auth_token: str) -> Optional[str]:
|
| 95 |
+
"""从认证令牌中提取SSO值"""
|
| 96 |
+
if "sso=" in auth_token:
|
| 97 |
+
return auth_token.split("sso=")[1].split(";")[0]
|
| 98 |
+
logger.warning("[Token] 无法从认证令牌中提取SSO值")
|
| 99 |
+
return None
|
| 100 |
+
|
| 101 |
+
def _find_token(self, sso_value: str) -> Tuple[Optional[str], Optional[Dict[str, Any]]]:
|
| 102 |
+
"""查找Token数据,返回(token_type, token_data)"""
|
| 103 |
+
for token_type in [TokenType.NORMAL.value, TokenType.SUPER.value]:
|
| 104 |
+
if sso_value in self.token_data[token_type]:
|
| 105 |
+
return token_type, self.token_data[token_type][sso_value]
|
| 106 |
+
return None, None
|
| 107 |
+
|
| 108 |
+
async def add_token(self, tokens: list[str], token_type: TokenType) -> None:
|
| 109 |
+
"""添加Token到管理器"""
|
| 110 |
+
if not tokens:
|
| 111 |
+
logger.debug("[Token] 尝试添加空的Token列表")
|
| 112 |
+
return
|
| 113 |
+
|
| 114 |
+
added_count = 0
|
| 115 |
+
for token in tokens:
|
| 116 |
+
if not token or not token.strip():
|
| 117 |
+
logger.debug("[Token] 跳过空的Token")
|
| 118 |
+
continue
|
| 119 |
+
|
| 120 |
+
self.token_data[token_type.value][token] = {
|
| 121 |
+
"createdTime": int(time.time() * 1000),
|
| 122 |
+
"remainingQueries": -1,
|
| 123 |
+
"heavyremainingQueries": -1,
|
| 124 |
+
"status": "active",
|
| 125 |
+
"failedCount": 0,
|
| 126 |
+
"lastFailureTime": None,
|
| 127 |
+
"lastFailureReason": None
|
| 128 |
+
}
|
| 129 |
+
added_count += 1
|
| 130 |
+
|
| 131 |
+
await self._save_data()
|
| 132 |
+
logger.info(f"[Token] 成功添加 {added_count} 个 {token_type.value} Token")
|
| 133 |
+
|
| 134 |
+
async def delete_token(self, tokens: list[str], token_type: TokenType) -> None:
|
| 135 |
+
"""删除指定的Token"""
|
| 136 |
+
if not tokens:
|
| 137 |
+
logger.debug("[Token] 尝试删除空的Token列表")
|
| 138 |
+
return
|
| 139 |
+
|
| 140 |
+
deleted_count = 0
|
| 141 |
+
for token in tokens:
|
| 142 |
+
if token in self.token_data[token_type.value]:
|
| 143 |
+
del self.token_data[token_type.value][token]
|
| 144 |
+
deleted_count += 1
|
| 145 |
+
else:
|
| 146 |
+
logger.debug(f"[Token] Token不存在: {token[:10]}...")
|
| 147 |
+
|
| 148 |
+
await self._save_data()
|
| 149 |
+
logger.info(f"[Token] 成功删除 {deleted_count} 个 {token_type.value} Token")
|
| 150 |
+
|
| 151 |
+
def get_tokens(self) -> Dict[str, Any]:
|
| 152 |
+
"""获取所有Token数据"""
|
| 153 |
+
return self.token_data.copy()
|
| 154 |
+
|
| 155 |
+
def get_token(self, model: str) -> str:
|
| 156 |
+
"""获取指定模型的Token"""
|
| 157 |
+
jwt_token = self.select_token(model)
|
| 158 |
+
return f"sso-rw={jwt_token};sso={jwt_token}"
|
| 159 |
+
|
| 160 |
+
def select_token(self, model: str) -> str:
|
| 161 |
+
"""根据模型类型和剩余次数选择最优Token"""
|
| 162 |
+
def select_best_token(tokens_dict: Dict[str, Any]) -> Tuple[Optional[str], Optional[int]]:
|
| 163 |
+
"""从 token 字典中选择最佳 token"""
|
| 164 |
+
unused_tokens = [] # remaining = -1 的 token
|
| 165 |
+
used_tokens = [] # remaining > 0 的 token
|
| 166 |
+
|
| 167 |
+
for token_key, token_data in tokens_dict.items():
|
| 168 |
+
# 跳过已失效的Token
|
| 169 |
+
if token_data.get("status") == "expired":
|
| 170 |
+
continue
|
| 171 |
+
|
| 172 |
+
remaining = int(token_data.get(remaining_field, -1))
|
| 173 |
+
|
| 174 |
+
# 跳过已限流的 token
|
| 175 |
+
if remaining == 0:
|
| 176 |
+
continue
|
| 177 |
+
|
| 178 |
+
# 分类存储
|
| 179 |
+
if remaining == -1:
|
| 180 |
+
unused_tokens.append(token_key)
|
| 181 |
+
elif remaining > 0:
|
| 182 |
+
used_tokens.append((token_key, remaining))
|
| 183 |
+
|
| 184 |
+
# 优先返回尚未使用的 token
|
| 185 |
+
if unused_tokens:
|
| 186 |
+
return unused_tokens[0], -1
|
| 187 |
+
|
| 188 |
+
# 否则返回次数最多的 token
|
| 189 |
+
if used_tokens:
|
| 190 |
+
used_tokens.sort(key=lambda x: x[1], reverse=True)
|
| 191 |
+
return used_tokens[0][0], used_tokens[0][1]
|
| 192 |
+
|
| 193 |
+
return None, None
|
| 194 |
+
|
| 195 |
+
max_token_key = None
|
| 196 |
+
max_remaining = None
|
| 197 |
+
|
| 198 |
+
# 深拷贝
|
| 199 |
+
token_data_snapshot = {
|
| 200 |
+
TokenType.NORMAL.value: self.token_data[TokenType.NORMAL.value].copy(),
|
| 201 |
+
TokenType.SUPER.value: self.token_data[TokenType.SUPER.value].copy()
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
if model == "grok-4-heavy":
|
| 205 |
+
# grok-4-heavy 只能使用Super Token + heavy remaining queries
|
| 206 |
+
remaining_field = "heavyremainingQueries"
|
| 207 |
+
max_token_key, max_remaining = select_best_token(token_data_snapshot[TokenType.SUPER.value])
|
| 208 |
+
else:
|
| 209 |
+
# 其他模型使用 remaining Queries
|
| 210 |
+
remaining_field = "remainingQueries"
|
| 211 |
+
|
| 212 |
+
# 优先使用普通Token
|
| 213 |
+
max_token_key, max_remaining = select_best_token(token_data_snapshot[TokenType.NORMAL.value])
|
| 214 |
+
|
| 215 |
+
# 如果普通Token没有可用的,尝试使用Super Token
|
| 216 |
+
if max_token_key is None:
|
| 217 |
+
max_token_key, max_remaining = select_best_token(token_data_snapshot[TokenType.SUPER.value])
|
| 218 |
+
|
| 219 |
+
if max_token_key is None:
|
| 220 |
+
raise GrokApiException(
|
| 221 |
+
f"没有可用Token用于模型 {model}",
|
| 222 |
+
"NO_AVAILABLE_TOKEN",
|
| 223 |
+
{
|
| 224 |
+
"model": model,
|
| 225 |
+
"normal_count": len(token_data_snapshot[TokenType.NORMAL.value]),
|
| 226 |
+
"super_count": len(token_data_snapshot[TokenType.SUPER.value])
|
| 227 |
+
}
|
| 228 |
+
)
|
| 229 |
+
|
| 230 |
+
status_text = "未使用" if max_remaining == -1 else f"剩余{max_remaining}次"
|
| 231 |
+
logger.debug(f"[Token] 为模型 {model} 选择Token ({status_text})")
|
| 232 |
+
return max_token_key
|
| 233 |
+
|
| 234 |
+
async def check_limits(self, auth_token: str, model: str) -> Optional[Dict[str, Any]]:
|
| 235 |
+
"""检查并更新模型速率限制"""
|
| 236 |
+
try:
|
| 237 |
+
rate_limit_model_name = Models.to_rate_limit(model)
|
| 238 |
+
logger.debug(f"[Token] 检查模型 {model} (接口模型: {rate_limit_model_name}) 的速率限制")
|
| 239 |
+
|
| 240 |
+
# 准备请求
|
| 241 |
+
payload = {"requestKind": "DEFAULT", "modelName": rate_limit_model_name}
|
| 242 |
+
cf_clearance = setting.grok_config.get("cf_clearance", "")
|
| 243 |
+
cookie = f"{auth_token};{cf_clearance}" if cf_clearance else auth_token
|
| 244 |
+
|
| 245 |
+
headers = get_dynamic_headers("/rest/rate-limits")
|
| 246 |
+
headers["Cookie"] = cookie
|
| 247 |
+
|
| 248 |
+
# 获取代理配置
|
| 249 |
+
proxy_url = setting.grok_config.get("proxy_url", "")
|
| 250 |
+
proxies = {"http": proxy_url, "https": proxy_url} if proxy_url else None
|
| 251 |
+
|
| 252 |
+
# 发送异步请求
|
| 253 |
+
async with AsyncSession() as session:
|
| 254 |
+
response = await session.post(
|
| 255 |
+
RATE_LIMIT_ENDPOINT,
|
| 256 |
+
headers=headers,
|
| 257 |
+
json=payload,
|
| 258 |
+
impersonate=IMPERSONATE_BROWSER,
|
| 259 |
+
timeout=REQUEST_TIMEOUT,
|
| 260 |
+
proxies=proxies
|
| 261 |
+
)
|
| 262 |
+
|
| 263 |
+
if response.status_code == 200:
|
| 264 |
+
rate_limit_data = response.json()
|
| 265 |
+
logger.debug(f"[Token] 成功获取速率限制信息")
|
| 266 |
+
|
| 267 |
+
# 保存速率限制信息
|
| 268 |
+
sso_value = self._extract_sso(auth_token)
|
| 269 |
+
if sso_value:
|
| 270 |
+
if model == "grok-4-heavy":
|
| 271 |
+
await self.update_limits(sso_value, normal=None, heavy=rate_limit_data.get("remainingQueries", -1))
|
| 272 |
+
logger.info(f"[Token] 已更新限制: sso={sso_value[:10]}..., heavy={rate_limit_data.get('remainingQueries', -1)}")
|
| 273 |
+
else:
|
| 274 |
+
await self.update_limits(sso_value, normal=rate_limit_data.get("remainingTokens", -1), heavy=None)
|
| 275 |
+
logger.info(f"[Token] 已更新限制: sso={sso_value[:10]}..., 通用={rate_limit_data.get('remainingTokens', -1)}")
|
| 276 |
+
|
| 277 |
+
return rate_limit_data
|
| 278 |
+
else:
|
| 279 |
+
logger.warning(f"[Token] 获取速率限制失败,状态码: {response.status_code}")
|
| 280 |
+
return None
|
| 281 |
+
|
| 282 |
+
except Exception as e:
|
| 283 |
+
logger.error(f"[Token] 检查速率限制时发生错误: {str(e)}")
|
| 284 |
+
return None
|
| 285 |
+
|
| 286 |
+
async def update_limits(self, sso_value: str, normal: Optional[int] = None, heavy: Optional[int] = None) -> None:
|
| 287 |
+
"""更新Token限制信息"""
|
| 288 |
+
try:
|
| 289 |
+
for token_type in [TokenType.NORMAL.value, TokenType.SUPER.value]:
|
| 290 |
+
if sso_value in self.token_data[token_type]:
|
| 291 |
+
if normal is not None:
|
| 292 |
+
self.token_data[token_type][sso_value]["remainingQueries"] = normal
|
| 293 |
+
if heavy is not None:
|
| 294 |
+
self.token_data[token_type][sso_value]["heavyremainingQueries"] = heavy
|
| 295 |
+
|
| 296 |
+
await self._save_data()
|
| 297 |
+
logger.info(f"[Token] 已更新Token {sso_value[:10]}... 的限制信息")
|
| 298 |
+
return
|
| 299 |
+
|
| 300 |
+
logger.warning(f"[Token] 未找到SSO值为 {sso_value[:10]}... 的Token")
|
| 301 |
+
|
| 302 |
+
except Exception as e:
|
| 303 |
+
logger.error(f"[Token] 更新Token限制时发生错误: {str(e)}")
|
| 304 |
+
|
| 305 |
+
async def record_failure(self, auth_token: str, status_code: int, error_message: str) -> None:
|
| 306 |
+
"""记录Token失败信息
|
| 307 |
+
|
| 308 |
+
错误码说明:
|
| 309 |
+
- 401: SSO Token失效,会标记Token为expired
|
| 310 |
+
- 403: x-statsig-id失效,不影响Token状态
|
| 311 |
+
|
| 312 |
+
Args:
|
| 313 |
+
auth_token: 完整的认证Token (格式: sso-rw=xxx;sso=xxx)
|
| 314 |
+
status_code: HTTP状态码
|
| 315 |
+
error_message: 错误信息
|
| 316 |
+
"""
|
| 317 |
+
try:
|
| 318 |
+
# 403错误是x-statsig-id失效,不是Token问题
|
| 319 |
+
if status_code == STATSIG_INVALID_CODE:
|
| 320 |
+
logger.warning(f"[Token] x-statsig-id失效 (403),需要更新配置文件中的x_statsig_id")
|
| 321 |
+
return
|
| 322 |
+
|
| 323 |
+
sso_value = self._extract_sso(auth_token)
|
| 324 |
+
if not sso_value:
|
| 325 |
+
return
|
| 326 |
+
|
| 327 |
+
_, token_data = self._find_token(sso_value)
|
| 328 |
+
if not token_data:
|
| 329 |
+
logger.warning(f"[Token] 未找到SSO值为 {sso_value[:10]}... 的Token")
|
| 330 |
+
return
|
| 331 |
+
|
| 332 |
+
# 更新失败计数
|
| 333 |
+
token_data["failedCount"] = token_data.get("failedCount", 0) + 1
|
| 334 |
+
token_data["lastFailureTime"] = int(time.time() * 1000)
|
| 335 |
+
token_data["lastFailureReason"] = f"{status_code}: {error_message}"
|
| 336 |
+
|
| 337 |
+
logger.warning(
|
| 338 |
+
f"[Token] Token {sso_value[:10]}... 失败 (状态码: {status_code}), "
|
| 339 |
+
f"失败次数: {token_data['failedCount']}/{MAX_FAILURE_COUNT}, "
|
| 340 |
+
f"原因: {error_message}"
|
| 341 |
+
)
|
| 342 |
+
|
| 343 |
+
# 只有401错误(SSO Token失效)且失败次数达到上限时,标记为失效
|
| 344 |
+
if status_code == TOKEN_INVALID_CODE and token_data["failedCount"] >= MAX_FAILURE_COUNT:
|
| 345 |
+
token_data["status"] = "expired"
|
| 346 |
+
logger.error(
|
| 347 |
+
f"[Token] SSO Token {sso_value[:10]}... 已被标记为失效 "
|
| 348 |
+
f"(连续401错误{token_data['failedCount']}次)"
|
| 349 |
+
)
|
| 350 |
+
|
| 351 |
+
await self._save_data()
|
| 352 |
+
|
| 353 |
+
except Exception as e:
|
| 354 |
+
logger.error(f"[Token] 记录Token失败信息时发生错误: {str(e)}")
|
| 355 |
+
|
| 356 |
+
async def reset_failure(self, auth_token: str) -> None:
|
| 357 |
+
"""重置Token失败计数
|
| 358 |
+
|
| 359 |
+
当Token成功完成请求时调用此方法,用于清除失败记录。
|
| 360 |
+
|
| 361 |
+
Args:
|
| 362 |
+
auth_token: 完整的认证Token (格式: sso-rw=xxx;sso=xxx)
|
| 363 |
+
"""
|
| 364 |
+
try:
|
| 365 |
+
sso_value = self._extract_sso(auth_token)
|
| 366 |
+
if not sso_value:
|
| 367 |
+
return
|
| 368 |
+
|
| 369 |
+
_, token_data = self._find_token(sso_value)
|
| 370 |
+
if not token_data:
|
| 371 |
+
logger.warning(f"[Token] 未找到SSO值为 {sso_value[:10]}... 的Token")
|
| 372 |
+
return
|
| 373 |
+
|
| 374 |
+
# 只有在有失败记录时才重置并保存
|
| 375 |
+
if token_data.get("failedCount", 0) > 0:
|
| 376 |
+
token_data["failedCount"] = 0
|
| 377 |
+
token_data["lastFailureTime"] = None
|
| 378 |
+
token_data["lastFailureReason"] = None
|
| 379 |
+
|
| 380 |
+
await self._save_data()
|
| 381 |
+
logger.info(f"[Token] Token {sso_value[:10]}... 失败计数已重置")
|
| 382 |
+
|
| 383 |
+
except Exception as e:
|
| 384 |
+
logger.error(f"[Token] 重置Token失败计数时发生错误: {str(e)}")
|
| 385 |
+
|
| 386 |
+
|
| 387 |
+
# 全局Token管理器实例
|
| 388 |
+
token_manager = GrokTokenManager()
|
app/services/grok/upload.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""图片上传管理器"""
|
| 2 |
+
|
| 3 |
+
import base64
|
| 4 |
+
import re
|
| 5 |
+
from typing import Tuple, Optional
|
| 6 |
+
from urllib.parse import urlparse
|
| 7 |
+
|
| 8 |
+
from curl_cffi.requests import AsyncSession
|
| 9 |
+
|
| 10 |
+
from app.services.grok.statsig import get_dynamic_headers
|
| 11 |
+
from app.core.exception import GrokApiException
|
| 12 |
+
from app.core.config import setting
|
| 13 |
+
from app.core.logger import logger
|
| 14 |
+
|
| 15 |
+
# 常量定义
|
| 16 |
+
UPLOAD_ENDPOINT = "https://grok.com/rest/app-chat/upload-file"
|
| 17 |
+
REQUEST_TIMEOUT = 30
|
| 18 |
+
IMPERSONATE_BROWSER = "chrome133a"
|
| 19 |
+
DEFAULT_MIME_TYPE = "image/jpeg"
|
| 20 |
+
DEFAULT_EXTENSION = "jpg"
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class ImageUploadManager:
|
| 24 |
+
"""
|
| 25 |
+
Grok图片上传管理器
|
| 26 |
+
|
| 27 |
+
提供图片上传功能,支持:
|
| 28 |
+
- Base64格式图片上传
|
| 29 |
+
- URL图片下载并上传
|
| 30 |
+
- 多种图片格式支持
|
| 31 |
+
"""
|
| 32 |
+
|
| 33 |
+
@staticmethod
|
| 34 |
+
async def upload(image_input: str, auth_token: str) -> str:
|
| 35 |
+
"""上传图片到Grok,支持Base64或URL"""
|
| 36 |
+
try:
|
| 37 |
+
if ImageUploadManager._is_url(image_input):
|
| 38 |
+
# 下载 URL 图片
|
| 39 |
+
image_buffer, mime_type = await ImageUploadManager._download(image_input)
|
| 40 |
+
|
| 41 |
+
# 获取图片信息
|
| 42 |
+
file_name, _ = ImageUploadManager._get_info("", mime_type)
|
| 43 |
+
|
| 44 |
+
else:
|
| 45 |
+
# 处理 base64 数据
|
| 46 |
+
image_buffer = image_input.split(",")[1] if "data:image" in image_input else image_input
|
| 47 |
+
|
| 48 |
+
# 获取图片信息
|
| 49 |
+
file_name, mime_type = ImageUploadManager._get_info(image_input)
|
| 50 |
+
|
| 51 |
+
# 构建上传数据
|
| 52 |
+
upload_data = {
|
| 53 |
+
"fileName": file_name,
|
| 54 |
+
"fileMimeType": mime_type,
|
| 55 |
+
"content": image_buffer,
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
# 获取认证令牌
|
| 59 |
+
if not auth_token:
|
| 60 |
+
raise GrokApiException("认证令牌缺失或为空", "NO_AUTH_TOKEN")
|
| 61 |
+
|
| 62 |
+
cf_clearance = setting.grok_config.get("cf_clearance", "")
|
| 63 |
+
cookie = f"{auth_token};{cf_clearance}" if cf_clearance else auth_token
|
| 64 |
+
proxy_url = setting.grok_config.get("proxy_url", "")
|
| 65 |
+
proxies = {"http": proxy_url, "https": proxy_url} if proxy_url else None
|
| 66 |
+
|
| 67 |
+
# 发送异步请求
|
| 68 |
+
async with AsyncSession() as session:
|
| 69 |
+
response = await session.post(
|
| 70 |
+
UPLOAD_ENDPOINT,
|
| 71 |
+
headers={
|
| 72 |
+
**get_dynamic_headers("/rest/app-chat/upload-file"),
|
| 73 |
+
"Cookie": cookie,
|
| 74 |
+
},
|
| 75 |
+
json=upload_data,
|
| 76 |
+
impersonate=IMPERSONATE_BROWSER,
|
| 77 |
+
timeout=REQUEST_TIMEOUT,
|
| 78 |
+
proxies=proxies,
|
| 79 |
+
)
|
| 80 |
+
|
| 81 |
+
# 检查响应
|
| 82 |
+
if response.status_code == 200:
|
| 83 |
+
result = response.json()
|
| 84 |
+
file_id = result.get("fileMetadataId", "")
|
| 85 |
+
logger.debug(f"[Upload] 图片上传成功,文件ID: {file_id}")
|
| 86 |
+
return file_id
|
| 87 |
+
|
| 88 |
+
return ""
|
| 89 |
+
|
| 90 |
+
except Exception as e:
|
| 91 |
+
logger.warning(f"[Upload] 上传图片失败: {e}")
|
| 92 |
+
return ""
|
| 93 |
+
|
| 94 |
+
@staticmethod
|
| 95 |
+
def _is_url(image_input: str) -> bool:
|
| 96 |
+
"""检查输入是否为有效的URL"""
|
| 97 |
+
try:
|
| 98 |
+
result = urlparse(image_input)
|
| 99 |
+
return all([result.scheme, result.netloc]) and result.scheme in ['http', 'https']
|
| 100 |
+
except Exception as e:
|
| 101 |
+
logger.warning(f"[Upload] URL解析失败: {e}")
|
| 102 |
+
return False
|
| 103 |
+
|
| 104 |
+
@staticmethod
|
| 105 |
+
async def _download(url: str) -> Tuple[str, str]:
|
| 106 |
+
"""下载图片并转换为Base64"""
|
| 107 |
+
try:
|
| 108 |
+
async with AsyncSession() as session:
|
| 109 |
+
response = await session.get(url, timeout=5)
|
| 110 |
+
response.raise_for_status()
|
| 111 |
+
|
| 112 |
+
# 获取内容类型
|
| 113 |
+
content_type = response.headers.get('content-type', DEFAULT_MIME_TYPE)
|
| 114 |
+
if not content_type.startswith('image/'):
|
| 115 |
+
content_type = DEFAULT_MIME_TYPE
|
| 116 |
+
|
| 117 |
+
# 转换为 Base64
|
| 118 |
+
image_base64 = base64.b64encode(response.content).decode('utf-8')
|
| 119 |
+
return image_base64, content_type
|
| 120 |
+
except Exception as e:
|
| 121 |
+
logger.warning(f"[Upload] 下载图片失败: {e}")
|
| 122 |
+
return "", ""
|
| 123 |
+
|
| 124 |
+
@staticmethod
|
| 125 |
+
def _get_info(image_data: str, mime_type: Optional[str] = None) -> Tuple[str, str]:
|
| 126 |
+
"""获取图片文件名和MIME类型"""
|
| 127 |
+
# mime_type 有值,直接使用
|
| 128 |
+
if mime_type:
|
| 129 |
+
extension = mime_type.split("/")[1] if "/" in mime_type else DEFAULT_EXTENSION
|
| 130 |
+
file_name = f"image.{extension}"
|
| 131 |
+
return file_name, mime_type
|
| 132 |
+
|
| 133 |
+
# mime_type 没有值,使用默认值
|
| 134 |
+
mime_type = DEFAULT_MIME_TYPE
|
| 135 |
+
extension = DEFAULT_EXTENSION
|
| 136 |
+
|
| 137 |
+
# 从 Base64 数据中提取 MIME 类型
|
| 138 |
+
if "data:image" in image_data:
|
| 139 |
+
match = re.search(r"data:([a-zA-Z0-9]+/[a-zA-Z0-9-.+]+);base64,", image_data)
|
| 140 |
+
if match:
|
| 141 |
+
mime_type = match.group(1)
|
| 142 |
+
extension = mime_type.split("/")[1]
|
| 143 |
+
|
| 144 |
+
file_name = f"image.{extension}"
|
| 145 |
+
return file_name, mime_type
|
app/template/admin.html
ADDED
|
@@ -0,0 +1,493 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="zh-CN" class="h-full">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>管理控制台 - Grok2API</title>
|
| 7 |
+
<script src="https://cdn.tailwindcss.com"></script>
|
| 8 |
+
<style>
|
| 9 |
+
@keyframes slide-up {
|
| 10 |
+
from {
|
| 11 |
+
transform: translateY(100%);
|
| 12 |
+
opacity: 0;
|
| 13 |
+
}
|
| 14 |
+
to {
|
| 15 |
+
transform: translateY(0);
|
| 16 |
+
opacity: 1;
|
| 17 |
+
}
|
| 18 |
+
}
|
| 19 |
+
.animate-slide-up {
|
| 20 |
+
animation: slide-up 0.3s ease-out;
|
| 21 |
+
}
|
| 22 |
+
.tab-btn {
|
| 23 |
+
transition: all 0.2s ease;
|
| 24 |
+
}
|
| 25 |
+
[title] {
|
| 26 |
+
position: relative;
|
| 27 |
+
}
|
| 28 |
+
[title]:hover::after {
|
| 29 |
+
content: attr(title);
|
| 30 |
+
position: absolute;
|
| 31 |
+
bottom: 100%;
|
| 32 |
+
left: 50%;
|
| 33 |
+
transform: translateX(-50%) translateY(-4px);
|
| 34 |
+
background: hsl(0 0% 3.9%);
|
| 35 |
+
color: hsl(0 0% 98%);
|
| 36 |
+
padding: 4px 8px;
|
| 37 |
+
border-radius: 4px;
|
| 38 |
+
font-size: 11px;
|
| 39 |
+
white-space: nowrap;
|
| 40 |
+
z-index: 1000;
|
| 41 |
+
pointer-events: none;
|
| 42 |
+
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
| 43 |
+
}
|
| 44 |
+
[title]:hover::before {
|
| 45 |
+
content: '';
|
| 46 |
+
position: absolute;
|
| 47 |
+
bottom: 100%;
|
| 48 |
+
left: 50%;
|
| 49 |
+
transform: translateX(-50%);
|
| 50 |
+
border: 4px solid transparent;
|
| 51 |
+
border-top-color: hsl(0 0% 3.9%);
|
| 52 |
+
z-index: 1000;
|
| 53 |
+
}
|
| 54 |
+
</style>
|
| 55 |
+
<script>
|
| 56 |
+
tailwind.config = {
|
| 57 |
+
theme: {
|
| 58 |
+
extend: {
|
| 59 |
+
colors: {
|
| 60 |
+
border: "hsl(0 0% 89%)",
|
| 61 |
+
input: "hsl(0 0% 89%)",
|
| 62 |
+
ring: "hsl(0 0% 3.9%)",
|
| 63 |
+
background: "hsl(0 0% 100%)",
|
| 64 |
+
foreground: "hsl(0 0% 3.9%)",
|
| 65 |
+
primary: {
|
| 66 |
+
DEFAULT: "hsl(0 0% 9%)",
|
| 67 |
+
foreground: "hsl(0 0% 98%)",
|
| 68 |
+
},
|
| 69 |
+
secondary: {
|
| 70 |
+
DEFAULT: "hsl(0 0% 96.1%)",
|
| 71 |
+
foreground: "hsl(0 0% 9%)",
|
| 72 |
+
},
|
| 73 |
+
muted: {
|
| 74 |
+
DEFAULT: "hsl(0 0% 96.1%)",
|
| 75 |
+
foreground: "hsl(0 0% 45.1%)",
|
| 76 |
+
},
|
| 77 |
+
accent: {
|
| 78 |
+
DEFAULT: "hsl(0 0% 96.1%)",
|
| 79 |
+
foreground: "hsl(0 0% 9%)",
|
| 80 |
+
},
|
| 81 |
+
destructive: {
|
| 82 |
+
DEFAULT: "hsl(0 84.2% 60.2%)",
|
| 83 |
+
foreground: "hsl(0 0% 98%)",
|
| 84 |
+
},
|
| 85 |
+
},
|
| 86 |
+
}
|
| 87 |
+
}
|
| 88 |
+
}
|
| 89 |
+
</script>
|
| 90 |
+
</head>
|
| 91 |
+
<body class="h-full bg-background text-foreground antialiased">
|
| 92 |
+
<!-- 导航栏 -->
|
| 93 |
+
<header class="sticky top-0 z-50 w-full border-b border-border/40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
| 94 |
+
<div class="mx-auto flex h-14 max-w-7xl items-center px-6">
|
| 95 |
+
<div class="mr-4 flex">
|
| 96 |
+
<span class="font-bold text-lg">Grok2API</span>
|
| 97 |
+
</div>
|
| 98 |
+
<div class="flex flex-1 items-center justify-between space-x-2 md:justify-end">
|
| 99 |
+
<nav class="flex items-center space-x-2">
|
| 100 |
+
<button
|
| 101 |
+
onclick="logout()"
|
| 102 |
+
class="inline-flex items-center justify-center text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 hover:bg-accent hover:text-accent-foreground h-9 px-4 py-2"
|
| 103 |
+
>
|
| 104 |
+
退出
|
| 105 |
+
</button>
|
| 106 |
+
</nav>
|
| 107 |
+
</div>
|
| 108 |
+
</div>
|
| 109 |
+
</header>
|
| 110 |
+
|
| 111 |
+
<main class="mx-auto max-w-7xl px-6 py-6">
|
| 112 |
+
<!-- Tab 导航 -->
|
| 113 |
+
<div class="border-b border-border mb-6">
|
| 114 |
+
<nav class="flex space-x-8">
|
| 115 |
+
<button onclick="switchTab('tokens')" id="tabTokens" class="tab-btn border-b-2 border-primary text-sm font-medium py-3 px-1">Token 管理</button>
|
| 116 |
+
<button onclick="switchTab('settings')" id="tabSettings" class="tab-btn border-b-2 border-transparent text-sm font-medium py-3 px-1">Setting 配置</button>
|
| 117 |
+
</nav>
|
| 118 |
+
</div>
|
| 119 |
+
|
| 120 |
+
<!-- Token 管理面板 -->
|
| 121 |
+
<div id="panelTokens">
|
| 122 |
+
<!-- 统计卡片 -->
|
| 123 |
+
<div class="grid gap-4 grid-cols-2 md:grid-cols-4 mb-6">
|
| 124 |
+
<div class="rounded-lg border border-border bg-background p-4">
|
| 125 |
+
<p class="text-sm font-medium text-muted-foreground mb-2">Token 总数</p>
|
| 126 |
+
<h3 class="text-xl font-bold" id="statTotal">-</h3>
|
| 127 |
+
</div>
|
| 128 |
+
|
| 129 |
+
<div class="rounded-lg border border-border bg-background p-4">
|
| 130 |
+
<p class="text-sm font-medium text-muted-foreground mb-2">未使用</p>
|
| 131 |
+
<h3 class="text-xl font-bold text-gray-500" id="statUnused">-</h3>
|
| 132 |
+
</div>
|
| 133 |
+
|
| 134 |
+
<div class="rounded-lg border border-border bg-background p-4">
|
| 135 |
+
<p class="text-sm font-medium text-muted-foreground mb-2">限流中</p>
|
| 136 |
+
<h3 class="text-xl font-bold text-orange-600" id="statLimited">-</h3>
|
| 137 |
+
</div>
|
| 138 |
+
|
| 139 |
+
<div class="rounded-lg border border-border bg-background p-4">
|
| 140 |
+
<p class="text-sm font-medium text-muted-foreground mb-2">失效</p>
|
| 141 |
+
<h3 class="text-xl font-bold text-destructive" id="statExpired">-</h3>
|
| 142 |
+
</div>
|
| 143 |
+
|
| 144 |
+
<div class="rounded-lg border border-border bg-background p-4">
|
| 145 |
+
<p class="text-sm font-medium text-muted-foreground mb-2">正常</p>
|
| 146 |
+
<h3 class="text-xl font-bold text-green-600" id="statActive">-</h3>
|
| 147 |
+
</div>
|
| 148 |
+
|
| 149 |
+
<div class="rounded-lg border border-border bg-background p-4">
|
| 150 |
+
<p class="text-sm font-medium text-muted-foreground mb-2">总剩余</p>
|
| 151 |
+
<h3 class="text-xl font-bold" id="statTotalRemaining">-</h3>
|
| 152 |
+
</div>
|
| 153 |
+
|
| 154 |
+
<div class="rounded-lg border border-border bg-background p-4">
|
| 155 |
+
<p class="text-sm font-medium text-muted-foreground mb-2">普通剩余</p>
|
| 156 |
+
<h3 class="text-xl font-bold text-blue-600" id="statNormalRemaining">-</h3>
|
| 157 |
+
</div>
|
| 158 |
+
|
| 159 |
+
<div class="rounded-lg border border-border bg-background p-4">
|
| 160 |
+
<p class="text-sm font-medium text-muted-foreground mb-2">高级剩余</p>
|
| 161 |
+
<h3 class="text-xl font-bold text-purple-600" id="statHeavyRemaining">-</h3>
|
| 162 |
+
</div>
|
| 163 |
+
</div>
|
| 164 |
+
|
| 165 |
+
<!-- Token 列表 -->
|
| 166 |
+
<div class="rounded-lg border border-border bg-background">
|
| 167 |
+
<!-- 工具栏 -->
|
| 168 |
+
<div class="flex items-center justify-between gap-4 p-4 border-b border-border">
|
| 169 |
+
<div class="flex items-center gap-3 flex-1">
|
| 170 |
+
<div class="flex items-center gap-2">
|
| 171 |
+
<select id="filterType" onchange="filterTokens()" class="h-8 px-3 text-sm rounded-md border border-input bg-background focus:outline-none focus:ring-1 focus:ring-ring">
|
| 172 |
+
<option value="all">全部类型</option>
|
| 173 |
+
<option value="sso">SSO</option>
|
| 174 |
+
<option value="ssoSuper">SuperSSO</option>
|
| 175 |
+
</select>
|
| 176 |
+
<select id="filterStatus" onchange="filterTokens()" class="h-8 px-3 text-sm rounded-md border border-input bg-background focus:outline-none focus:ring-1 focus:ring-ring">
|
| 177 |
+
<option value="all">全部状态</option>
|
| 178 |
+
<option value="未使用">未使用</option>
|
| 179 |
+
<option value="限流中">限流中</option>
|
| 180 |
+
<option value="失效">失效</option>
|
| 181 |
+
<option value="正常">正常</option>
|
| 182 |
+
</select>
|
| 183 |
+
</div>
|
| 184 |
+
</div>
|
| 185 |
+
|
| 186 |
+
<div class="flex items-center gap-2">
|
| 187 |
+
<button
|
| 188 |
+
onclick="refreshTokens()"
|
| 189 |
+
class="inline-flex items-center justify-center rounded-md transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring hover:bg-accent hover:text-accent-foreground h-8 w-8"
|
| 190 |
+
title="刷新列表"
|
| 191 |
+
>
|
| 192 |
+
<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 193 |
+
<polyline points="23 4 23 10 17 10"/>
|
| 194 |
+
<polyline points="1 20 1 14 7 14"/>
|
| 195 |
+
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>
|
| 196 |
+
</svg>
|
| 197 |
+
</button>
|
| 198 |
+
<div id="batchActions" class="hidden items-center gap-2">
|
| 199 |
+
<button
|
| 200 |
+
onclick="exportSelected()"
|
| 201 |
+
class="inline-flex items-center justify-center rounded-md transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring hover:bg-accent hover:text-accent-foreground h-8 w-8"
|
| 202 |
+
title="导出选中项"
|
| 203 |
+
>
|
| 204 |
+
<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 205 |
+
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
| 206 |
+
<polyline points="7 10 12 15 17 10"/>
|
| 207 |
+
<line x1="12" y1="15" x2="12" y2="3"/>
|
| 208 |
+
</svg>
|
| 209 |
+
</button>
|
| 210 |
+
<button
|
| 211 |
+
onclick="batchDelete()"
|
| 212 |
+
class="inline-flex items-center justify-center rounded-md transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring hover:bg-destructive/10 hover:text-destructive h-8 w-8"
|
| 213 |
+
title="批量删除"
|
| 214 |
+
>
|
| 215 |
+
<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 216 |
+
<polyline points="3 6 5 6 21 6"/>
|
| 217 |
+
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
|
| 218 |
+
<line x1="10" y1="11" x2="10" y2="17"/>
|
| 219 |
+
<line x1="14" y1="11" x2="14" y2="17"/>
|
| 220 |
+
</svg>
|
| 221 |
+
</button>
|
| 222 |
+
</div>
|
| 223 |
+
<button
|
| 224 |
+
onclick="openAddModal()"
|
| 225 |
+
class="inline-flex items-center justify-center rounded-md transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring bg-primary text-primary-foreground hover:bg-primary/90 h-8 px-3"
|
| 226 |
+
title="添加 Token"
|
| 227 |
+
>
|
| 228 |
+
<svg class="h-4 w-4 mr-2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 229 |
+
<line x1="12" y1="5" x2="12" y2="19"/>
|
| 230 |
+
<line x1="5" y1="12" x2="19" y2="12"/>
|
| 231 |
+
</svg>
|
| 232 |
+
<span class="text-sm font-medium">新增</span>
|
| 233 |
+
</button>
|
| 234 |
+
</div>
|
| 235 |
+
</div>
|
| 236 |
+
|
| 237 |
+
<!-- 表格 -->
|
| 238 |
+
<div class="relative w-full overflow-auto">
|
| 239 |
+
<table class="w-full text-sm table-fixed">
|
| 240 |
+
<thead>
|
| 241 |
+
<tr class="border-b border-border">
|
| 242 |
+
<th class="h-10 px-3 text-left align-middle font-medium w-12">
|
| 243 |
+
<input
|
| 244 |
+
type="checkbox"
|
| 245 |
+
id="selectAll"
|
| 246 |
+
onchange="toggleSelectAll()"
|
| 247 |
+
class="h-3.5 w-3.5 rounded border border-input focus:ring-1 focus:ring-ring"
|
| 248 |
+
>
|
| 249 |
+
</th>
|
| 250 |
+
<th class="h-10 px-3 text-left align-middle text-sm font-medium text-muted-foreground w-72">Token</th>
|
| 251 |
+
<th class="h-10 px-3 text-left align-middle text-sm font-medium text-muted-foreground w-20">类型</th>
|
| 252 |
+
<th class="h-10 px-3 text-left align-middle text-sm font-medium text-muted-foreground w-20">状态</th>
|
| 253 |
+
<th class="h-10 px-3 text-left align-middle text-sm font-medium text-muted-foreground w-20">普通剩余</th>
|
| 254 |
+
<th class="h-10 px-3 text-left align-middle text-sm font-medium text-muted-foreground w-20">高级剩余</th>
|
| 255 |
+
<th class="h-10 px-3 text-left align-middle text-sm font-medium text-muted-foreground w-40">创建时间</th>
|
| 256 |
+
<th class="h-10 px-3 text-right align-middle text-sm font-medium text-muted-foreground w-20">操作</th>
|
| 257 |
+
</tr>
|
| 258 |
+
</thead>
|
| 259 |
+
<tbody id="tokenTableBody" class="divide-y divide-border">
|
| 260 |
+
<!-- 动态填充 -->
|
| 261 |
+
</tbody>
|
| 262 |
+
</table>
|
| 263 |
+
</div>
|
| 264 |
+
|
| 265 |
+
<div id="emptyState" class="hidden flex flex-col items-center justify-center py-12">
|
| 266 |
+
<svg class="h-10 w-10 text-muted-foreground/50 mb-3" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 267 |
+
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
| 268 |
+
<path d="M9 9h6v6H9z"/>
|
| 269 |
+
</svg>
|
| 270 |
+
<p class="text-sm text-muted-foreground">暂无数据</p>
|
| 271 |
+
</div>
|
| 272 |
+
</div>
|
| 273 |
+
</div>
|
| 274 |
+
|
| 275 |
+
<!-- 全局设置面板 -->
|
| 276 |
+
<div id="panelSettings" class="hidden">
|
| 277 |
+
<div class="grid gap-6 lg:grid-cols-2">
|
| 278 |
+
<!-- 全局配置 -->
|
| 279 |
+
<div class="rounded-lg border border-border bg-background p-6">
|
| 280 |
+
<h3 class="text-lg font-semibold mb-4">全局配置</h3>
|
| 281 |
+
<div class="space-y-4">
|
| 282 |
+
<div>
|
| 283 |
+
<label class="text-sm font-medium flex items-center gap-1 mb-2">
|
| 284 |
+
管理账户
|
| 285 |
+
<span class="inline-flex items-center justify-center w-3.5 h-3.5 rounded-full border border-muted-foreground text-muted-foreground cursor-help" style="font-size:10px;line-height:1" title="登录管理后台的用户名">?</span>
|
| 286 |
+
</label>
|
| 287 |
+
<input id="cfgAdminUser" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="admin">
|
| 288 |
+
</div>
|
| 289 |
+
<div>
|
| 290 |
+
<label class="text-sm font-medium flex items-center gap-1 mb-2">
|
| 291 |
+
管理密码
|
| 292 |
+
<span class="inline-flex items-center justify-center w-3.5 h-3.5 rounded-full border border-muted-foreground text-muted-foreground cursor-help" style="font-size:10px;line-height:1" title="登录管理后台的密码,留空表示不修改当前密码">?</span>
|
| 293 |
+
</label>
|
| 294 |
+
<input id="cfgAdminPass" type="password" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="留空则不修改">
|
| 295 |
+
</div>
|
| 296 |
+
<div>
|
| 297 |
+
<label class="text-sm font-medium flex items-center gap-1 mb-2">
|
| 298 |
+
日志级别
|
| 299 |
+
<span class="inline-flex items-center justify-center w-3.5 h-3.5 rounded-full border border-muted-foreground text-muted-foreground cursor-help" style="font-size:10px;line-height:1" title="日志详细程度。DEBUG:最详细 | INFO:一般信息 | WARNING:警告 | ERROR:仅错误">?</span>
|
| 300 |
+
</label>
|
| 301 |
+
<select id="cfgLogLevel" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm">
|
| 302 |
+
<option>DEBUG</option><option>INFO</option><option>WARNING</option><option>ERROR</option>
|
| 303 |
+
</select>
|
| 304 |
+
</div>
|
| 305 |
+
<div>
|
| 306 |
+
<label class="text-sm font-medium flex items-center gap-1 mb-2">
|
| 307 |
+
缓存上限 (MB)
|
| 308 |
+
<span class="inline-flex items-center justify-center w-3.5 h-3.5 rounded-full border border-muted-foreground text-muted-foreground cursor-help" style="font-size:10px;line-height:1" title="图片缓存目录的最大容量,超过后自动删除最旧的缓存文件">?</span>
|
| 309 |
+
</label>
|
| 310 |
+
<input id="cfgTempMaxSize" type="number" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="500">
|
| 311 |
+
</div>
|
| 312 |
+
<div>
|
| 313 |
+
<label class="text-sm font-medium flex items-center gap-1 mb-2">
|
| 314 |
+
缓存大小
|
| 315 |
+
<span class="inline-flex items-center justify-center w-3.5 h-3.5 rounded-full border border-muted-foreground text-muted-foreground cursor-help" style="font-size:10px;line-height:1" title="当前缓存目录的大小">?</span>
|
| 316 |
+
</label>
|
| 317 |
+
<div class="flex gap-2">
|
| 318 |
+
<input id="cacheSize" readonly class="flex h-9 flex-1 rounded-md border border-input bg-muted px-3 py-2 text-sm" placeholder="0 MB">
|
| 319 |
+
<button onclick="clearCache()" class="inline-flex items-center justify-center rounded-md text-sm font-medium bg-orange-600 text-white hover:bg-orange-700 h-9 px-3 transition-colors">
|
| 320 |
+
<svg class="h-4 w-4 mr-1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 321 |
+
<path d="M3 6h18"/>
|
| 322 |
+
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
|
| 323 |
+
<line x1="10" y1="11" x2="10" y2="17"/>
|
| 324 |
+
<line x1="14" y1="11" x2="14" y2="17"/>
|
| 325 |
+
</svg>
|
| 326 |
+
清除
|
| 327 |
+
</button>
|
| 328 |
+
</div>
|
| 329 |
+
</div>
|
| 330 |
+
<div>
|
| 331 |
+
<label class="text-sm font-medium flex items-center gap-1 mb-2">
|
| 332 |
+
服务网址
|
| 333 |
+
<span class="inline-flex items-center justify-center w-3.5 h-3.5 rounded-full border border-muted-foreground text-muted-foreground cursor-help" style="font-size:10px;line-height:1" title="用于生成图片链接的服务地址。建议使用域名以保护服务器IP,如无图片需求可留空">?</span>
|
| 334 |
+
</label>
|
| 335 |
+
<input id="cfgBaseUrl" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="http://localhost:8000">
|
| 336 |
+
</div>
|
| 337 |
+
</div>
|
| 338 |
+
<div class="mt-6 flex justify-end">
|
| 339 |
+
<button onclick="saveGlobalSettings()" class="inline-flex items-center justify-center rounded-md text-sm font-medium bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-6 transition-colors">保存配置</button>
|
| 340 |
+
</div>
|
| 341 |
+
</div>
|
| 342 |
+
|
| 343 |
+
<!-- Grok 配置 -->
|
| 344 |
+
<div class="rounded-lg border border-border bg-background p-6">
|
| 345 |
+
<h3 class="text-lg font-semibold mb-4">Grok 配置</h3>
|
| 346 |
+
<div class="space-y-4">
|
| 347 |
+
<div>
|
| 348 |
+
<label class="text-sm font-medium flex items-center gap-1 mb-2">
|
| 349 |
+
API Key
|
| 350 |
+
<span class="inline-flex items-center justify-center w-3.5 h-3.5 rounded-full border border-muted-foreground text-muted-foreground cursor-help" style="font-size:10px;line-height:1" title="API访问密钥。建议填写以提高安全性,可留空">?</span>
|
| 351 |
+
</label>
|
| 352 |
+
<input id="cfgApiKey" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="">
|
| 353 |
+
</div>
|
| 354 |
+
<div>
|
| 355 |
+
<label class="text-sm font-medium flex items-center gap-1 mb-2">
|
| 356 |
+
Proxy Url
|
| 357 |
+
<span class="inline-flex items-center justify-center w-3.5 h-3.5 rounded-full border border-muted-foreground text-muted-foreground cursor-help" style="font-size:10px;line-height:1" title="HTTP代理服务器地址,用于访问Grok服务,可留空">?</span>
|
| 358 |
+
</label>
|
| 359 |
+
<input id="cfgProxyUrl" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="http://127.0.0.1:7890">
|
| 360 |
+
</div>
|
| 361 |
+
<div>
|
| 362 |
+
<label class="text-sm font-medium flex items-center gap-1 mb-2">
|
| 363 |
+
CF Clearance
|
| 364 |
+
<span class="inline-flex items-center justify-center w-3.5 h-3.5 rounded-full border border-muted-foreground text-muted-foreground cursor-help" style="font-size:10px;line-height:1" title="Cloudflare安全令牌,用于绕过人机验证,可留空">?</span>
|
| 365 |
+
</label>
|
| 366 |
+
<input id="cfgCfClearance" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="">
|
| 367 |
+
</div>
|
| 368 |
+
<div>
|
| 369 |
+
<label class="text-sm font-medium flex items-center gap-1 mb-2">
|
| 370 |
+
X Statsig ID
|
| 371 |
+
<span class="inline-flex items-center justify-center w-3.5 h-3.5 rounded-full border border-muted-foreground text-muted-foreground cursor-help" style="font-size:10px;line-height:1" title="Grok反机器人检测的唯一标识符,必填">?</span>
|
| 372 |
+
</label>
|
| 373 |
+
<input id="cfgStatsigId" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="">
|
| 374 |
+
</div>
|
| 375 |
+
<div>
|
| 376 |
+
<label class="text-sm font-medium flex items-center gap-1 mb-2">
|
| 377 |
+
Filtered_tags
|
| 378 |
+
<span class="inline-flex items-center justify-center w-3.5 h-3.5 rounded-full border border-muted-foreground text-muted-foreground cursor-help" style="font-size:10px;line-height:1" title="过滤Grok响应中的指定标签,多个标签用逗号分隔">?</span>
|
| 379 |
+
</label>
|
| 380 |
+
<input id="cfgFilteredTags" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="xaiartifact,xai:tool_usage_card">
|
| 381 |
+
</div>
|
| 382 |
+
<div>
|
| 383 |
+
<label class="text-sm font-medium flex items-center gap-1 mb-2">
|
| 384 |
+
临时会话
|
| 385 |
+
<span class="inline-flex items-center justify-center w-3.5 h-3.5 rounded-full border border-muted-foreground text-muted-foreground cursor-help" style="font-size:10px;line-height:1" title="选择会话模式:标准模式保留上下文,临时模式每次对话独立">?</span>
|
| 386 |
+
</label>
|
| 387 |
+
<select id="cfgTemporary" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-ring">
|
| 388 |
+
<option value="false">关闭</option>
|
| 389 |
+
<option value="true">开启</option>
|
| 390 |
+
</select>
|
| 391 |
+
</div>
|
| 392 |
+
</div>
|
| 393 |
+
<div class="mt-6 flex justify-end">
|
| 394 |
+
<button onclick="saveGrokSettings()" class="inline-flex items-center justify-center rounded-md text-sm font-medium bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-6 transition-colors">保存配置</button>
|
| 395 |
+
</div>
|
| 396 |
+
</div>
|
| 397 |
+
</div>
|
| 398 |
+
</div>
|
| 399 |
+
</main>
|
| 400 |
+
|
| 401 |
+
<!-- 添加 Token 模态框 -->
|
| 402 |
+
<div id="addModal" class="hidden fixed inset-0 z-50 bg-black/80 flex items-center justify-center p-4">
|
| 403 |
+
<div class="bg-background rounded-lg border border-border w-full max-w-2xl max-h-[90vh] overflow-y-auto shadow-xl">
|
| 404 |
+
<div class="flex items-center justify-between p-5 border-b border-border">
|
| 405 |
+
<h3 class="text-lg font-semibold">添加 Token</h3>
|
| 406 |
+
<button onclick="closeAddModal()" class="text-muted-foreground hover:text-foreground transition-colors">
|
| 407 |
+
<svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 408 |
+
<line x1="18" y1="6" x2="6" y2="18"/>
|
| 409 |
+
<line x1="6" y1="6" x2="18" y2="18"/>
|
| 410 |
+
</svg>
|
| 411 |
+
</button>
|
| 412 |
+
</div>
|
| 413 |
+
<div class="p-5 space-y-4">
|
| 414 |
+
<div class="space-y-2">
|
| 415 |
+
<label class="text-sm font-medium">Token 类型</label>
|
| 416 |
+
<select id="addTokenType" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-ring">
|
| 417 |
+
<option value="sso">SSO</option>
|
| 418 |
+
<option value="ssoSuper">SuperSSO</option>
|
| 419 |
+
</select>
|
| 420 |
+
</div>
|
| 421 |
+
<div class="space-y-2">
|
| 422 |
+
<label class="text-sm font-medium">Token 列表 <span class="text-muted-foreground">(每行一个)</span></label>
|
| 423 |
+
<textarea
|
| 424 |
+
id="addTokenList"
|
| 425 |
+
rows="12"
|
| 426 |
+
placeholder="请输入 Token,每行一个"
|
| 427 |
+
class="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-ring font-mono resize-none"
|
| 428 |
+
></textarea>
|
| 429 |
+
</div>
|
| 430 |
+
</div>
|
| 431 |
+
<div class="flex items-center justify-end gap-3 p-5 border-t border-border bg-muted/30">
|
| 432 |
+
<button
|
| 433 |
+
onclick="closeAddModal()"
|
| 434 |
+
class="inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 border border-input bg-background hover:bg-accent h-9 px-5"
|
| 435 |
+
>
|
| 436 |
+
取消
|
| 437 |
+
</button>
|
| 438 |
+
<button
|
| 439 |
+
onclick="submitAddTokens()"
|
| 440 |
+
class="inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-5"
|
| 441 |
+
>
|
| 442 |
+
添加
|
| 443 |
+
</button>
|
| 444 |
+
</div>
|
| 445 |
+
</div>
|
| 446 |
+
</div>
|
| 447 |
+
|
| 448 |
+
<script>
|
| 449 |
+
let allTokens=[],filteredTokens=[],selectedTokens=new Set();
|
| 450 |
+
const $=(id)=>document.getElementById(id),
|
| 451 |
+
checkAuth=()=>{const t=localStorage.getItem('adminToken');return t||(location.href='/login',null),t},
|
| 452 |
+
apiRequest=async(url,opts={})=>{const t=checkAuth();if(!t)return null;const r=await fetch(url,{...opts,headers:{...opts.headers,Authorization:`Bearer ${t}`,'Content-Type':'application/json'}});return r.status===401?(localStorage.removeItem('adminToken'),location.href='/login',null):r},
|
| 453 |
+
loadStats=async()=>{try{const r=await apiRequest('/api/stats');if(!r)return;const d=await r.json();if(d.success){const s=d.data;$('statTotal').textContent=s.total||0;['Unused','Limited','Expired','Active'].forEach((k,i)=>$(`stat${k}`).textContent=(s.normal?.[k.toLowerCase()]||0)+(s.super?.[k.toLowerCase()]||0))}}catch(e){console.error('加载统计失败:',e)}},
|
| 454 |
+
calcRemaining=()=>{let n=0,h=0;allTokens.forEach(t=>{if(t.remaining_queries>0)n+=t.remaining_queries;if(t.heavy_remaining_queries>0)h+=t.heavy_remaining_queries});return{normal:n,heavy:h,total:n+h}},
|
| 455 |
+
loadTokens=async()=>{try{const r=await apiRequest('/api/tokens');if(!r)return;const d=await r.json();d.success&&(allTokens=d.data,filteredTokens=allTokens,selectedTokens.clear(),renderTokens(),updateRemaining())}catch(e){console.error('加载列表失败:',e)}},
|
| 456 |
+
updateRemaining=()=>{const r=calcRemaining();['Total','Normal','Heavy'].forEach(k=>$(`stat${k}Remaining`).textContent=r[k.toLowerCase()]===0?'-':r[k.toLowerCase()].toLocaleString())}
|
| 457 |
+
|
| 458 |
+
const renderTokens=()=>{const tb=$('tokenTableBody'),es=$('emptyState'),ss={'未使用':'bg-muted text-muted-foreground','限流中':'bg-orange-50 text-orange-700 border-orange-200','失效':'bg-destructive/10 text-destructive border-destructive/20','正常':'bg-green-50 text-green-700 border-green-200'},ts={sso:'bg-blue-50 text-blue-700 border-blue-200',ssoSuper:'bg-purple-50 text-purple-700 border-purple-200'},tl={sso:'SSO',ssoSuper:'SuperSSO'};if(!filteredTokens.length){tb.innerHTML='';es.classList.remove('hidden');$('selectAll').checked=false;return updateBatchActions()}es.classList.add('hidden');tb.innerHTML=filteredTokens.map(t=>`
|
| 459 |
+
<tr class="transition-colors">
|
| 460 |
+
<td class="py-2.5 px-3 align-middle w-12"><input type="checkbox" class="token-checkbox h-3.5 w-3.5 rounded border border-input focus:ring-1 focus:ring-ring" data-token="${t.token}" data-type="${t.token_type}" ${selectedTokens.has(t.token)?'checked':''} onchange="toggleToken('${t.token}')"></td>
|
| 461 |
+
<td class="py-2.5 px-3 align-middle w-80"><div class="flex items-center gap-2"><span class="font-mono text-xs">${t.token.substring(0,30)}...</span><button onclick="copyToken('${t.token.replace(/'/g,"\\'")}',event)" class="inline-flex items-center justify-center rounded-md transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring hover:bg-accent h-6 w-6" title="复制完整 Token"><svg class="h-3 w-3 text-muted-foreground" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button></div></td>
|
| 462 |
+
<td class="py-2.5 px-3 align-middle w-20"><span class="inline-flex items-center rounded-full px-1.5 py-0.5 text-xs font-medium border ${ts[t.token_type]}">${tl[t.token_type]}</span></td>
|
| 463 |
+
<td class="py-2.5 px-3 align-middle w-20"><span class="inline-flex items-center rounded-full px-1.5 py-0.5 text-xs font-medium border ${ss[t.status]}">${t.status}</span></td>
|
| 464 |
+
<td class="py-2.5 px-3 align-middle w-20 text-xs tabular-nums">${t.remaining_queries===-1?'-':t.remaining_queries}</td>
|
| 465 |
+
<td class="py-2.5 px-3 align-middle w-20 text-xs tabular-nums">${t.heavy_remaining_queries===-1?'-':t.heavy_remaining_queries}</td>
|
| 466 |
+
<td class="py-2.5 px-3 align-middle w-32 text-xs text-muted-foreground">${t.created_time?new Date(t.created_time).toLocaleString('zh-CN',{dateStyle:'short',timeStyle:'short'}):'-'}</td>
|
| 467 |
+
<td class="py-2.5 px-3 align-middle text-right w-16"><button onclick="deleteToken('${t.token}','${t.token_type}')" class="inline-flex items-center justify-center rounded-md text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring hover:bg-destructive/10 hover:text-destructive h-7 w-7"><svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg></button></td>
|
| 468 |
+
</tr>`).join('');updateBatchActions()},
|
| 469 |
+
toggleToken=t=>selectedTokens[selectedTokens.has(t)?'delete':'add'](t)||updateBatchActions(),
|
| 470 |
+
toggleSelectAll=()=>{const sa=$('selectAll');sa.checked?filteredTokens.forEach(t=>selectedTokens.add(t.token)):selectedTokens.clear();renderTokens()},
|
| 471 |
+
updateBatchActions=()=>{const ba=$('batchActions'),sc=$('selectedCount'),c=selectedTokens.size;ba.classList[c>0?'add':'remove']('flex');ba.classList[c>0?'remove':'add']('hidden');c>0&&(sc.textContent=`已选择 ${c} 项`);$('selectAll').checked=filteredTokens.length>0&&c===filteredTokens.length},
|
| 472 |
+
filterTokens=()=>{const tf=$('filterType').value,sf=$('filterStatus').value;filteredTokens=allTokens.filter(t=>(tf==='all'||t.token_type===tf)&&(sf==='all'||t.status===sf));selectedTokens.clear();renderTokens()},
|
| 473 |
+
refreshTokens=async()=>{await loadTokens();await loadStats()},
|
| 474 |
+
openAddModal=()=>$('addModal').classList.remove('hidden'),
|
| 475 |
+
closeAddModal=()=>{$('addModal').classList.add('hidden');$('addTokenList').value=''},
|
| 476 |
+
deleteToken=async(t,tt)=>{if(!confirm('确定要删除这个 Token 吗?'))return;try{const r=await apiRequest('/api/tokens/delete',{method:'POST',body:JSON.stringify({tokens:[t],token_type:tt})});if(!r)return;const d=await r.json();d.success?await refreshTokens():alert('删除失败: '+(d.error||'未知错误'))}catch(e){alert('删除失败: '+e.message)}},
|
| 477 |
+
batchDelete=async()=>{if(!selectedTokens.size||!confirm(`确定要删除选中的 ${selectedTokens.size} 个 Token 吗?此操作不可恢复!`))return;const tbt={sso:[],ssoSuper:[]};document.querySelectorAll('.token-checkbox:checked').forEach(cb=>tbt[cb.dataset.type].push(cb.dataset.token));try{const ps=[];['sso','ssoSuper'].forEach(k=>tbt[k].length&&ps.push(apiRequest('/api/tokens/delete',{method:'POST',body:JSON.stringify({tokens:tbt[k],token_type:k})})));await Promise.all(ps);await refreshTokens()}catch(e){alert('批量删除失败: '+e.message)}},
|
| 478 |
+
submitAddTokens=async()=>{const tt=$('addTokenType').value,tks=$('addTokenList').value.split('\n').map(t=>t.trim()).filter(t=>t);if(!tks.length)return alert('请输入至少一个 Token');try{const r=await apiRequest('/api/tokens/add',{method:'POST',body:JSON.stringify({tokens:tks,token_type:tt})});if(!r)return;const d=await r.json();d.success?(closeAddModal(),await refreshTokens()):alert('添加失败: '+(d.error||'未知错误'))}catch(e){alert('添加失败: '+e.message)}},
|
| 479 |
+
copyToken=async(t,e)=>{e.stopPropagation();try{await navigator.clipboard.writeText(t);showToast('Token 已复制到剪贴板','success')}catch(err){console.error('复制失败:',err);showToast('复制失败,请手动复制','error')}}
|
| 480 |
+
|
| 481 |
+
const exportSelected=()=>{if(!selectedTokens.size)return showToast('请先选择要导出的 Token','error');const sd=allTokens.filter(t=>selectedTokens.has(t.token)),csv=[['Token','类型','状态','普通调用剩余','高级调用剩余','创建时间'].join(','),...sd.map(t=>[`"${t.token}"`,t.token_type==='sso'?'SSO':'SuperSSO',t.status,t.remaining_queries===-1?'未使用':t.remaining_queries,t.heavy_remaining_queries===-1?'未使用':t.heavy_remaining_queries,`"${t.created_time?new Date(t.created_time).toLocaleString('zh-CN'):'-'}"`].join(','))].join('\n'),l=document.createElement('a');l.href=URL.createObjectURL(new Blob(['\uFEFF'+csv],{type:'text/csv;charset=utf-8;'}));l.download=`grok_tokens_${new Date().toISOString().slice(0,10)}.csv`;l.style.visibility='hidden';document.body.appendChild(l);l.click();document.body.removeChild(l);URL.revokeObjectURL(l.href);showToast(`已导出 ${selectedTokens.size} 个 Token`,'success')},
|
| 482 |
+
showToast=(m,t='info')=>{const d=document.createElement('div'),bc={success:'bg-green-600',error:'bg-destructive',info:'bg-primary'};d.className=`fixed bottom-4 right-4 ${bc[t]||bc.info} text-white px-4 py-2.5 rounded-lg shadow-lg text-sm font-medium z-50 animate-slide-up`;d.textContent=m;document.body.appendChild(d);setTimeout(()=>{d.style.opacity='0';d.style.transition='opacity 0.3s';setTimeout(()=>d.parentNode&&document.body.removeChild(d),300)},2000)},
|
| 483 |
+
logout=async()=>{if(!confirm('确定要退出登录吗?'))return;try{await apiRequest('/api/logout',{method:'POST'})}catch(e){console.error('登出失败:',e)}finally{localStorage.removeItem('adminToken');location.href='/login'}},
|
| 484 |
+
switchTab=t=>{['tokens','settings'].forEach(n=>{$(`panel${n.charAt(0).toUpperCase()+n.slice(1)}`).classList[n===t?'remove':'add']('hidden');$(`tab${n.charAt(0).toUpperCase()+n.slice(1)}`).classList[n===t?'add':'remove']('border-primary','text-primary');$(`tab${n.charAt(0).toUpperCase()+n.slice(1)}`).classList[n===t?'remove':'add']('border-transparent','text-muted-foreground')});t==='settings'&&loadSettings()},
|
| 485 |
+
loadSettings=async()=>{try{const r=await apiRequest('/api/settings');if(!r)return;const d=await r.json();if(d.success){const g=d.data.global,k=d.data.grok;$('cfgAdminUser').value=g.admin_username||'';$('cfgAdminPass').value='';$('cfgLogLevel').value=g.log_level||'DEBUG';$('cfgTempMaxSize').value=g.temp_max_size_mb||500;$('cfgBaseUrl').value=g.base_url||'';$('cfgApiKey').value=k.api_key||'';$('cfgProxyUrl').value=k.proxy_url||'';$('cfgCfClearance').value=k.cf_clearance||'';$('cfgStatsigId').value=k.x_statsig_id||'';$('cfgFilteredTags').value=k.filtered_tags||'';$('cfgTemporary').value=k.temporary!==false?'true':'false';await loadCacheSize()}}catch(e){console.error('加载配置失败:',e);showToast('加载配置失败','error')}},
|
| 486 |
+
loadCacheSize=async()=>{try{const r=await apiRequest('/api/cache/size');if(!r)return;const d=await r.json();if(d.success){$('cacheSize').value=d.data.size||'0 MB'}}catch(e){console.error('加载缓存大小失败:',e);$('cacheSize').value='0 MB'}},
|
| 487 |
+
clearCache=async()=>{if(!confirm('确定要清理缓存吗?此操作将删除 /data/temp 目录中的所有文件!'))return;try{const r=await apiRequest('/api/cache/clear',{method:'POST'});if(!r)return;const d=await r.json();if(d.success){showToast(`缓存清理完成,已删除 ${d.data.deleted_count||0} 个文件`,'success');await loadCacheSize()}else{showToast('清理失败: '+(d.error||'未知错误'),'error')}}catch(e){showToast('清理失败: '+e.message,'error')}},
|
| 488 |
+
saveGlobalSettings=async()=>{const gc={admin_username:$('cfgAdminUser').value,log_level:$('cfgLogLevel').value,temp_max_size_mb:parseInt($('cfgTempMaxSize').value)||500,base_url:$('cfgBaseUrl').value};if($('cfgAdminPass').value)gc.admin_password=$('cfgAdminPass').value;try{const r=await apiRequest('/api/settings');if(!r)return;const d=await r.json();if(!d.success)return showToast('加载配置失败','error');const s=await apiRequest('/api/settings',{method:'POST',body:JSON.stringify({global_config:gc,grok_config:d.data.grok})});if(!s)return;const sd=await s.json();sd.success?(showToast('全局配置保存成功','success'),$('cfgAdminPass').value=''):showToast('保存失败: '+(sd.error||'未知错误'),'error')}catch(e){showToast('保���失败: '+e.message,'error')}},
|
| 489 |
+
saveGrokSettings=async()=>{const kc={api_key:$('cfgApiKey').value,proxy_url:$('cfgProxyUrl').value,cf_clearance:$('cfgCfClearance').value,x_statsig_id:$('cfgStatsigId').value,filtered_tags:$('cfgFilteredTags').value,temporary:$('cfgTemporary').value==='true'};try{const r=await apiRequest('/api/settings');if(!r)return;const d=await r.json();if(!d.success)return showToast('加载配置失败','error');const s=await apiRequest('/api/settings',{method:'POST',body:JSON.stringify({global_config:d.data.global,grok_config:kc})});if(!s)return;const sd=await s.json();sd.success?showToast('Grok配置保存成功','success'):showToast('保存失败: '+(sd.error||'未知错误'),'error')}catch(e){showToast('保存失败: '+e.message,'error')}};
|
| 490 |
+
window.addEventListener('DOMContentLoaded',()=>{checkAuth();refreshTokens();setInterval(()=>{loadStats();updateRemaining()},30000)});
|
| 491 |
+
</script>
|
| 492 |
+
</body>
|
| 493 |
+
</html>
|
app/template/login.html
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="zh-CN" class="h-full">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>登录 - Grok2API</title>
|
| 7 |
+
<script src="https://cdn.tailwindcss.com"></script>
|
| 8 |
+
<script>
|
| 9 |
+
tailwind.config = {
|
| 10 |
+
theme: {
|
| 11 |
+
extend: {
|
| 12 |
+
colors: {
|
| 13 |
+
border: "hsl(0 0% 89%)",
|
| 14 |
+
input: "hsl(0 0% 89%)",
|
| 15 |
+
ring: "hsl(0 0% 3.9%)",
|
| 16 |
+
background: "hsl(0 0% 100%)",
|
| 17 |
+
foreground: "hsl(0 0% 3.9%)",
|
| 18 |
+
primary: {
|
| 19 |
+
DEFAULT: "hsl(0 0% 9%)",
|
| 20 |
+
foreground: "hsl(0 0% 98%)",
|
| 21 |
+
},
|
| 22 |
+
secondary: {
|
| 23 |
+
DEFAULT: "hsl(0 0% 96.1%)",
|
| 24 |
+
foreground: "hsl(0 0% 9%)",
|
| 25 |
+
},
|
| 26 |
+
muted: {
|
| 27 |
+
DEFAULT: "hsl(0 0% 96.1%)",
|
| 28 |
+
foreground: "hsl(0 0% 45.1%)",
|
| 29 |
+
},
|
| 30 |
+
destructive: {
|
| 31 |
+
DEFAULT: "hsl(0 84.2% 60.2%)",
|
| 32 |
+
foreground: "hsl(0 0% 98%)",
|
| 33 |
+
},
|
| 34 |
+
},
|
| 35 |
+
}
|
| 36 |
+
}
|
| 37 |
+
}
|
| 38 |
+
</script>
|
| 39 |
+
<style>
|
| 40 |
+
@keyframes slide-up {
|
| 41 |
+
from {
|
| 42 |
+
transform: translateY(100%);
|
| 43 |
+
opacity: 0;
|
| 44 |
+
}
|
| 45 |
+
to {
|
| 46 |
+
transform: translateY(0);
|
| 47 |
+
opacity: 1;
|
| 48 |
+
}
|
| 49 |
+
}
|
| 50 |
+
.animate-slide-up {
|
| 51 |
+
animation: slide-up 0.3s ease-out;
|
| 52 |
+
}
|
| 53 |
+
</style>
|
| 54 |
+
</head>
|
| 55 |
+
<body class="h-full bg-background text-foreground antialiased">
|
| 56 |
+
<div class="flex min-h-full flex-col justify-center py-12 px-4 sm:px-6 lg:px-8">
|
| 57 |
+
<div class="sm:mx-auto sm:w-full sm:max-w-md">
|
| 58 |
+
<div class="text-center">
|
| 59 |
+
<h1 class="text-4xl font-bold">Grok2API</h1>
|
| 60 |
+
<p class="mt-2 text-sm text-muted-foreground">管理员控制台</p>
|
| 61 |
+
</div>
|
| 62 |
+
</div>
|
| 63 |
+
|
| 64 |
+
<div class="sm:mx-auto sm:w-full sm:max-w-md">
|
| 65 |
+
<div class="bg-background py-8 px-4 sm:px-10 rounded-lg">
|
| 66 |
+
<form id="loginForm" class="space-y-6">
|
| 67 |
+
<!-- 账户 -->
|
| 68 |
+
<div class="space-y-2">
|
| 69 |
+
<label for="username" class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
|
| 70 |
+
账户
|
| 71 |
+
</label>
|
| 72 |
+
<input
|
| 73 |
+
type="text"
|
| 74 |
+
id="username"
|
| 75 |
+
name="username"
|
| 76 |
+
required
|
| 77 |
+
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50 transition-colors"
|
| 78 |
+
placeholder="请输入用户名"
|
| 79 |
+
>
|
| 80 |
+
</div>
|
| 81 |
+
|
| 82 |
+
<!-- 密码 -->
|
| 83 |
+
<div class="space-y-2">
|
| 84 |
+
<label for="password" class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
|
| 85 |
+
密码
|
| 86 |
+
</label>
|
| 87 |
+
<input
|
| 88 |
+
type="password"
|
| 89 |
+
id="password"
|
| 90 |
+
name="password"
|
| 91 |
+
required
|
| 92 |
+
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50 transition-colors"
|
| 93 |
+
placeholder="请输入密码"
|
| 94 |
+
>
|
| 95 |
+
</div>
|
| 96 |
+
|
| 97 |
+
<!-- 登录按钮 -->
|
| 98 |
+
<button
|
| 99 |
+
type="submit"
|
| 100 |
+
id="loginButton"
|
| 101 |
+
class="inline-flex items-center justify-center whitespace-nowrap rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-0 disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-4 py-2 w-full text-sm"
|
| 102 |
+
>
|
| 103 |
+
���录
|
| 104 |
+
</button>
|
| 105 |
+
</form>
|
| 106 |
+
|
| 107 |
+
<div class="mt-6 text-center text-xs text-muted-foreground">
|
| 108 |
+
<p>Created By Chenyme © 2025</p>
|
| 109 |
+
</div>
|
| 110 |
+
</div>
|
| 111 |
+
</div>
|
| 112 |
+
</div>
|
| 113 |
+
|
| 114 |
+
<script>
|
| 115 |
+
const loginForm = document.getElementById('loginForm');
|
| 116 |
+
const loginButton = document.getElementById('loginButton');
|
| 117 |
+
|
| 118 |
+
loginForm.addEventListener('submit', async (e) => {
|
| 119 |
+
e.preventDefault();
|
| 120 |
+
|
| 121 |
+
loginButton.disabled = true;
|
| 122 |
+
loginButton.textContent = '登录中...';
|
| 123 |
+
|
| 124 |
+
try {
|
| 125 |
+
const formData = new FormData(loginForm);
|
| 126 |
+
const response = await fetch('/api/login', {
|
| 127 |
+
method: 'POST',
|
| 128 |
+
headers: { 'Content-Type': 'application/json' },
|
| 129 |
+
body: JSON.stringify({
|
| 130 |
+
username: formData.get('username'),
|
| 131 |
+
password: formData.get('password')
|
| 132 |
+
})
|
| 133 |
+
});
|
| 134 |
+
|
| 135 |
+
const data = await response.json();
|
| 136 |
+
|
| 137 |
+
if (data.success) {
|
| 138 |
+
localStorage.setItem('adminToken', data.token);
|
| 139 |
+
window.location.href = '/manage';
|
| 140 |
+
} else {
|
| 141 |
+
showToast(data.message || '登录失败', 'error');
|
| 142 |
+
}
|
| 143 |
+
} catch (error) {
|
| 144 |
+
showToast('网络错误,请稍后重试', 'error');
|
| 145 |
+
} finally {
|
| 146 |
+
loginButton.disabled = false;
|
| 147 |
+
loginButton.textContent = '登录';
|
| 148 |
+
}
|
| 149 |
+
});
|
| 150 |
+
|
| 151 |
+
function showToast(message, type = 'error') {
|
| 152 |
+
const toast = document.createElement('div');
|
| 153 |
+
const bgColors = {
|
| 154 |
+
success: 'bg-green-600',
|
| 155 |
+
error: 'bg-destructive',
|
| 156 |
+
info: 'bg-primary'
|
| 157 |
+
};
|
| 158 |
+
toast.className = `fixed bottom-4 right-4 ${bgColors[type] || bgColors.error} text-white px-4 py-2.5 rounded-lg shadow-lg text-sm font-medium z-50 animate-slide-up`;
|
| 159 |
+
toast.textContent = message;
|
| 160 |
+
document.body.appendChild(toast);
|
| 161 |
+
setTimeout(() => {
|
| 162 |
+
toast.style.opacity = '0';
|
| 163 |
+
toast.style.transition = 'opacity 0.3s';
|
| 164 |
+
setTimeout(() => toast.parentNode && document.body.removeChild(toast), 300);
|
| 165 |
+
}, 2000);
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
window.addEventListener('DOMContentLoaded', () => {
|
| 169 |
+
const token = localStorage.getItem('adminToken');
|
| 170 |
+
if (token) {
|
| 171 |
+
fetch('/api/stats', {
|
| 172 |
+
headers: { 'Authorization': `Bearer ${token}` }
|
| 173 |
+
}).then(response => {
|
| 174 |
+
if (response.ok) window.location.href = '/manage';
|
| 175 |
+
});
|
| 176 |
+
}
|
| 177 |
+
});
|
| 178 |
+
</script>
|
| 179 |
+
</body>
|
| 180 |
+
</html>
|
data/setting.toml
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[global]
|
| 2 |
+
admin_username = "admin"
|
| 3 |
+
admin_password = "admin"
|
| 4 |
+
log_level = "INFO"
|
| 5 |
+
temp_max_size_mb = 500
|
| 6 |
+
base_url = ""
|
| 7 |
+
|
| 8 |
+
[grok]
|
| 9 |
+
api_key = ""
|
| 10 |
+
proxy_url = ""
|
| 11 |
+
cf_clearance = ""
|
| 12 |
+
temporary = true
|
| 13 |
+
filtered_tags = "xaiartifact,xai:tool_usage_card,grok:render"
|
| 14 |
+
x_statsig_id = "ZTpUeXBlRXJyb3I6IENhbm5vdCByZWFkIHByb3BlcnRpZXMgb2YgdW5kZWZpbmVkIChyZWFkaW5nICdjaGlsZE5vZGVzJyk="
|
data/temp/image.temp
ADDED
|
File without changes
|
data/token.json
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"ssoNormal": {},
|
| 3 |
+
"ssoSuper": {}
|
| 4 |
+
}
|
docker-compose.yml
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
services:
|
| 2 |
+
grok2api:
|
| 3 |
+
image: ghcr.io/chenyme/grok2api:latest
|
| 4 |
+
ports:
|
| 5 |
+
- "8000:8000"
|
| 6 |
+
volumes:
|
| 7 |
+
- grok_data:/app/data
|
| 8 |
+
- ./logs:/app/logs
|
| 9 |
+
|
| 10 |
+
volumes:
|
| 11 |
+
grok_data:
|
main.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FastAPI应用主入口"""
|
| 2 |
+
|
| 3 |
+
from contextlib import asynccontextmanager
|
| 4 |
+
from fastapi import FastAPI
|
| 5 |
+
from fastapi.staticfiles import StaticFiles
|
| 6 |
+
from app.core.logger import logger
|
| 7 |
+
from app.core.exception import register_exception_handlers
|
| 8 |
+
from app.api.v1.chat import router as chat_router
|
| 9 |
+
from app.api.v1.models import router as models_router
|
| 10 |
+
from app.api.v1.images import router as images_router
|
| 11 |
+
from app.api.admin.manage import router as admin_router
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@asynccontextmanager
|
| 15 |
+
async def lifespan(app: FastAPI):
|
| 16 |
+
"""应用生命周期管理"""
|
| 17 |
+
logger.debug("[Web2API] 应用启动成功")
|
| 18 |
+
yield
|
| 19 |
+
logger.info("[Web2API] 应用关闭成功")
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
# 初始化日志
|
| 23 |
+
logger.info("[Web2API] 应用正在启动...")
|
| 24 |
+
|
| 25 |
+
# 创建FastAPI应用
|
| 26 |
+
app = FastAPI(
|
| 27 |
+
title="Web2API",
|
| 28 |
+
description="Web服务API",
|
| 29 |
+
version="1.0.0",
|
| 30 |
+
lifespan=lifespan
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
# 注册全局异常处理器
|
| 34 |
+
register_exception_handlers(app)
|
| 35 |
+
|
| 36 |
+
# 注册路由
|
| 37 |
+
app.include_router(chat_router, prefix="/v1")
|
| 38 |
+
app.include_router(models_router, prefix="/v1")
|
| 39 |
+
app.include_router(images_router)
|
| 40 |
+
app.include_router(admin_router)
|
| 41 |
+
|
| 42 |
+
# 挂载静态文件(注意:这个应该在API路由之后,避免拦截API请求)
|
| 43 |
+
app.mount("/static", StaticFiles(directory="app/template"), name="template")
|
| 44 |
+
|
| 45 |
+
@app.get("/")
|
| 46 |
+
async def root():
|
| 47 |
+
"""根路径"""
|
| 48 |
+
return {"message": "Welcome to Web2API"}
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
if __name__ == "__main__":
|
| 52 |
+
import uvicorn
|
| 53 |
+
uvicorn.run(app, host="0.0.0.0", port=8001)
|
requirements.txt
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
toml==0.10.2
|
| 2 |
+
fastapi==0.118.2
|
| 3 |
+
uvicorn==0.37.0
|
| 4 |
+
python-dotenv==1.1.1
|
| 5 |
+
curl_cffi==0.13.0
|
| 6 |
+
requests==2.32.5
|
| 7 |
+
starlette==0.48.0
|
| 8 |
+
pydantic==2.12.0
|
| 9 |
+
aiofiles==24.1.0
|