Upload 30 files
Browse files- .gitattributes +1 -0
- app/api/admin/manage.py +33 -20
- app/api/v1/images.py +2 -2
- app/core/config.py +88 -1
- app/core/logger.py +42 -2
- app/core/storage.py +490 -0
- app/services/grok/cache.py +64 -100
- app/services/grok/client.py +51 -28
- app/services/grok/processer.py +87 -23
- app/services/grok/token.py +23 -7
- app/services/grok/upload.py +4 -0
- app/services/mcp/__init__.py +6 -0
- app/services/mcp/server.py +63 -0
- app/services/mcp/tools.py +77 -0
- app/template/admin.html +275 -251
- app/template/favicon.png +3 -0
- app/template/login.html +13 -139
- data/setting.toml +14 -15
- data/token.json +2 -2
- docker-compose.yml +9 -1
- main.py +71 -10
- requirements.txt +7 -3
.gitattributes
CHANGED
|
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
app/template/favicon.png filter=lfs diff=lfs merge=lfs -text
|
app/api/admin/manage.py
CHANGED
|
@@ -439,28 +439,13 @@ class StreamTimeoutSettings(BaseModel):
|
|
| 439 |
async def update_settings(request: UpdateSettingsRequest, _: bool = Depends(verify_admin_session)) -> Dict[str, Any]:
|
| 440 |
"""更新全局配置"""
|
| 441 |
try:
|
| 442 |
-
import toml
|
| 443 |
-
import aiofiles
|
| 444 |
logger.debug("[Admin] 更新全局配置")
|
| 445 |
|
| 446 |
-
#
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
# 更新配置
|
| 452 |
-
if request.global_config:
|
| 453 |
-
config["global"].update(request.global_config)
|
| 454 |
-
if request.grok_config:
|
| 455 |
-
config["grok"].update(request.grok_config)
|
| 456 |
-
|
| 457 |
-
# 异步写回配置文件
|
| 458 |
-
async with aiofiles.open(setting.config_path, "w", encoding="utf-8") as f:
|
| 459 |
-
await f.write(toml.dumps(config))
|
| 460 |
-
|
| 461 |
-
# 重新加载配置
|
| 462 |
-
setting.global_config = setting.load("global")
|
| 463 |
-
setting.grok_config = setting.load("grok")
|
| 464 |
|
| 465 |
logger.debug("[Admin] 配置更新成功")
|
| 466 |
return {"success": True, "message": "配置更新成功"}
|
|
@@ -706,3 +691,31 @@ async def get_stats(_: bool = Depends(verify_admin_session)) -> Dict[str, Any]:
|
|
| 706 |
status_code=500,
|
| 707 |
detail={"error": f"获取统计信息失败: {str(e)}", "code": "STATS_ERROR"}
|
| 708 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 439 |
async def update_settings(request: UpdateSettingsRequest, _: bool = Depends(verify_admin_session)) -> Dict[str, Any]:
|
| 440 |
"""更新全局配置"""
|
| 441 |
try:
|
|
|
|
|
|
|
| 442 |
logger.debug("[Admin] 更新全局配置")
|
| 443 |
|
| 444 |
+
# 使用ConfigManager的save方法(支持存储抽象层)
|
| 445 |
+
await setting.save(
|
| 446 |
+
global_config=request.global_config,
|
| 447 |
+
grok_config=request.grok_config
|
| 448 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 449 |
|
| 450 |
logger.debug("[Admin] 配置更新成功")
|
| 451 |
return {"success": True, "message": "配置更新成功"}
|
|
|
|
| 691 |
status_code=500,
|
| 692 |
detail={"error": f"获取统计信息失败: {str(e)}", "code": "STATS_ERROR"}
|
| 693 |
)
|
| 694 |
+
|
| 695 |
+
|
| 696 |
+
@router.get("/api/storage/mode")
|
| 697 |
+
async def get_storage_mode(_: bool = Depends(verify_admin_session)) -> Dict[str, Any]:
|
| 698 |
+
"""
|
| 699 |
+
获取当前存储模式
|
| 700 |
+
|
| 701 |
+
返回当前的存储模式(file/mysql/redis)。
|
| 702 |
+
"""
|
| 703 |
+
try:
|
| 704 |
+
logger.debug("[Admin] 获取存储模式")
|
| 705 |
+
|
| 706 |
+
import os
|
| 707 |
+
storage_mode = os.getenv("STORAGE_MODE", "file").upper()
|
| 708 |
+
|
| 709 |
+
return {
|
| 710 |
+
"success": True,
|
| 711 |
+
"data": {
|
| 712 |
+
"mode": storage_mode
|
| 713 |
+
}
|
| 714 |
+
}
|
| 715 |
+
|
| 716 |
+
except Exception as e:
|
| 717 |
+
logger.error(f"[Admin] 获取存储模式异常 - 错误: {str(e)}")
|
| 718 |
+
raise HTTPException(
|
| 719 |
+
status_code=500,
|
| 720 |
+
detail={"error": f"获取存储模式失败: {str(e)}", "code": "STORAGE_MODE_ERROR"}
|
| 721 |
+
)
|
app/api/v1/images.py
CHANGED
|
@@ -29,11 +29,11 @@ async def get_image(img_path: str):
|
|
| 29 |
|
| 30 |
if is_video:
|
| 31 |
# 检查视频缓存
|
| 32 |
-
cache_path = video_cache_service.
|
| 33 |
media_type = "video/mp4"
|
| 34 |
else:
|
| 35 |
# 检查图片缓存
|
| 36 |
-
cache_path = image_cache_service.
|
| 37 |
media_type = "image/jpeg"
|
| 38 |
|
| 39 |
if cache_path and cache_path.exists():
|
|
|
|
| 29 |
|
| 30 |
if is_video:
|
| 31 |
# 检查视频缓存
|
| 32 |
+
cache_path = video_cache_service.get_cached(original_path)
|
| 33 |
media_type = "video/mp4"
|
| 34 |
else:
|
| 35 |
# 检查图片缓存
|
| 36 |
+
cache_path = image_cache_service.get_cached(original_path)
|
| 37 |
media_type = "image/jpeg"
|
| 38 |
|
| 39 |
if cache_path and cache_path.exists():
|
app/core/config.py
CHANGED
|
@@ -15,14 +15,101 @@ class ConfigManager:
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
except Exception as e:
|
| 25 |
raise Exception(f"[Setting] 配置加载失败: {e}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
# 全局设置
|
| 28 |
setting = ConfigManager()
|
|
|
|
| 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 |
+
self._storage = None
|
| 19 |
+
|
| 20 |
+
def set_storage(self, storage) -> None:
|
| 21 |
+
"""设置存储实例"""
|
| 22 |
+
self._storage = storage
|
| 23 |
|
| 24 |
def load(self, section: str) -> Dict[str, Any]:
|
| 25 |
"""配置加载器"""
|
| 26 |
try:
|
| 27 |
with open(self.config_path, "r", encoding="utf-8") as f:
|
| 28 |
+
config = toml.load(f)[section]
|
| 29 |
+
|
| 30 |
+
# 自动将 SOCKS5 转换为 SOCKS5H
|
| 31 |
+
if section == "grok" and "proxy_url" in config:
|
| 32 |
+
proxy_url = config["proxy_url"]
|
| 33 |
+
if proxy_url and proxy_url.startswith("socks5://"):
|
| 34 |
+
config["proxy_url"] = proxy_url.replace("socks5://", "socks5h://", 1)
|
| 35 |
+
|
| 36 |
+
# 自动为 CF Clearance 添加前缀
|
| 37 |
+
if section == "grok" and "cf_clearance" in config:
|
| 38 |
+
cf_clearance = config["cf_clearance"]
|
| 39 |
+
if cf_clearance and not cf_clearance.startswith("cf_clearance="):
|
| 40 |
+
config["cf_clearance"] = f"cf_clearance={cf_clearance}"
|
| 41 |
+
|
| 42 |
+
return config
|
| 43 |
except Exception as e:
|
| 44 |
raise Exception(f"[Setting] 配置加载失败: {e}")
|
| 45 |
+
|
| 46 |
+
async def reload(self) -> None:
|
| 47 |
+
"""重新加载配置(用于从存储同步后)"""
|
| 48 |
+
self.global_config = self.load("global")
|
| 49 |
+
self.grok_config = self.load("grok")
|
| 50 |
+
|
| 51 |
+
async def save(self, global_config: Dict[str, Any] = None, grok_config: Dict[str, Any] = None) -> None:
|
| 52 |
+
"""保存配置到存储"""
|
| 53 |
+
if not self._storage:
|
| 54 |
+
# 如果没有设置存储,使用传统文件保存方式
|
| 55 |
+
import aiofiles
|
| 56 |
+
async with aiofiles.open(self.config_path, "r", encoding="utf-8") as f:
|
| 57 |
+
content = await f.read()
|
| 58 |
+
config = toml.loads(content)
|
| 59 |
+
|
| 60 |
+
if global_config:
|
| 61 |
+
config["global"].update(global_config)
|
| 62 |
+
if grok_config:
|
| 63 |
+
# 处理 cf_clearance,移除前缀后保存
|
| 64 |
+
processed_grok_config = grok_config.copy()
|
| 65 |
+
if "cf_clearance" in processed_grok_config:
|
| 66 |
+
cf_clearance = processed_grok_config["cf_clearance"]
|
| 67 |
+
if cf_clearance and cf_clearance.startswith("cf_clearance="):
|
| 68 |
+
processed_grok_config["cf_clearance"] = cf_clearance.replace("cf_clearance=", "", 1)
|
| 69 |
+
config["grok"].update(processed_grok_config)
|
| 70 |
+
|
| 71 |
+
async with aiofiles.open(self.config_path, "w", encoding="utf-8") as f:
|
| 72 |
+
await f.write(toml.dumps(config))
|
| 73 |
+
else:
|
| 74 |
+
# 使用存储抽象层
|
| 75 |
+
config_data = await self._storage.load_config()
|
| 76 |
+
|
| 77 |
+
if global_config:
|
| 78 |
+
config_data["global"].update(global_config)
|
| 79 |
+
if grok_config:
|
| 80 |
+
# 处理 cf_clearance,移除前缀后保存
|
| 81 |
+
processed_grok_config = grok_config.copy()
|
| 82 |
+
if "cf_clearance" in processed_grok_config:
|
| 83 |
+
cf_clearance = processed_grok_config["cf_clearance"]
|
| 84 |
+
if cf_clearance and cf_clearance.startswith("cf_clearance="):
|
| 85 |
+
processed_grok_config["cf_clearance"] = cf_clearance.replace("cf_clearance=", "", 1)
|
| 86 |
+
config_data["grok"].update(processed_grok_config)
|
| 87 |
+
|
| 88 |
+
await self._storage.save_config(config_data)
|
| 89 |
+
|
| 90 |
+
# 重新加载配置
|
| 91 |
+
await self.reload()
|
| 92 |
+
|
| 93 |
+
def get_service_proxy(self) -> str:
|
| 94 |
+
"""获取服务代理URL(用于 client 和 upload)"""
|
| 95 |
+
return self.grok_config.get("proxy_url", "")
|
| 96 |
+
|
| 97 |
+
def get_cache_proxy(self) -> str:
|
| 98 |
+
"""获取缓存代理URL(用于 cache)
|
| 99 |
+
|
| 100 |
+
逻辑:
|
| 101 |
+
- 如果只设置了 proxy_url,缓存和服务都使用 proxy_url
|
| 102 |
+
- 如果同时设置了 proxy_url 和 cache_proxy_url,缓存使用 cache_proxy_url
|
| 103 |
+
"""
|
| 104 |
+
cache_proxy = self.grok_config.get("cache_proxy_url", "")
|
| 105 |
+
service_proxy = self.grok_config.get("proxy_url", "")
|
| 106 |
+
|
| 107 |
+
# 如果设置了 cache_proxy_url,优先使用
|
| 108 |
+
if cache_proxy:
|
| 109 |
+
return cache_proxy
|
| 110 |
+
|
| 111 |
+
# 否则使用 proxy_url(服务代理)
|
| 112 |
+
return service_proxy
|
| 113 |
|
| 114 |
# 全局设置
|
| 115 |
setting = ConfigManager()
|
app/core/logger.py
CHANGED
|
@@ -7,6 +7,27 @@ from logging.handlers import RotatingFileHandler
|
|
| 7 |
from app.core.config import setting
|
| 8 |
|
| 9 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
class LoggerManager:
|
| 11 |
"""日志管理器"""
|
| 12 |
|
|
@@ -32,21 +53,40 @@ class LoggerManager:
|
|
| 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(
|
|
|
|
| 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(
|
|
|
|
| 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:
|
|
|
|
| 7 |
from app.core.config import setting
|
| 8 |
|
| 9 |
|
| 10 |
+
class MCPLogFilter(logging.Filter):
|
| 11 |
+
"""MCP 日志过滤器 - 过滤掉包含大量数据的 DEBUG 日志"""
|
| 12 |
+
|
| 13 |
+
def filter(self, record):
|
| 14 |
+
# 过滤掉包含原始字节数据的 SSE 日志
|
| 15 |
+
if record.name == "sse_starlette.sse" and "chunk: b'" in record.getMessage():
|
| 16 |
+
return False
|
| 17 |
+
|
| 18 |
+
# 过滤掉 SSE 的一些冗余日志
|
| 19 |
+
if record.name == "sse_starlette.sse" and record.levelno == logging.DEBUG:
|
| 20 |
+
msg = record.getMessage()
|
| 21 |
+
if any(x in msg for x in ["Got event:", "Closing", "chunk:"]):
|
| 22 |
+
return False
|
| 23 |
+
|
| 24 |
+
# 过滤掉 MCP streamable_http 的一些 DEBUG 日志
|
| 25 |
+
if "mcp.server.streamable_http" in record.name and record.levelno == logging.DEBUG:
|
| 26 |
+
return False
|
| 27 |
+
|
| 28 |
+
return True
|
| 29 |
+
|
| 30 |
+
|
| 31 |
class LoggerManager:
|
| 32 |
"""日志管理器"""
|
| 33 |
|
|
|
|
| 53 |
if self.logger.handlers:
|
| 54 |
return
|
| 55 |
|
| 56 |
+
# 创建格式器
|
| 57 |
+
formatter = logging.Formatter(log_format)
|
| 58 |
+
|
| 59 |
+
# 创建日志过滤器
|
| 60 |
+
mcp_filter = MCPLogFilter()
|
| 61 |
+
|
| 62 |
# 控制台处理器
|
| 63 |
console_handler = logging.StreamHandler(sys.stdout)
|
| 64 |
console_handler.setLevel(log_level)
|
| 65 |
+
console_handler.setFormatter(formatter)
|
| 66 |
+
console_handler.addFilter(mcp_filter)
|
| 67 |
|
| 68 |
# 文件处理器
|
| 69 |
file_handler = RotatingFileHandler(
|
| 70 |
log_file, maxBytes=10 * 1024 * 1024, backupCount=5, encoding="utf-8"
|
| 71 |
)
|
| 72 |
file_handler.setLevel(log_level)
|
| 73 |
+
file_handler.setFormatter(formatter)
|
| 74 |
+
file_handler.addFilter(mcp_filter)
|
| 75 |
|
| 76 |
+
# 添加处理器到根日志器
|
| 77 |
self.logger.addHandler(console_handler)
|
| 78 |
self.logger.addHandler(file_handler)
|
| 79 |
|
| 80 |
+
# 配置第三方库日志级别,避免过多调试信息
|
| 81 |
+
logging.getLogger("asyncio").setLevel(logging.WARNING)
|
| 82 |
+
logging.getLogger("uvicorn").setLevel(logging.INFO)
|
| 83 |
+
logging.getLogger("fastapi").setLevel(logging.INFO)
|
| 84 |
+
logging.getLogger("aiomysql").setLevel(logging.WARNING)
|
| 85 |
+
|
| 86 |
+
# FastMCP 相关日志 - 关闭
|
| 87 |
+
logging.getLogger("mcp").setLevel(logging.CRITICAL)
|
| 88 |
+
logging.getLogger("fastmcp").setLevel(logging.CRITICAL)
|
| 89 |
+
|
| 90 |
LoggerManager._initialized = True
|
| 91 |
|
| 92 |
def debug(self, msg: str) -> None:
|
app/core/storage.py
ADDED
|
@@ -0,0 +1,490 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""存储抽象层 - 支持文件、MySQL和Redis存储"""
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
import json
|
| 5 |
+
import toml
|
| 6 |
+
import asyncio
|
| 7 |
+
import warnings
|
| 8 |
+
import aiofiles
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
from typing import Dict, Any, Optional, Literal
|
| 11 |
+
from abc import ABC, abstractmethod
|
| 12 |
+
from urllib.parse import urlparse, unquote
|
| 13 |
+
|
| 14 |
+
from app.core.logger import logger
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
StorageMode = Literal["file", "mysql", "redis"]
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class BaseStorage(ABC):
|
| 21 |
+
"""存储基类"""
|
| 22 |
+
|
| 23 |
+
@abstractmethod
|
| 24 |
+
async def init_db(self) -> None:
|
| 25 |
+
"""初始化数据库"""
|
| 26 |
+
pass
|
| 27 |
+
|
| 28 |
+
@abstractmethod
|
| 29 |
+
async def load_tokens(self) -> Dict[str, Any]:
|
| 30 |
+
"""加载token数据"""
|
| 31 |
+
pass
|
| 32 |
+
|
| 33 |
+
@abstractmethod
|
| 34 |
+
async def save_tokens(self, data: Dict[str, Any]) -> None:
|
| 35 |
+
"""保存token数据"""
|
| 36 |
+
pass
|
| 37 |
+
|
| 38 |
+
@abstractmethod
|
| 39 |
+
async def load_config(self) -> Dict[str, Any]:
|
| 40 |
+
"""加载配置数据"""
|
| 41 |
+
pass
|
| 42 |
+
|
| 43 |
+
@abstractmethod
|
| 44 |
+
async def save_config(self, data: Dict[str, Any]) -> None:
|
| 45 |
+
"""保存配置数据"""
|
| 46 |
+
pass
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
class FileStorage(BaseStorage):
|
| 50 |
+
"""文件存储实现"""
|
| 51 |
+
|
| 52 |
+
def __init__(self, data_dir: Path):
|
| 53 |
+
self.data_dir = data_dir
|
| 54 |
+
self.token_file = data_dir / "token.json"
|
| 55 |
+
self.config_file = data_dir / "setting.toml"
|
| 56 |
+
self._token_lock = asyncio.Lock()
|
| 57 |
+
self._config_lock = asyncio.Lock()
|
| 58 |
+
|
| 59 |
+
async def init_db(self) -> None:
|
| 60 |
+
"""初始化文件存储"""
|
| 61 |
+
self.data_dir.mkdir(parents=True, exist_ok=True)
|
| 62 |
+
|
| 63 |
+
# 初始化token文件
|
| 64 |
+
if not self.token_file.exists():
|
| 65 |
+
await self._write_file(self.token_file, json.dumps({"sso": {}, "ssoSuper": {}}, indent=2, ensure_ascii=False))
|
| 66 |
+
logger.info("[Storage] 创建新的token文件")
|
| 67 |
+
|
| 68 |
+
# 初始化配置文件
|
| 69 |
+
if not self.config_file.exists():
|
| 70 |
+
default_config = {
|
| 71 |
+
"global": {"api_keys": [], "admin_username": "admin", "admin_password": "admin"},
|
| 72 |
+
"grok": {"proxy_url": "", "cf_clearance": "", "x_statsig_id": ""}
|
| 73 |
+
}
|
| 74 |
+
await self._write_file(self.config_file, toml.dumps(default_config))
|
| 75 |
+
logger.info("[Storage] 创建新的配置文件")
|
| 76 |
+
|
| 77 |
+
async def _read_file(self, file_path: Path) -> str:
|
| 78 |
+
"""读取文件内容"""
|
| 79 |
+
async with aiofiles.open(file_path, "r", encoding="utf-8") as f:
|
| 80 |
+
return await f.read()
|
| 81 |
+
|
| 82 |
+
async def _write_file(self, file_path: Path, content: str) -> None:
|
| 83 |
+
"""写入文件内容"""
|
| 84 |
+
async with aiofiles.open(file_path, "w", encoding="utf-8") as f:
|
| 85 |
+
await f.write(content)
|
| 86 |
+
|
| 87 |
+
async def _load_json(self, file_path: Path, default: Dict[str, Any], lock: asyncio.Lock) -> Dict[str, Any]:
|
| 88 |
+
"""加载JSON文件"""
|
| 89 |
+
try:
|
| 90 |
+
async with lock:
|
| 91 |
+
if not file_path.exists():
|
| 92 |
+
return default
|
| 93 |
+
return json.loads(await self._read_file(file_path))
|
| 94 |
+
except Exception as e:
|
| 95 |
+
logger.error(f"[Storage] 加载{file_path.name}失败: {e}")
|
| 96 |
+
return default
|
| 97 |
+
|
| 98 |
+
async def _save_json(self, file_path: Path, data: Dict[str, Any], lock: asyncio.Lock) -> None:
|
| 99 |
+
"""保存JSON文件"""
|
| 100 |
+
try:
|
| 101 |
+
async with lock:
|
| 102 |
+
await self._write_file(file_path, json.dumps(data, indent=2, ensure_ascii=False))
|
| 103 |
+
except Exception as e:
|
| 104 |
+
logger.error(f"[Storage] 保存{file_path.name}失败: {e}")
|
| 105 |
+
raise
|
| 106 |
+
|
| 107 |
+
async def _load_toml(self, file_path: Path, default: Dict[str, Any], lock: asyncio.Lock) -> Dict[str, Any]:
|
| 108 |
+
"""加载TOML文件"""
|
| 109 |
+
try:
|
| 110 |
+
async with lock:
|
| 111 |
+
if not file_path.exists():
|
| 112 |
+
return default
|
| 113 |
+
return toml.loads(await self._read_file(file_path))
|
| 114 |
+
except Exception as e:
|
| 115 |
+
logger.error(f"[Storage] 加载{file_path.name}失败: {e}")
|
| 116 |
+
return default
|
| 117 |
+
|
| 118 |
+
async def _save_toml(self, file_path: Path, data: Dict[str, Any], lock: asyncio.Lock) -> None:
|
| 119 |
+
"""保存TOML文件"""
|
| 120 |
+
try:
|
| 121 |
+
async with lock:
|
| 122 |
+
await self._write_file(file_path, toml.dumps(data))
|
| 123 |
+
except Exception as e:
|
| 124 |
+
logger.error(f"[Storage] 保存{file_path.name}失败: {e}")
|
| 125 |
+
raise
|
| 126 |
+
|
| 127 |
+
async def load_tokens(self) -> Dict[str, Any]:
|
| 128 |
+
"""加载token数据"""
|
| 129 |
+
return await self._load_json(self.token_file, {"sso": {}, "ssoSuper": {}}, self._token_lock)
|
| 130 |
+
|
| 131 |
+
async def save_tokens(self, data: Dict[str, Any]) -> None:
|
| 132 |
+
"""保存token数据"""
|
| 133 |
+
await self._save_json(self.token_file, data, self._token_lock)
|
| 134 |
+
|
| 135 |
+
async def load_config(self) -> Dict[str, Any]:
|
| 136 |
+
"""加载配置数据"""
|
| 137 |
+
return await self._load_toml(self.config_file, {"global": {}, "grok": {}}, self._config_lock)
|
| 138 |
+
|
| 139 |
+
async def save_config(self, data: Dict[str, Any]) -> None:
|
| 140 |
+
"""保存配置数据"""
|
| 141 |
+
await self._save_toml(self.config_file, data, self._config_lock)
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
class MysqlStorage(BaseStorage):
|
| 145 |
+
"""MySQL存储实现"""
|
| 146 |
+
|
| 147 |
+
def __init__(self, database_url: str, data_dir: Path):
|
| 148 |
+
self.database_url = database_url
|
| 149 |
+
self.data_dir = data_dir
|
| 150 |
+
self._pool = None
|
| 151 |
+
self._file = FileStorage(data_dir)
|
| 152 |
+
|
| 153 |
+
async def init_db(self) -> None:
|
| 154 |
+
"""初始化MySQL"""
|
| 155 |
+
try:
|
| 156 |
+
import aiomysql
|
| 157 |
+
parsed = self._parse_url(self.database_url)
|
| 158 |
+
logger.info(f"[Storage] 解析数据库连接: {parsed['user']}@{parsed['host']}:{parsed['port']}/{parsed['db']}")
|
| 159 |
+
|
| 160 |
+
# 创建数据库
|
| 161 |
+
await self._create_db(parsed)
|
| 162 |
+
|
| 163 |
+
# 创建连接池
|
| 164 |
+
self._pool = await aiomysql.create_pool(
|
| 165 |
+
host=parsed['host'], port=parsed['port'], user=parsed['user'],
|
| 166 |
+
password=parsed['password'], db=parsed['db'], charset="utf8mb4",
|
| 167 |
+
autocommit=True, maxsize=10
|
| 168 |
+
)
|
| 169 |
+
|
| 170 |
+
# 创建表
|
| 171 |
+
await self._create_tables()
|
| 172 |
+
|
| 173 |
+
# 初始化文件存储和同步数据
|
| 174 |
+
await self._file.init_db()
|
| 175 |
+
await self._sync_data()
|
| 176 |
+
|
| 177 |
+
except ImportError:
|
| 178 |
+
raise Exception("aiomysql未安装")
|
| 179 |
+
except Exception as e:
|
| 180 |
+
logger.error(f"[Storage] MySQL初始化失败: {e}")
|
| 181 |
+
raise
|
| 182 |
+
|
| 183 |
+
def _parse_url(self, url: str) -> Dict[str, Any]:
|
| 184 |
+
"""解析数据库URL"""
|
| 185 |
+
parsed = urlparse(url)
|
| 186 |
+
return {
|
| 187 |
+
'user': unquote(parsed.username) if parsed.username else "",
|
| 188 |
+
'password': unquote(parsed.password) if parsed.password else "",
|
| 189 |
+
'host': parsed.hostname,
|
| 190 |
+
'port': parsed.port or 3306,
|
| 191 |
+
'db': parsed.path[1:] if parsed.path else "grok2api"
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
async def _create_db(self, parsed: Dict[str, Any]) -> None:
|
| 195 |
+
"""创建数据库"""
|
| 196 |
+
import aiomysql
|
| 197 |
+
temp_pool = await aiomysql.create_pool(
|
| 198 |
+
host=parsed['host'], port=parsed['port'], user=parsed['user'],
|
| 199 |
+
password=parsed['password'], charset="utf8mb4", autocommit=True, maxsize=1
|
| 200 |
+
)
|
| 201 |
+
|
| 202 |
+
try:
|
| 203 |
+
async with temp_pool.acquire() as conn:
|
| 204 |
+
async with conn.cursor() as cursor:
|
| 205 |
+
with warnings.catch_warnings():
|
| 206 |
+
warnings.filterwarnings('ignore', message='.*database exists')
|
| 207 |
+
await cursor.execute(
|
| 208 |
+
f"CREATE DATABASE IF NOT EXISTS `{parsed['db']}` "
|
| 209 |
+
f"CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"
|
| 210 |
+
)
|
| 211 |
+
logger.info(f"[Storage] MySQL数据库 '{parsed['db']}' 已就绪")
|
| 212 |
+
finally:
|
| 213 |
+
temp_pool.close()
|
| 214 |
+
await temp_pool.wait_closed()
|
| 215 |
+
|
| 216 |
+
async def _create_tables(self) -> None:
|
| 217 |
+
"""创建表"""
|
| 218 |
+
tables = {
|
| 219 |
+
"grok_tokens": """
|
| 220 |
+
CREATE TABLE IF NOT EXISTS grok_tokens (
|
| 221 |
+
id INT AUTO_INCREMENT PRIMARY KEY,
|
| 222 |
+
data JSON NOT NULL,
|
| 223 |
+
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
| 224 |
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
| 225 |
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
| 226 |
+
""",
|
| 227 |
+
"grok_settings": """
|
| 228 |
+
CREATE TABLE IF NOT EXISTS grok_settings (
|
| 229 |
+
id INT AUTO_INCREMENT PRIMARY KEY,
|
| 230 |
+
data JSON NOT NULL,
|
| 231 |
+
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
| 232 |
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
| 233 |
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
| 234 |
+
"""
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
async with self._pool.acquire() as conn:
|
| 238 |
+
async with conn.cursor() as cursor:
|
| 239 |
+
with warnings.catch_warnings():
|
| 240 |
+
warnings.filterwarnings('ignore', message='.*already exists')
|
| 241 |
+
for sql in tables.values():
|
| 242 |
+
await cursor.execute(sql)
|
| 243 |
+
logger.info("[Storage] MySQL表创建/验证成功")
|
| 244 |
+
|
| 245 |
+
async def _sync_data(self) -> None:
|
| 246 |
+
"""同步数据"""
|
| 247 |
+
try:
|
| 248 |
+
for table, key in [("grok_tokens", "sso"), ("grok_settings", "global")]:
|
| 249 |
+
data = await self._load_db(table)
|
| 250 |
+
if data:
|
| 251 |
+
if table == "grok_tokens":
|
| 252 |
+
await self._file.save_tokens(data)
|
| 253 |
+
else:
|
| 254 |
+
await self._file.save_config(data)
|
| 255 |
+
logger.info(f"[Storage] {table.split('_')[1]}数据已从数据库同步到文件")
|
| 256 |
+
else:
|
| 257 |
+
if table == "grok_tokens":
|
| 258 |
+
file_data = await self._file.load_tokens()
|
| 259 |
+
if file_data.get(key) or file_data.get("ssoSuper"):
|
| 260 |
+
await self._save_db(table, file_data)
|
| 261 |
+
logger.info("[Storage] Token数据已从��件初始化到数据库")
|
| 262 |
+
else:
|
| 263 |
+
file_data = await self._file.load_config()
|
| 264 |
+
if file_data.get(key) or file_data.get("grok"):
|
| 265 |
+
await self._save_db(table, file_data)
|
| 266 |
+
logger.info("[Storage] 配置数据已从文件初始化到数据库")
|
| 267 |
+
except Exception as e:
|
| 268 |
+
logger.warning(f"[Storage] 数据同步失败: {e}")
|
| 269 |
+
|
| 270 |
+
async def _load_db(self, table: str) -> Optional[Dict[str, Any]]:
|
| 271 |
+
"""从数据库加载数据"""
|
| 272 |
+
try:
|
| 273 |
+
async with self._pool.acquire() as conn:
|
| 274 |
+
async with conn.cursor() as cursor:
|
| 275 |
+
await cursor.execute(f"SELECT data FROM {table} ORDER BY id DESC LIMIT 1")
|
| 276 |
+
result = await cursor.fetchone()
|
| 277 |
+
return json.loads(result[0]) if result else None
|
| 278 |
+
except Exception as e:
|
| 279 |
+
logger.error(f"[Storage] 从数据库加载{table}失败: {e}")
|
| 280 |
+
return None
|
| 281 |
+
|
| 282 |
+
async def _save_db(self, table: str, data: Dict[str, Any]) -> None:
|
| 283 |
+
"""保存数据到数据库"""
|
| 284 |
+
try:
|
| 285 |
+
async with self._pool.acquire() as conn:
|
| 286 |
+
async with conn.cursor() as cursor:
|
| 287 |
+
json_data = json.dumps(data, ensure_ascii=False)
|
| 288 |
+
await cursor.execute(f"SELECT id FROM {table} ORDER BY id DESC LIMIT 1")
|
| 289 |
+
result = await cursor.fetchone()
|
| 290 |
+
|
| 291 |
+
if result:
|
| 292 |
+
await cursor.execute(f"UPDATE {table} SET data = %s WHERE id = %s", (json_data, result[0]))
|
| 293 |
+
else:
|
| 294 |
+
await cursor.execute(f"INSERT INTO {table} (data) VALUES (%s)", (json_data,))
|
| 295 |
+
except Exception as e:
|
| 296 |
+
logger.error(f"[Storage] 保存数据到{table}失败: {e}")
|
| 297 |
+
raise
|
| 298 |
+
|
| 299 |
+
async def load_tokens(self) -> Dict[str, Any]:
|
| 300 |
+
"""加载token数据"""
|
| 301 |
+
return await self._file.load_tokens()
|
| 302 |
+
|
| 303 |
+
async def save_tokens(self, data: Dict[str, Any]) -> None:
|
| 304 |
+
"""保存token数据"""
|
| 305 |
+
await self._file.save_tokens(data)
|
| 306 |
+
await self._save_db("grok_tokens", data)
|
| 307 |
+
|
| 308 |
+
async def load_config(self) -> Dict[str, Any]:
|
| 309 |
+
"""加载配置数据"""
|
| 310 |
+
return await self._file.load_config()
|
| 311 |
+
|
| 312 |
+
async def save_config(self, data: Dict[str, Any]) -> None:
|
| 313 |
+
"""保存配置数据"""
|
| 314 |
+
await self._file.save_config(data)
|
| 315 |
+
await self._save_db("grok_settings", data)
|
| 316 |
+
|
| 317 |
+
async def close(self) -> None:
|
| 318 |
+
"""关闭连接"""
|
| 319 |
+
if self._pool:
|
| 320 |
+
self._pool.close()
|
| 321 |
+
await self._pool.wait_closed()
|
| 322 |
+
logger.info("[Storage] MySQL连接池已关闭")
|
| 323 |
+
|
| 324 |
+
|
| 325 |
+
class RedisStorage(BaseStorage):
|
| 326 |
+
"""Redis存储实现"""
|
| 327 |
+
|
| 328 |
+
def __init__(self, redis_url: str, data_dir: Path):
|
| 329 |
+
self.redis_url = redis_url
|
| 330 |
+
self.data_dir = data_dir
|
| 331 |
+
self._redis = None
|
| 332 |
+
self._file = FileStorage(data_dir)
|
| 333 |
+
|
| 334 |
+
async def init_db(self) -> None:
|
| 335 |
+
"""初始化Redis"""
|
| 336 |
+
try:
|
| 337 |
+
import redis.asyncio as redis
|
| 338 |
+
parsed = self._parse_url(self.redis_url)
|
| 339 |
+
logger.info(f"[Storage] 解析Redis URL: host={parsed['host']}, port={parsed['port']}, db={parsed.get('db', 0)}, username={parsed.get('username')}, password={'***' if parsed.get('password') else None}")
|
| 340 |
+
|
| 341 |
+
# 创建Redis连接
|
| 342 |
+
self._redis = redis.Redis(
|
| 343 |
+
host=parsed['host'], port=parsed['port'], password=parsed.get('password'),
|
| 344 |
+
username=parsed.get('username'), db=parsed.get('db', 0),
|
| 345 |
+
encoding="utf-8", decode_responses=True
|
| 346 |
+
)
|
| 347 |
+
|
| 348 |
+
# 测试连接
|
| 349 |
+
await self._redis.ping()
|
| 350 |
+
logger.info(f"[Storage] Redis连接成功: {parsed['host']}:{parsed['port']}/{parsed['db']}")
|
| 351 |
+
|
| 352 |
+
# 初始化文件存储和同步数据
|
| 353 |
+
await self._file.init_db()
|
| 354 |
+
await self._sync_data()
|
| 355 |
+
|
| 356 |
+
except ImportError:
|
| 357 |
+
raise Exception("redis未安装")
|
| 358 |
+
except Exception as e:
|
| 359 |
+
logger.error(f"[Storage] Redis初始化失败: {e}")
|
| 360 |
+
raise
|
| 361 |
+
|
| 362 |
+
def _parse_url(self, url: str) -> Dict[str, Any]:
|
| 363 |
+
"""解析Redis URL"""
|
| 364 |
+
if url.startswith('redis://'):
|
| 365 |
+
url = url[8:]
|
| 366 |
+
parsed = urlparse(f'//{url}')
|
| 367 |
+
|
| 368 |
+
result = {
|
| 369 |
+
'host': parsed.hostname or 'localhost',
|
| 370 |
+
'port': parsed.port or 6379,
|
| 371 |
+
'db': int(parsed.path.lstrip('/')) if parsed.path and parsed.path != '/' else 0,
|
| 372 |
+
'username': unquote(parsed.username) if parsed.username else None,
|
| 373 |
+
'password': unquote(parsed.password) if parsed.password else None
|
| 374 |
+
}
|
| 375 |
+
|
| 376 |
+
# Redis 6+ 默认用户名
|
| 377 |
+
if result['password'] and not result['username']:
|
| 378 |
+
result['username'] = 'default'
|
| 379 |
+
|
| 380 |
+
return result
|
| 381 |
+
|
| 382 |
+
async def _sync_data(self) -> None:
|
| 383 |
+
"""同步数据"""
|
| 384 |
+
try:
|
| 385 |
+
for key, file_func, key_name in [
|
| 386 |
+
("grok:tokens", self._file.load_tokens, "sso"),
|
| 387 |
+
("grok:settings", self._file.load_config, "global")
|
| 388 |
+
]:
|
| 389 |
+
data = await self._redis.get(key)
|
| 390 |
+
if data:
|
| 391 |
+
parsed = json.loads(data)
|
| 392 |
+
if key == "grok:tokens":
|
| 393 |
+
await self._file.save_tokens(parsed)
|
| 394 |
+
else:
|
| 395 |
+
await self._file.save_config(parsed)
|
| 396 |
+
logger.info(f"[Storage] {key.split(':')[1]}数据已从Redis同步到文件")
|
| 397 |
+
else:
|
| 398 |
+
file_data = await file_func()
|
| 399 |
+
if file_data.get(key_name) or (key == "grok:tokens" and file_data.get("ssoSuper")):
|
| 400 |
+
json_data = json.dumps(file_data, ensure_ascii=False)
|
| 401 |
+
await self._redis.set(key, json_data)
|
| 402 |
+
logger.info(f"[Storage] {key.split(':')[1]}数据已从文件初始化到Redis")
|
| 403 |
+
except Exception as e:
|
| 404 |
+
logger.warning(f"[Storage] 数据同步失败: {e}")
|
| 405 |
+
|
| 406 |
+
async def _save_redis(self, key: str, data: Dict[str, Any]) -> None:
|
| 407 |
+
"""保存到Redis"""
|
| 408 |
+
try:
|
| 409 |
+
await self._redis.set(key, json.dumps(data, ensure_ascii=False))
|
| 410 |
+
except Exception as e:
|
| 411 |
+
logger.error(f"[Storage] 保存到Redis失败: {e}")
|
| 412 |
+
raise
|
| 413 |
+
|
| 414 |
+
async def load_tokens(self) -> Dict[str, Any]:
|
| 415 |
+
"""加载token数据"""
|
| 416 |
+
return await self._file.load_tokens()
|
| 417 |
+
|
| 418 |
+
async def save_tokens(self, data: Dict[str, Any]) -> None:
|
| 419 |
+
"""保存token数据"""
|
| 420 |
+
await self._file.save_tokens(data)
|
| 421 |
+
await self._save_redis("grok:tokens", data)
|
| 422 |
+
|
| 423 |
+
async def load_config(self) -> Dict[str, Any]:
|
| 424 |
+
"""加载配置数据"""
|
| 425 |
+
return await self._file.load_config()
|
| 426 |
+
|
| 427 |
+
async def save_config(self, data: Dict[str, Any]) -> None:
|
| 428 |
+
"""保存配置数据"""
|
| 429 |
+
await self._file.save_config(data)
|
| 430 |
+
await self._save_redis("grok:settings", data)
|
| 431 |
+
|
| 432 |
+
async def close(self) -> None:
|
| 433 |
+
"""关闭连接"""
|
| 434 |
+
if self._redis:
|
| 435 |
+
await self._redis.close()
|
| 436 |
+
logger.info("[Storage] Redis连接已关闭")
|
| 437 |
+
|
| 438 |
+
|
| 439 |
+
class StorageManager:
|
| 440 |
+
"""存储管理器"""
|
| 441 |
+
|
| 442 |
+
_instance: Optional['StorageManager'] = None
|
| 443 |
+
_storage: Optional[BaseStorage] = None
|
| 444 |
+
_initialized: bool = False
|
| 445 |
+
|
| 446 |
+
def __new__(cls):
|
| 447 |
+
if cls._instance is None:
|
| 448 |
+
cls._instance = super().__new__(cls)
|
| 449 |
+
return cls._instance
|
| 450 |
+
|
| 451 |
+
async def init(self) -> None:
|
| 452 |
+
"""初始化存储"""
|
| 453 |
+
if self._initialized:
|
| 454 |
+
return
|
| 455 |
+
|
| 456 |
+
mode = os.getenv("STORAGE_MODE", "file").lower()
|
| 457 |
+
url = os.getenv("DATABASE_URL", "")
|
| 458 |
+
data_dir = Path(__file__).parents[2] / "data"
|
| 459 |
+
|
| 460 |
+
storage_classes = {
|
| 461 |
+
"mysql": MysqlStorage,
|
| 462 |
+
"redis": RedisStorage,
|
| 463 |
+
"file": FileStorage
|
| 464 |
+
}
|
| 465 |
+
|
| 466 |
+
if mode in ("mysql", "redis") and not url:
|
| 467 |
+
raise ValueError(f"{mode.upper()}模式需要DATABASE_URL环境变量")
|
| 468 |
+
|
| 469 |
+
storage_class = storage_classes.get(mode, FileStorage)
|
| 470 |
+
self._storage = storage_class(url, data_dir) if mode != "file" else storage_class(data_dir)
|
| 471 |
+
|
| 472 |
+
await self._storage.init_db()
|
| 473 |
+
self._initialized = True
|
| 474 |
+
logger.info(f"[Storage] 使用{mode}存储模式")
|
| 475 |
+
logger.info("[Storage] 存储管理器初始化完成")
|
| 476 |
+
|
| 477 |
+
def get_storage(self) -> BaseStorage:
|
| 478 |
+
"""获取存储实例"""
|
| 479 |
+
if not self._initialized or not self._storage:
|
| 480 |
+
raise RuntimeError("StorageManager未初始化")
|
| 481 |
+
return self._storage
|
| 482 |
+
|
| 483 |
+
async def close(self) -> None:
|
| 484 |
+
"""关闭存储"""
|
| 485 |
+
if self._storage and hasattr(self._storage, 'close'):
|
| 486 |
+
await self._storage.close()
|
| 487 |
+
|
| 488 |
+
|
| 489 |
+
# 全局存储管理器实例
|
| 490 |
+
storage_manager = StorageManager()
|
app/services/grok/cache.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
"""缓存服务模块"""
|
| 2 |
|
| 3 |
import asyncio
|
|
|
|
| 4 |
from pathlib import Path
|
| 5 |
from typing import Optional
|
| 6 |
from curl_cffi.requests import AsyncSession
|
|
@@ -19,42 +20,20 @@ class CacheService:
|
|
| 19 |
self.cache_dir = Path(f"data/temp/{cache_type}")
|
| 20 |
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
| 21 |
|
| 22 |
-
|
| 23 |
-
def _get_cache_filename(file_path: str) -> str:
|
| 24 |
-
"""将文件路径转换为缓存文件名"""
|
| 25 |
-
filename = file_path.lstrip('/').replace('/', '-')
|
| 26 |
-
return filename
|
| 27 |
-
|
| 28 |
-
def _get_cache_path(self, file_path: str) -> Path:
|
| 29 |
"""获取缓存文件的完整路径"""
|
| 30 |
-
filename =
|
| 31 |
return self.cache_dir / filename
|
| 32 |
|
| 33 |
async def download_file(self, file_path: str, auth_token: str, timeout: float = 30.0) -> Optional[Path]:
|
| 34 |
-
"""下载并缓存文件
|
| 35 |
-
|
| 36 |
-
Args:
|
| 37 |
-
file_path: 文件路径,如 /users/xxx/generated/xxx/file.jpg
|
| 38 |
-
auth_token: 认证令牌
|
| 39 |
-
timeout: 下载超时时间(秒)
|
| 40 |
-
|
| 41 |
-
Returns:
|
| 42 |
-
缓存文件路径,下载失败返回 None
|
| 43 |
-
"""
|
| 44 |
-
cache_path = self._get_cache_path(file_path)
|
| 45 |
-
|
| 46 |
if cache_path.exists():
|
| 47 |
logger.debug(f"[{self.cache_type.upper()}Cache] 文件已缓存: {cache_path}")
|
| 48 |
return cache_path
|
| 49 |
|
| 50 |
-
file_url = f"https://assets.grok.com{file_path}"
|
| 51 |
-
|
| 52 |
try:
|
| 53 |
-
# 构建 Cookie
|
| 54 |
cf_clearance = setting.grok_config.get("cf_clearance", "")
|
| 55 |
-
cookie = f"{auth_token};{cf_clearance}" if cf_clearance else auth_token
|
| 56 |
-
|
| 57 |
-
# 构建请求头
|
| 58 |
headers = {
|
| 59 |
**get_dynamic_headers(pathname=file_path),
|
| 60 |
"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",
|
|
@@ -64,17 +43,20 @@ class CacheService:
|
|
| 64 |
"Sec-Fetch-User": "?1",
|
| 65 |
"Upgrade-Insecure-Requests": "1",
|
| 66 |
"Referer": "https://grok.com/",
|
| 67 |
-
"Cookie":
|
| 68 |
}
|
| 69 |
|
| 70 |
-
#
|
| 71 |
-
proxy_url = setting.
|
| 72 |
proxies = {"http": proxy_url, "https": proxy_url} if proxy_url else {}
|
|
|
|
|
|
|
|
|
|
| 73 |
|
| 74 |
async with AsyncSession() as session:
|
| 75 |
-
logger.debug(f"[{self.cache_type.upper()}Cache] 开始下载: {
|
| 76 |
response = await session.get(
|
| 77 |
-
|
| 78 |
headers=headers,
|
| 79 |
proxies=proxies,
|
| 80 |
timeout=timeout,
|
|
@@ -82,70 +64,44 @@ class CacheService:
|
|
| 82 |
impersonate="chrome133a"
|
| 83 |
)
|
| 84 |
response.raise_for_status()
|
| 85 |
-
|
| 86 |
cache_path.write_bytes(response.content)
|
| 87 |
logger.debug(f"[{self.cache_type.upper()}Cache] 文件已缓存: {cache_path} ({len(response.content)} bytes)")
|
| 88 |
-
|
| 89 |
asyncio.create_task(self.cleanup_cache())
|
| 90 |
-
|
| 91 |
return cache_path
|
| 92 |
-
|
| 93 |
except Exception as e:
|
| 94 |
logger.error(f"[{self.cache_type.upper()}Cache] 下载文件失败: {e}")
|
| 95 |
return None
|
| 96 |
|
| 97 |
-
def
|
| 98 |
-
"""获取缓存的文件路径
|
| 99 |
-
|
| 100 |
-
Args:
|
| 101 |
-
file_path: 文件路径
|
| 102 |
-
|
| 103 |
-
Returns:
|
| 104 |
-
缓存文件路径,不存在返回 None
|
| 105 |
-
"""
|
| 106 |
-
cache_path = self._get_cache_path(file_path)
|
| 107 |
return cache_path if cache_path.exists() else None
|
| 108 |
|
| 109 |
async def cleanup_cache(self):
|
| 110 |
"""清理缓存目录,确保不超过配置的大小限制"""
|
| 111 |
try:
|
| 112 |
-
|
| 113 |
-
config_key = f"{self.cache_type}_cache_max_size_mb"
|
| 114 |
-
max_size_mb = setting.global_config.get(config_key, 500)
|
| 115 |
max_size_bytes = max_size_mb * 1024 * 1024
|
| 116 |
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
total_size =
|
| 120 |
|
| 121 |
-
for file_path in self.cache_dir.glob("*"):
|
| 122 |
-
if file_path.is_file():
|
| 123 |
-
size = file_path.stat().st_size
|
| 124 |
-
mtime = file_path.stat().st_mtime
|
| 125 |
-
files.append((file_path, size, mtime))
|
| 126 |
-
total_size += size
|
| 127 |
-
|
| 128 |
-
# 如果总大小未超限,无需清理
|
| 129 |
if total_size <= max_size_bytes:
|
| 130 |
logger.debug(f"[{self.cache_type.upper()}Cache] 缓存大小 {total_size / 1024 / 1024:.2f}MB,未超限")
|
| 131 |
return
|
| 132 |
|
| 133 |
logger.info(f"[{self.cache_type.upper()}Cache] 缓存大小 {total_size / 1024 / 1024:.2f}MB 超过限制 {max_size_mb}MB,开始清理")
|
| 134 |
-
|
| 135 |
-
# 按修改时间排序
|
| 136 |
files.sort(key=lambda x: x[2])
|
| 137 |
|
| 138 |
-
# 删除文件直到总大小低于限制
|
| 139 |
for file_path, size, _ in files:
|
| 140 |
if total_size <= max_size_bytes:
|
| 141 |
break
|
| 142 |
-
|
| 143 |
file_path.unlink()
|
| 144 |
total_size -= size
|
| 145 |
logger.debug(f"[{self.cache_type.upper()}Cache] 已删除缓存文件: {file_path}")
|
| 146 |
|
| 147 |
logger.info(f"[{self.cache_type.upper()}Cache] 缓存清理完成,当前大小 {total_size / 1024 / 1024:.2f}MB")
|
| 148 |
-
|
| 149 |
except Exception as e:
|
| 150 |
logger.error(f"[{self.cache_type.upper()}Cache] 清理缓存失败: {e}")
|
| 151 |
|
|
@@ -157,27 +113,51 @@ class ImageCacheService(CacheService):
|
|
| 157 |
super().__init__("image")
|
| 158 |
|
| 159 |
async def download_image(self, image_path: str, auth_token: str) -> Optional[Path]:
|
| 160 |
-
"""下载并缓存图片
|
|
|
|
| 161 |
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
"""
|
| 169 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 170 |
|
| 171 |
-
def
|
| 172 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 173 |
|
| 174 |
-
|
| 175 |
-
image_path: 图片路径
|
| 176 |
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 181 |
|
| 182 |
|
| 183 |
class VideoCacheService(CacheService):
|
|
@@ -187,30 +167,14 @@ class VideoCacheService(CacheService):
|
|
| 187 |
super().__init__("video")
|
| 188 |
|
| 189 |
async def download_video(self, video_path: str, auth_token: str) -> Optional[Path]:
|
| 190 |
-
"""下载并缓存视频
|
| 191 |
-
|
| 192 |
-
Args:
|
| 193 |
-
video_path: 视频路径,如 /users/xxx/generated/xxx/video.mp4
|
| 194 |
-
auth_token: 认证令牌
|
| 195 |
-
|
| 196 |
-
Returns:
|
| 197 |
-
缓存文件路径,下载失败返回 None
|
| 198 |
-
"""
|
| 199 |
return await self.download_file(video_path, auth_token, timeout=60.0)
|
| 200 |
|
| 201 |
-
def
|
| 202 |
-
"""获取缓存的视频路径
|
| 203 |
-
|
| 204 |
-
Args:
|
| 205 |
-
video_path: 视频路径
|
| 206 |
-
|
| 207 |
-
Returns:
|
| 208 |
-
缓存文件路��,不存在返回 None
|
| 209 |
-
"""
|
| 210 |
-
return self.get_cached_file(video_path)
|
| 211 |
|
| 212 |
|
| 213 |
# 全局实例
|
| 214 |
image_cache_service = ImageCacheService()
|
| 215 |
video_cache_service = VideoCacheService()
|
| 216 |
-
|
|
|
|
| 1 |
"""缓存服务模块"""
|
| 2 |
|
| 3 |
import asyncio
|
| 4 |
+
import base64
|
| 5 |
from pathlib import Path
|
| 6 |
from typing import Optional
|
| 7 |
from curl_cffi.requests import AsyncSession
|
|
|
|
| 20 |
self.cache_dir = Path(f"data/temp/{cache_type}")
|
| 21 |
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
| 22 |
|
| 23 |
+
def _cache_path(self, file_path: str) -> Path:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
"""获取缓存文件的完整路径"""
|
| 25 |
+
filename = file_path.lstrip('/').replace('/', '-')
|
| 26 |
return self.cache_dir / filename
|
| 27 |
|
| 28 |
async def download_file(self, file_path: str, auth_token: str, timeout: float = 30.0) -> Optional[Path]:
|
| 29 |
+
"""下载并缓存文件"""
|
| 30 |
+
cache_path = self._cache_path(file_path)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
if cache_path.exists():
|
| 32 |
logger.debug(f"[{self.cache_type.upper()}Cache] 文件已缓存: {cache_path}")
|
| 33 |
return cache_path
|
| 34 |
|
|
|
|
|
|
|
| 35 |
try:
|
|
|
|
| 36 |
cf_clearance = setting.grok_config.get("cf_clearance", "")
|
|
|
|
|
|
|
|
|
|
| 37 |
headers = {
|
| 38 |
**get_dynamic_headers(pathname=file_path),
|
| 39 |
"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",
|
|
|
|
| 43 |
"Sec-Fetch-User": "?1",
|
| 44 |
"Upgrade-Insecure-Requests": "1",
|
| 45 |
"Referer": "https://grok.com/",
|
| 46 |
+
"Cookie": f"{auth_token};{cf_clearance}" if cf_clearance else auth_token
|
| 47 |
}
|
| 48 |
|
| 49 |
+
# 使用缓存代理
|
| 50 |
+
proxy_url = setting.get_cache_proxy()
|
| 51 |
proxies = {"http": proxy_url, "https": proxy_url} if proxy_url else {}
|
| 52 |
+
|
| 53 |
+
if proxy_url:
|
| 54 |
+
logger.debug(f"[{self.cache_type.upper()}Cache] 使用缓存代理: {proxy_url.split('@')[-1] if '@' in proxy_url else proxy_url}")
|
| 55 |
|
| 56 |
async with AsyncSession() as session:
|
| 57 |
+
logger.debug(f"[{self.cache_type.upper()}Cache] 开始下载: https://assets.grok.com{file_path}")
|
| 58 |
response = await session.get(
|
| 59 |
+
f"https://assets.grok.com{file_path}",
|
| 60 |
headers=headers,
|
| 61 |
proxies=proxies,
|
| 62 |
timeout=timeout,
|
|
|
|
| 64 |
impersonate="chrome133a"
|
| 65 |
)
|
| 66 |
response.raise_for_status()
|
|
|
|
| 67 |
cache_path.write_bytes(response.content)
|
| 68 |
logger.debug(f"[{self.cache_type.upper()}Cache] 文件已缓存: {cache_path} ({len(response.content)} bytes)")
|
|
|
|
| 69 |
asyncio.create_task(self.cleanup_cache())
|
|
|
|
| 70 |
return cache_path
|
|
|
|
| 71 |
except Exception as e:
|
| 72 |
logger.error(f"[{self.cache_type.upper()}Cache] 下载文件失败: {e}")
|
| 73 |
return None
|
| 74 |
|
| 75 |
+
def get_cached(self, file_path: str) -> Optional[Path]:
|
| 76 |
+
"""获取缓存的文件路径"""
|
| 77 |
+
cache_path = self._cache_path(file_path)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
return cache_path if cache_path.exists() else None
|
| 79 |
|
| 80 |
async def cleanup_cache(self):
|
| 81 |
"""清理缓存目录,确保不超过配置的大小限制"""
|
| 82 |
try:
|
| 83 |
+
max_size_mb = setting.global_config.get(f"{self.cache_type}_cache_max_size_mb", 500)
|
|
|
|
|
|
|
| 84 |
max_size_bytes = max_size_mb * 1024 * 1024
|
| 85 |
|
| 86 |
+
files = [(fp, (stat := fp.stat()).st_size, stat.st_mtime)
|
| 87 |
+
for fp in self.cache_dir.glob("*") if fp.is_file()]
|
| 88 |
+
total_size = sum(size for _, size, _ in files)
|
| 89 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
if total_size <= max_size_bytes:
|
| 91 |
logger.debug(f"[{self.cache_type.upper()}Cache] 缓存大小 {total_size / 1024 / 1024:.2f}MB,未超限")
|
| 92 |
return
|
| 93 |
|
| 94 |
logger.info(f"[{self.cache_type.upper()}Cache] 缓存大小 {total_size / 1024 / 1024:.2f}MB 超过限制 {max_size_mb}MB,开始清理")
|
|
|
|
|
|
|
| 95 |
files.sort(key=lambda x: x[2])
|
| 96 |
|
|
|
|
| 97 |
for file_path, size, _ in files:
|
| 98 |
if total_size <= max_size_bytes:
|
| 99 |
break
|
|
|
|
| 100 |
file_path.unlink()
|
| 101 |
total_size -= size
|
| 102 |
logger.debug(f"[{self.cache_type.upper()}Cache] 已删除缓存文件: {file_path}")
|
| 103 |
|
| 104 |
logger.info(f"[{self.cache_type.upper()}Cache] 缓存清理完成,当前大小 {total_size / 1024 / 1024:.2f}MB")
|
|
|
|
| 105 |
except Exception as e:
|
| 106 |
logger.error(f"[{self.cache_type.upper()}Cache] 清理缓存失败: {e}")
|
| 107 |
|
|
|
|
| 113 |
super().__init__("image")
|
| 114 |
|
| 115 |
async def download_image(self, image_path: str, auth_token: str) -> Optional[Path]:
|
| 116 |
+
"""下载并缓存图片"""
|
| 117 |
+
return await self.download_file(image_path, auth_token, timeout=30.0)
|
| 118 |
|
| 119 |
+
def get_cached(self, image_path: str) -> Optional[Path]:
|
| 120 |
+
"""获取缓存的图片路径"""
|
| 121 |
+
return super().get_cached(image_path)
|
| 122 |
|
| 123 |
+
@staticmethod
|
| 124 |
+
def to_base64(image_path: Path) -> Optional[str]:
|
| 125 |
+
"""将图片转换为 base64 编码"""
|
| 126 |
+
try:
|
| 127 |
+
if not image_path.exists():
|
| 128 |
+
logger.error(f"[ImageCache] 图片文件不存在: {image_path}")
|
| 129 |
+
return None
|
| 130 |
+
|
| 131 |
+
with open(image_path, "rb") as f:
|
| 132 |
+
base64_data = base64.b64encode(f.read()).decode('utf-8')
|
| 133 |
+
|
| 134 |
+
mime_type = {'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png',
|
| 135 |
+
'.gif': 'image/gif', '.webp': 'image/webp'}.get(image_path.suffix.lower(), 'image/jpeg')
|
| 136 |
+
|
| 137 |
+
return f"data:{mime_type};base64,{base64_data}"
|
| 138 |
+
except Exception as e:
|
| 139 |
+
logger.error(f"[ImageCache] 图片转 base64 失败: {e}")
|
| 140 |
+
return None
|
| 141 |
|
| 142 |
+
async def download_base64(self, image_path: str, auth_token: str) -> Optional[str]:
|
| 143 |
+
"""下载图片并转换为 base64 编码(转换后立即删除缓存文件)"""
|
| 144 |
+
try:
|
| 145 |
+
cache_path = await self.download_file(image_path, auth_token, timeout=30.0)
|
| 146 |
+
if not cache_path:
|
| 147 |
+
return None
|
| 148 |
|
| 149 |
+
base64_str = self.to_base64(cache_path)
|
|
|
|
| 150 |
|
| 151 |
+
try:
|
| 152 |
+
cache_path.unlink()
|
| 153 |
+
logger.debug(f"[ImageCache] 已删除临时文件: {cache_path}")
|
| 154 |
+
except Exception as e:
|
| 155 |
+
logger.warning(f"[ImageCache] 删除临时文件失败: {e}")
|
| 156 |
+
|
| 157 |
+
return base64_str
|
| 158 |
+
except Exception as e:
|
| 159 |
+
logger.error(f"[ImageCache] 下载并转换 base64 失败: {e}")
|
| 160 |
+
return None
|
| 161 |
|
| 162 |
|
| 163 |
class VideoCacheService(CacheService):
|
|
|
|
| 167 |
super().__init__("video")
|
| 168 |
|
| 169 |
async def download_video(self, video_path: str, auth_token: str) -> Optional[Path]:
|
| 170 |
+
"""下载并缓存视频"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
return await self.download_file(video_path, auth_token, timeout=60.0)
|
| 172 |
|
| 173 |
+
def get_cached(self, video_path: str) -> Optional[Path]:
|
| 174 |
+
"""获取缓存的视频路径"""
|
| 175 |
+
return super().get_cached(video_path)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 176 |
|
| 177 |
|
| 178 |
# 全局实例
|
| 179 |
image_cache_service = ImageCacheService()
|
| 180 |
video_cache_service = VideoCacheService()
|
|
|
app/services/grok/client.py
CHANGED
|
@@ -19,6 +19,7 @@ from app.core.exception import GrokApiException
|
|
| 19 |
GROK_API_ENDPOINT = "https://grok.com/rest/app-chat/conversations/new"
|
| 20 |
REQUEST_TIMEOUT = 120
|
| 21 |
IMPERSONATE_BROWSER = "chrome133a"
|
|
|
|
| 22 |
|
| 23 |
|
| 24 |
class GrokClient:
|
|
@@ -35,31 +36,55 @@ class GrokClient:
|
|
| 35 |
|
| 36 |
# 提取消息内容和图片URL
|
| 37 |
content, image_urls = GrokClient._extract_content(messages)
|
| 38 |
-
|
| 39 |
-
# 获取认证令牌和模型信息
|
| 40 |
-
auth_token = token_manager.get_token(model)
|
| 41 |
model_name, model_mode = Models.to_grok(model)
|
| 42 |
-
|
| 43 |
-
# 检查是否为视频模型
|
| 44 |
is_video_model = Models.get_model_info(model).get("is_video_model", False)
|
| 45 |
|
| 46 |
-
#
|
| 47 |
-
if is_video_model and len(image_urls) > 1:
|
| 48 |
-
logger.warning(f"[Client] 视频模型只允许一张图片,当前有{len(image_urls)}张,只使用第一张")
|
| 49 |
-
image_urls = image_urls[:1]
|
| 50 |
-
|
| 51 |
-
# 上传图片并获取附件ID列表
|
| 52 |
-
image_attachments = await GrokClient._upload_imgs(image_urls, auth_token)
|
| 53 |
-
|
| 54 |
-
# 视频模型:文本添加 --mode=custom
|
| 55 |
if is_video_model:
|
|
|
|
|
|
|
|
|
|
| 56 |
content = f"{content} --mode=custom"
|
| 57 |
logger.debug(f"[Client] 视频模型文本处理: {content}")
|
| 58 |
|
| 59 |
-
#
|
| 60 |
-
|
| 61 |
|
| 62 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
|
| 64 |
@staticmethod
|
| 65 |
def _extract_content(messages: List[Dict]) -> Tuple[str, List[str]]:
|
|
@@ -146,9 +171,15 @@ class GrokClient:
|
|
| 146 |
raise GrokApiException("认证令牌缺失", "NO_AUTH_TOKEN")
|
| 147 |
|
| 148 |
try:
|
| 149 |
-
#
|
| 150 |
headers = GrokClient._build_headers(auth_token)
|
| 151 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
|
| 153 |
# 构建请求参数
|
| 154 |
request_kwargs = {
|
|
@@ -157,7 +188,7 @@ class GrokClient:
|
|
| 157 |
"impersonate": IMPERSONATE_BROWSER,
|
| 158 |
"timeout": REQUEST_TIMEOUT,
|
| 159 |
"stream": True,
|
| 160 |
-
"proxies":
|
| 161 |
}
|
| 162 |
|
| 163 |
# 在线程池中执行同步HTTP请求,避免阻塞事件循环
|
|
@@ -200,14 +231,6 @@ class GrokClient:
|
|
| 200 |
|
| 201 |
return headers
|
| 202 |
|
| 203 |
-
@staticmethod
|
| 204 |
-
def _get_proxy() -> Dict[str, str]:
|
| 205 |
-
"""获取代理配置"""
|
| 206 |
-
proxy_url = setting.grok_config.get("proxy_url", "")
|
| 207 |
-
if proxy_url:
|
| 208 |
-
return {"http": proxy_url, "https": proxy_url}
|
| 209 |
-
return {}
|
| 210 |
-
|
| 211 |
@staticmethod
|
| 212 |
def _handle_error(response, auth_token: str):
|
| 213 |
"""处理错误响应"""
|
|
|
|
| 19 |
GROK_API_ENDPOINT = "https://grok.com/rest/app-chat/conversations/new"
|
| 20 |
REQUEST_TIMEOUT = 120
|
| 21 |
IMPERSONATE_BROWSER = "chrome133a"
|
| 22 |
+
MAX_RETRY = 3 # 最大重试次数
|
| 23 |
|
| 24 |
|
| 25 |
class GrokClient:
|
|
|
|
| 36 |
|
| 37 |
# 提取消息内容和图片URL
|
| 38 |
content, image_urls = GrokClient._extract_content(messages)
|
|
|
|
|
|
|
|
|
|
| 39 |
model_name, model_mode = Models.to_grok(model)
|
|
|
|
|
|
|
| 40 |
is_video_model = Models.get_model_info(model).get("is_video_model", False)
|
| 41 |
|
| 42 |
+
# 视频模型特殊处理
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
if is_video_model:
|
| 44 |
+
if len(image_urls) > 1:
|
| 45 |
+
logger.warning(f"[Client] 视频模型只允许一张图片,当前有{len(image_urls)}张,只使用第一张")
|
| 46 |
+
image_urls = image_urls[:1]
|
| 47 |
content = f"{content} --mode=custom"
|
| 48 |
logger.debug(f"[Client] 视频模型文本处理: {content}")
|
| 49 |
|
| 50 |
+
# 重试逻辑
|
| 51 |
+
return await GrokClient._try(model, content, image_urls, model_name, model_mode, is_video_model, stream)
|
| 52 |
|
| 53 |
+
@staticmethod
|
| 54 |
+
async def _try(model: str, content: str, image_urls: List[str], model_name: str, model_mode: str, is_video: bool, stream: bool):
|
| 55 |
+
"""带重试的请求执行"""
|
| 56 |
+
last_err = None
|
| 57 |
+
|
| 58 |
+
for i in range(MAX_RETRY):
|
| 59 |
+
try:
|
| 60 |
+
# 获取token
|
| 61 |
+
auth_token = token_manager.get_token(model)
|
| 62 |
+
|
| 63 |
+
# 上传图片
|
| 64 |
+
imgs = await GrokClient._upload_imgs(image_urls, auth_token)
|
| 65 |
+
|
| 66 |
+
# 构建并发送请求
|
| 67 |
+
payload = GrokClient._build_payload(content, model_name, model_mode, imgs, is_video)
|
| 68 |
+
return await GrokClient._send_request(payload, auth_token, model, stream)
|
| 69 |
+
|
| 70 |
+
except GrokApiException as e:
|
| 71 |
+
last_err = e
|
| 72 |
+
# 401/429 可重试,其他错误直接抛出
|
| 73 |
+
if e.error_code not in ["HTTP_ERROR", "NO_AVAILABLE_TOKEN"]:
|
| 74 |
+
raise
|
| 75 |
+
|
| 76 |
+
# 检查是否为可重试的状态码
|
| 77 |
+
status = e.context.get("status") if e.context else None
|
| 78 |
+
if status not in [401, 429]:
|
| 79 |
+
raise
|
| 80 |
+
|
| 81 |
+
if i < MAX_RETRY - 1:
|
| 82 |
+
logger.warning(f"[Client] 请求失败(状态码:{status}), 重试 {i+1}/{MAX_RETRY}")
|
| 83 |
+
await asyncio.sleep(0.5) # 短暂延迟
|
| 84 |
+
else:
|
| 85 |
+
logger.error(f"[Client] 重试{MAX_RETRY}次后仍失败")
|
| 86 |
+
|
| 87 |
+
raise last_err if last_err else GrokApiException("请求失败", "REQUEST_ERROR")
|
| 88 |
|
| 89 |
@staticmethod
|
| 90 |
def _extract_content(messages: List[Dict]) -> Tuple[str, List[str]]:
|
|
|
|
| 171 |
raise GrokApiException("认证令牌缺失", "NO_AUTH_TOKEN")
|
| 172 |
|
| 173 |
try:
|
| 174 |
+
# 构建请求头
|
| 175 |
headers = GrokClient._build_headers(auth_token)
|
| 176 |
+
|
| 177 |
+
# 使用服务代理
|
| 178 |
+
proxy_url = setting.get_service_proxy()
|
| 179 |
+
proxies = {"http": proxy_url, "https": proxy_url} if proxy_url else None
|
| 180 |
+
|
| 181 |
+
if proxy_url:
|
| 182 |
+
logger.debug(f"[Client] 使用服务代理: {proxy_url.split('@')[-1] if '@' in proxy_url else proxy_url}")
|
| 183 |
|
| 184 |
# 构建请求参数
|
| 185 |
request_kwargs = {
|
|
|
|
| 188 |
"impersonate": IMPERSONATE_BROWSER,
|
| 189 |
"timeout": REQUEST_TIMEOUT,
|
| 190 |
"stream": True,
|
| 191 |
+
"proxies": proxies
|
| 192 |
}
|
| 193 |
|
| 194 |
# 在线程池中执行同步HTTP请求,避免阻塞事件循环
|
|
|
|
| 231 |
|
| 232 |
return headers
|
| 233 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 234 |
@staticmethod
|
| 235 |
def _handle_error(response, auth_token: str):
|
| 236 |
"""处理错误响应"""
|
app/services/grok/processer.py
CHANGED
|
@@ -112,12 +112,12 @@ class GrokResponseProcessor:
|
|
| 112 |
video_path = video_url.replace('/', '-')
|
| 113 |
base_url = setting.global_config.get("base_url", "")
|
| 114 |
local_video_url = f"{base_url}/images/{video_path}" if base_url else f"/images/{video_path}"
|
| 115 |
-
content = f'<video src="{local_video_url}" controls="controls" width="500" height="300"></video
|
| 116 |
else:
|
| 117 |
-
content = f'<video src="{full_video_url}" controls="controls" width="500" height="300"></video
|
| 118 |
except Exception as e:
|
| 119 |
logger.warning(f"[Processor] 缓存视频失败: {e}")
|
| 120 |
-
content = f'<video src="{full_video_url}" controls="controls" width="500" height="300"></video
|
| 121 |
|
| 122 |
# 返回视频响应
|
| 123 |
result = OpenAIChatCompletionResponse(
|
|
@@ -157,18 +157,30 @@ class GrokResponseProcessor:
|
|
| 157 |
|
| 158 |
# 提取图片数据
|
| 159 |
if images := model_response.get("generatedImageUrls"):
|
|
|
|
|
|
|
|
|
|
| 160 |
for img in images:
|
| 161 |
try:
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
|
|
|
| 168 |
else:
|
| 169 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 170 |
except Exception as e:
|
| 171 |
-
logger.warning(f"[Processor]
|
| 172 |
content += f"\n"
|
| 173 |
|
| 174 |
# 返回 OpenAI 响应格式
|
|
@@ -310,12 +322,12 @@ class GrokResponseProcessor:
|
|
| 310 |
video_path = v_url.replace('/', '-')
|
| 311 |
base_url = setting.global_config.get("base_url", "")
|
| 312 |
local_video_url = f"{base_url}/images/{video_path}" if base_url else f"/images/{video_path}"
|
| 313 |
-
content += f'<video src="{local_video_url}" controls="controls"></video
|
| 314 |
else:
|
| 315 |
-
content += f'<video src="{full_video_url}" controls="controls"></video
|
| 316 |
except Exception as e:
|
| 317 |
logger.warning(f"[Processor] 缓存视频失败: {e}")
|
| 318 |
-
content += f'<video src="{full_video_url}" controls="controls"></video
|
| 319 |
|
| 320 |
yield make_chunk(content)
|
| 321 |
timeout_manager.mark_chunk_received()
|
|
@@ -333,20 +345,72 @@ class GrokResponseProcessor:
|
|
| 333 |
# 提取图片数据
|
| 334 |
if is_image:
|
| 335 |
if model_resp := grok_resp.get("modelResponse"):
|
| 336 |
-
#
|
|
|
|
|
|
|
|
|
|
| 337 |
content = ""
|
|
|
|
|
|
|
| 338 |
for img in model_resp.get("generatedImageUrls", []):
|
| 339 |
try:
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 347 |
except Exception as e:
|
| 348 |
-
logger.warning(f"[Processor]
|
| 349 |
content += f"\n"
|
|
|
|
|
|
|
| 350 |
yield make_chunk(content.strip(), "stop")
|
| 351 |
timeout_manager.mark_chunk_received()
|
| 352 |
return
|
|
|
|
| 112 |
video_path = video_url.replace('/', '-')
|
| 113 |
base_url = setting.global_config.get("base_url", "")
|
| 114 |
local_video_url = f"{base_url}/images/{video_path}" if base_url else f"/images/{video_path}"
|
| 115 |
+
content = f'<video src="{local_video_url}" controls="controls" width="500" height="300"></video>\n'
|
| 116 |
else:
|
| 117 |
+
content = f'<video src="{full_video_url}" controls="controls" width="500" height="300"></video>\n'
|
| 118 |
except Exception as e:
|
| 119 |
logger.warning(f"[Processor] 缓存视频失败: {e}")
|
| 120 |
+
content = f'<video src="{full_video_url}" controls="controls" width="500" height="300"></video>\n'
|
| 121 |
|
| 122 |
# 返回视频响应
|
| 123 |
result = OpenAIChatCompletionResponse(
|
|
|
|
| 157 |
|
| 158 |
# 提取图片数据
|
| 159 |
if images := model_response.get("generatedImageUrls"):
|
| 160 |
+
# 获取图片返回模式
|
| 161 |
+
image_mode = setting.global_config.get("image_mode", "url")
|
| 162 |
+
|
| 163 |
for img in images:
|
| 164 |
try:
|
| 165 |
+
if image_mode == "base64":
|
| 166 |
+
# base64 模式:下载并转换为 base64
|
| 167 |
+
base64_str = await image_cache_service.download_base64(f"/{img}", auth_token)
|
| 168 |
+
if base64_str:
|
| 169 |
+
content += f"\n"
|
| 170 |
+
else:
|
| 171 |
+
content += f"\n"
|
| 172 |
else:
|
| 173 |
+
# url 模式:缓存并返回链接
|
| 174 |
+
cache_path = await image_cache_service.download_image(f"/{img}", auth_token)
|
| 175 |
+
if cache_path:
|
| 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 |
+
else:
|
| 181 |
+
content += f"\n"
|
| 182 |
except Exception as e:
|
| 183 |
+
logger.warning(f"[Processor] 处理图片失败: {e}")
|
| 184 |
content += f"\n"
|
| 185 |
|
| 186 |
# 返回 OpenAI 响应格式
|
|
|
|
| 322 |
video_path = v_url.replace('/', '-')
|
| 323 |
base_url = setting.global_config.get("base_url", "")
|
| 324 |
local_video_url = f"{base_url}/images/{video_path}" if base_url else f"/images/{video_path}"
|
| 325 |
+
content += f'<video src="{local_video_url}" controls="controls"></video>\n'
|
| 326 |
else:
|
| 327 |
+
content += f'<video src="{full_video_url}" controls="controls"></video>\n'
|
| 328 |
except Exception as e:
|
| 329 |
logger.warning(f"[Processor] 缓存视频失败: {e}")
|
| 330 |
+
content += f'<video src="{full_video_url}" controls="controls"></video>\n'
|
| 331 |
|
| 332 |
yield make_chunk(content)
|
| 333 |
timeout_manager.mark_chunk_received()
|
|
|
|
| 345 |
# 提取图片数据
|
| 346 |
if is_image:
|
| 347 |
if model_resp := grok_resp.get("modelResponse"):
|
| 348 |
+
# 获取图片返回模式
|
| 349 |
+
image_mode = setting.global_config.get("image_mode", "url")
|
| 350 |
+
|
| 351 |
+
# 初始化内容变量
|
| 352 |
content = ""
|
| 353 |
+
|
| 354 |
+
# 生成图片链接并缓存
|
| 355 |
for img in model_resp.get("generatedImageUrls", []):
|
| 356 |
try:
|
| 357 |
+
if image_mode == "base64":
|
| 358 |
+
# base64 模式:下载并转换为 base64
|
| 359 |
+
base64_str = await image_cache_service.download_base64(f"/{img}", auth_token)
|
| 360 |
+
if base64_str:
|
| 361 |
+
# 分块发送 base64 数据,每 8KB 一个 chunk
|
| 362 |
+
markdown_prefix = "\n"
|
| 364 |
+
|
| 365 |
+
# 提取 data URL 的 mime 和 base64 部分
|
| 366 |
+
if base64_str.startswith("data:"):
|
| 367 |
+
parts = base64_str.split(",", 1)
|
| 368 |
+
if len(parts) == 2:
|
| 369 |
+
mime_part = parts[0] + ","
|
| 370 |
+
b64_data = parts[1]
|
| 371 |
+
|
| 372 |
+
# 发送前缀
|
| 373 |
+
yield make_chunk(markdown_prefix + mime_part)
|
| 374 |
+
timeout_manager.mark_chunk_received()
|
| 375 |
+
chunk_index += 1
|
| 376 |
+
|
| 377 |
+
# 分块发送 base64 数据
|
| 378 |
+
chunk_size = 8192
|
| 379 |
+
for i in range(0, len(b64_data), chunk_size):
|
| 380 |
+
chunk_data = b64_data[i:i + chunk_size]
|
| 381 |
+
yield make_chunk(chunk_data)
|
| 382 |
+
timeout_manager.mark_chunk_received()
|
| 383 |
+
chunk_index += 1
|
| 384 |
+
|
| 385 |
+
# 发送后缀
|
| 386 |
+
yield make_chunk(markdown_suffix)
|
| 387 |
+
timeout_manager.mark_chunk_received()
|
| 388 |
+
chunk_index += 1
|
| 389 |
+
else:
|
| 390 |
+
yield make_chunk(f"\n")
|
| 391 |
+
timeout_manager.mark_chunk_received()
|
| 392 |
+
chunk_index += 1
|
| 393 |
+
else:
|
| 394 |
+
yield make_chunk(f"\n")
|
| 395 |
+
timeout_manager.mark_chunk_received()
|
| 396 |
+
chunk_index += 1
|
| 397 |
+
else:
|
| 398 |
+
yield make_chunk(f"\n")
|
| 399 |
+
timeout_manager.mark_chunk_received()
|
| 400 |
+
chunk_index += 1
|
| 401 |
+
else:
|
| 402 |
+
# url 模式:缓存并返回链接
|
| 403 |
+
await image_cache_service.download_image(f"/{img}", auth_token)
|
| 404 |
+
# 本地图片路径
|
| 405 |
+
img_path = img.replace('/', '-')
|
| 406 |
+
base_url = setting.global_config.get("base_url", "")
|
| 407 |
+
img_url = f"{base_url}/images/{img_path}" if base_url else f"/images/{img_path}"
|
| 408 |
+
content += f"\n"
|
| 409 |
except Exception as e:
|
| 410 |
+
logger.warning(f"[Processor] 处理图片失败: {e}")
|
| 411 |
content += f"\n"
|
| 412 |
+
|
| 413 |
+
# 发送内容
|
| 414 |
yield make_chunk(content.strip(), "stop")
|
| 415 |
timeout_manager.mark_chunk_received()
|
| 416 |
return
|
app/services/grok/token.py
CHANGED
|
@@ -51,6 +51,7 @@ class GrokTokenManager:
|
|
| 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()
|
|
@@ -58,6 +59,10 @@ class GrokTokenManager:
|
|
| 58 |
|
| 59 |
logger.debug(f"[Token] 管理器初始化完成,文件: {self.token_file}")
|
| 60 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
def _load_data(self) -> None:
|
| 62 |
"""同步加载Token数据(仅用于初始化)"""
|
| 63 |
default_data = {
|
|
@@ -77,11 +82,16 @@ class GrokTokenManager:
|
|
| 77 |
self.token_data = default_data
|
| 78 |
|
| 79 |
async def _save_data(self) -> None:
|
| 80 |
-
"""异步保存Token
|
| 81 |
try:
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
except IOError as e:
|
| 86 |
logger.error(f"[Token] 保存Token数据失败: {str(e)}")
|
| 87 |
raise GrokApiException(
|
|
@@ -248,6 +258,9 @@ class GrokTokenManager:
|
|
| 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:
|
|
@@ -307,7 +320,7 @@ class GrokTokenManager:
|
|
| 307 |
|
| 308 |
错误码说明:
|
| 309 |
- 401: SSO Token失效,会标记Token为expired
|
| 310 |
-
- 403:
|
| 311 |
|
| 312 |
Args:
|
| 313 |
auth_token: 完整的认证Token (格式: sso-rw=xxx;sso=xxx)
|
|
@@ -315,9 +328,12 @@ class GrokTokenManager:
|
|
| 315 |
error_message: 错误信息
|
| 316 |
"""
|
| 317 |
try:
|
| 318 |
-
# 403
|
| 319 |
if status_code == STATSIG_INVALID_CODE:
|
| 320 |
-
logger.warning(
|
|
|
|
|
|
|
|
|
|
| 321 |
return
|
| 322 |
|
| 323 |
sso_value = self._extract_sso(auth_token)
|
|
|
|
| 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 |
+
self._storage = None
|
| 55 |
|
| 56 |
# 同步加载初始数据
|
| 57 |
self._load_data()
|
|
|
|
| 59 |
|
| 60 |
logger.debug(f"[Token] 管理器初始化完成,文件: {self.token_file}")
|
| 61 |
|
| 62 |
+
def set_storage(self, storage) -> None:
|
| 63 |
+
"""设置存储实例"""
|
| 64 |
+
self._storage = storage
|
| 65 |
+
|
| 66 |
def _load_data(self) -> None:
|
| 67 |
"""同步加载Token数据(仅用于初始化)"""
|
| 68 |
default_data = {
|
|
|
|
| 82 |
self.token_data = default_data
|
| 83 |
|
| 84 |
async def _save_data(self) -> None:
|
| 85 |
+
"""异步保存Token数据到存储"""
|
| 86 |
try:
|
| 87 |
+
if not self._storage:
|
| 88 |
+
# 如果没有设置存储,使用传统文件保存方式(向后兼容)
|
| 89 |
+
async with self._file_lock:
|
| 90 |
+
async with aiofiles.open(self.token_file, "w", encoding="utf-8") as f:
|
| 91 |
+
await f.write(json.dumps(self.token_data, indent=2, ensure_ascii=False))
|
| 92 |
+
else:
|
| 93 |
+
# 使用存储抽象层
|
| 94 |
+
await self._storage.save_tokens(self.token_data)
|
| 95 |
except IOError as e:
|
| 96 |
logger.error(f"[Token] 保存Token数据失败: {str(e)}")
|
| 97 |
raise GrokApiException(
|
|
|
|
| 258 |
# 获取代理配置
|
| 259 |
proxy_url = setting.grok_config.get("proxy_url", "")
|
| 260 |
proxies = {"http": proxy_url, "https": proxy_url} if proxy_url else None
|
| 261 |
+
|
| 262 |
+
if proxy_url:
|
| 263 |
+
logger.debug(f"[Token] 使用代理: {proxy_url.split('@')[-1] if '@' in proxy_url else proxy_url}")
|
| 264 |
|
| 265 |
# 发送异步请求
|
| 266 |
async with AsyncSession() as session:
|
|
|
|
| 320 |
|
| 321 |
错误码说明:
|
| 322 |
- 401: SSO Token失效,会标记Token为expired
|
| 323 |
+
- 403: 服务器IP被Block,不影响Token状态
|
| 324 |
|
| 325 |
Args:
|
| 326 |
auth_token: 完整的认证Token (格式: sso-rw=xxx;sso=xxx)
|
|
|
|
| 328 |
error_message: 错误信息
|
| 329 |
"""
|
| 330 |
try:
|
| 331 |
+
# 403错误是服务器IP被Block,不是Token问题
|
| 332 |
if status_code == STATSIG_INVALID_CODE:
|
| 333 |
+
logger.warning(
|
| 334 |
+
f"[Token] 服务器IP被Block (403),请 1. 更换服务器IP 2. 使用代理IP "
|
| 335 |
+
f"3. 服务器登陆Grok.com,过盾后F12找到CF值填入后台设置"
|
| 336 |
+
)
|
| 337 |
return
|
| 338 |
|
| 339 |
sso_value = self._extract_sso(auth_token)
|
app/services/grok/upload.py
CHANGED
|
@@ -61,7 +61,11 @@ class ImageUploadManager:
|
|
| 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 |
# 发送异步请求
|
|
|
|
| 61 |
|
| 62 |
cf_clearance = setting.grok_config.get("cf_clearance", "")
|
| 63 |
cookie = f"{auth_token};{cf_clearance}" if cf_clearance else auth_token
|
| 64 |
+
|
| 65 |
proxy_url = setting.grok_config.get("proxy_url", "")
|
| 66 |
+
if proxy_url:
|
| 67 |
+
logger.debug(f"[Upload] 使用代理: {proxy_url.split('@')[-1] if '@' in proxy_url else proxy_url}")
|
| 68 |
+
|
| 69 |
proxies = {"http": proxy_url, "https": proxy_url} if proxy_url else None
|
| 70 |
|
| 71 |
# 发送异步请求
|
app/services/mcp/__init__.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# -*- coding: utf-8 -*-
|
| 2 |
+
"""MCP模块初始化"""
|
| 3 |
+
|
| 4 |
+
from app.services.mcp.server import mcp
|
| 5 |
+
|
| 6 |
+
__all__ = ["mcp"]
|
app/services/mcp/server.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# -*- coding: utf-8 -*-
|
| 2 |
+
"""FastMCP服务器实例"""
|
| 3 |
+
|
| 4 |
+
from fastmcp import FastMCP
|
| 5 |
+
from fastmcp.server.auth.providers.jwt import StaticTokenVerifier
|
| 6 |
+
from app.services.mcp.tools import ask_grok_impl
|
| 7 |
+
from app.core.config import setting
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def create_mcp_server() -> FastMCP:
|
| 11 |
+
"""创建MCP服务器实例,如果配置了API密钥则启用认证"""
|
| 12 |
+
# 检查是否配置了API密钥
|
| 13 |
+
api_key = setting.grok_config.get("api_key")
|
| 14 |
+
|
| 15 |
+
# 如果配置了API密钥,则启用静态token验证
|
| 16 |
+
auth = None
|
| 17 |
+
if api_key:
|
| 18 |
+
auth = StaticTokenVerifier(
|
| 19 |
+
tokens={
|
| 20 |
+
api_key: {
|
| 21 |
+
"client_id": "grok2api-client",
|
| 22 |
+
"scopes": ["read", "write", "admin"]
|
| 23 |
+
}
|
| 24 |
+
},
|
| 25 |
+
required_scopes=["read"]
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
# 创建FastMCP实例
|
| 29 |
+
return FastMCP(
|
| 30 |
+
name="Grok2API-MCP",
|
| 31 |
+
instructions="MCP server providing Grok AI chat capabilities. Use ask_grok tool to interact with Grok AI models.",
|
| 32 |
+
auth=auth
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
# 创建全局MCP实例
|
| 37 |
+
mcp = create_mcp_server()
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
# 注册ask_grok工具
|
| 41 |
+
@mcp.tool
|
| 42 |
+
async def ask_grok(
|
| 43 |
+
query: str,
|
| 44 |
+
model: str = "grok-3-fast",
|
| 45 |
+
system_prompt: str = None
|
| 46 |
+
) -> str:
|
| 47 |
+
"""
|
| 48 |
+
调用Grok AI进行对话,尤其适用于当用户询问最新信息,需要调用搜索功能,或是想了解社交平台动态(如Twitter(X)、Reddit等)时。
|
| 49 |
+
|
| 50 |
+
Args:
|
| 51 |
+
query: 用户的问题或指令
|
| 52 |
+
model: Grok模型名称,可选值: grok-3-fast(默认), grok-4-fast, grok-4-fast-expert, grok-4-expert, grok-4-heavy
|
| 53 |
+
system_prompt: 可选的系统提示词,用于设定AI的角色或行为约束
|
| 54 |
+
|
| 55 |
+
Returns:
|
| 56 |
+
Grok AI的完整回复内容,可能包括文本和图片链接(Markdown格式)
|
| 57 |
+
|
| 58 |
+
Examples:
|
| 59 |
+
- 简单问答: ask_grok("什么是Python?")
|
| 60 |
+
- 指定模型: ask_grok("解释量子计算", model="grok-4-fast")
|
| 61 |
+
- 带系统提示: ask_grok("写一首诗", system_prompt="你是一位古典诗人")
|
| 62 |
+
"""
|
| 63 |
+
return await ask_grok_impl(query, model, system_prompt)
|
app/services/mcp/tools.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# -*- coding: utf-8 -*-
|
| 2 |
+
"""MCP Tools - Grok AI 对话工具"""
|
| 3 |
+
|
| 4 |
+
import json
|
| 5 |
+
from typing import Optional
|
| 6 |
+
from app.services.grok.client import GrokClient
|
| 7 |
+
from app.core.logger import logger
|
| 8 |
+
from app.core.exception import GrokApiException
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
async def ask_grok_impl(
|
| 12 |
+
query: str,
|
| 13 |
+
model: str = "grok-3-fast",
|
| 14 |
+
system_prompt: Optional[str] = None
|
| 15 |
+
) -> str:
|
| 16 |
+
"""
|
| 17 |
+
内部实现: 调用Grok API并收集完整响应
|
| 18 |
+
|
| 19 |
+
Args:
|
| 20 |
+
query: 用户问题
|
| 21 |
+
model: 模型名称
|
| 22 |
+
system_prompt: 系统提示词
|
| 23 |
+
|
| 24 |
+
Returns:
|
| 25 |
+
str: 完整的Grok响应内容
|
| 26 |
+
"""
|
| 27 |
+
try:
|
| 28 |
+
# 构建消息列表
|
| 29 |
+
messages = []
|
| 30 |
+
if system_prompt:
|
| 31 |
+
messages.append({"role": "system", "content": system_prompt})
|
| 32 |
+
messages.append({"role": "user", "content": query})
|
| 33 |
+
|
| 34 |
+
# 构建请求
|
| 35 |
+
request_data = {
|
| 36 |
+
"model": model,
|
| 37 |
+
"messages": messages,
|
| 38 |
+
"stream": True
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
logger.info(f"[MCP] ask_grok 调用, 模型: {model}")
|
| 42 |
+
|
| 43 |
+
# 调用Grok客户端(流式)
|
| 44 |
+
response_iterator = await GrokClient.openai_to_grok(request_data)
|
| 45 |
+
|
| 46 |
+
# 收集所有流式响应块
|
| 47 |
+
content_parts = []
|
| 48 |
+
async for chunk in response_iterator:
|
| 49 |
+
if isinstance(chunk, bytes):
|
| 50 |
+
chunk = chunk.decode('utf-8')
|
| 51 |
+
|
| 52 |
+
# 解析SSE格式
|
| 53 |
+
if chunk.startswith("data: "):
|
| 54 |
+
data_str = chunk[6:].strip()
|
| 55 |
+
if data_str == "[DONE]":
|
| 56 |
+
break
|
| 57 |
+
|
| 58 |
+
try:
|
| 59 |
+
data = json.loads(data_str)
|
| 60 |
+
choices = data.get("choices", [])
|
| 61 |
+
if choices:
|
| 62 |
+
delta = choices[0].get("delta", {})
|
| 63 |
+
if content := delta.get("content"):
|
| 64 |
+
content_parts.append(content)
|
| 65 |
+
except json.JSONDecodeError:
|
| 66 |
+
continue
|
| 67 |
+
|
| 68 |
+
result = "".join(content_parts)
|
| 69 |
+
logger.info(f"[MCP] ask_grok 完成, 响应长度: {len(result)}")
|
| 70 |
+
return result
|
| 71 |
+
|
| 72 |
+
except GrokApiException as e:
|
| 73 |
+
logger.error(f"[MCP] Grok API错误: {str(e)}")
|
| 74 |
+
raise Exception(f"Grok API调用失败: {str(e)}")
|
| 75 |
+
except Exception as e:
|
| 76 |
+
logger.error(f"[MCP] ask_grok异常: {str(e)}", exc_info=True)
|
| 77 |
+
raise Exception(f"处理请求时出错: {str(e)}")
|
app/template/admin.html
CHANGED
|
@@ -4,103 +4,73 @@
|
|
| 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 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
}
|
| 19 |
-
.
|
| 20 |
-
|
| 21 |
-
}
|
| 22 |
-
|
| 23 |
-
|
| 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-
|
|
|
|
| 97 |
</div>
|
| 98 |
-
<div class="flex flex-1 items-center justify-
|
| 99 |
-
<
|
| 100 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
onclick="logout()"
|
| 102 |
-
class="inline-flex items-center justify-center text-
|
| 103 |
>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
退出
|
| 105 |
</button>
|
| 106 |
</nav>
|
|
@@ -274,176 +244,237 @@
|
|
| 274 |
|
| 275 |
<!-- 全局设置面板 -->
|
| 276 |
<div id="panelSettings" class="hidden">
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
<div class="
|
| 280 |
-
<
|
| 281 |
-
<
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
</div>
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 318 |
</div>
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
|
|
|
| 327 |
<input id="imageCacheSize" readonly class="flex h-9 flex-1 rounded-md border border-input bg-muted px-3 py-2 text-sm" placeholder="0 MB">
|
| 328 |
-
<button onclick="clearImageCache()" class="inline-flex items-center justify-center rounded-md text-sm font-medium bg-
|
| 329 |
<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">
|
| 330 |
-
<
|
| 331 |
-
<circle cx="9" cy="9" r="2"/>
|
| 332 |
-
<path d="M21 15l-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/>
|
| 333 |
</svg>
|
| 334 |
</button>
|
| 335 |
</div>
|
| 336 |
-
|
| 337 |
-
|
|
|
|
|
|
|
| 338 |
<input id="videoCacheSize" readonly class="flex h-9 flex-1 rounded-md border border-input bg-muted px-3 py-2 text-sm" placeholder="0 MB">
|
| 339 |
-
<button onclick="clearVideoCache()" class="inline-flex items-center justify-center rounded-md text-sm font-medium bg-
|
| 340 |
<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">
|
| 341 |
-
<
|
| 342 |
-
<rect x="1" y="5" width="15" height="14" rx="2" ry="2"/>
|
| 343 |
</svg>
|
| 344 |
</button>
|
| 345 |
</div>
|
| 346 |
-
|
| 347 |
-
|
|
|
|
|
|
|
| 348 |
<input id="totalCacheSize" readonly class="flex h-9 flex-1 rounded-md border border-input bg-muted px-3 py-2 text-sm font-medium" placeholder="0 MB">
|
| 349 |
-
<button onclick="clearCache()" class="inline-flex items-center justify-center rounded-md text-sm font-medium bg-
|
| 350 |
-
<svg class="h-4 w-4
|
| 351 |
-
<path d="M3
|
| 352 |
-
<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"/>
|
| 353 |
-
<line x1="10" y1="11" x2="10" y2="17"/>
|
| 354 |
-
<line x1="14" y1="11" x2="14" y2="17"/>
|
| 355 |
</svg>
|
| 356 |
-
清除全部
|
| 357 |
</button>
|
| 358 |
</div>
|
| 359 |
</div>
|
| 360 |
</div>
|
| 361 |
-
<div>
|
| 362 |
-
<label class="text-sm font-medium flex items-center gap-1 mb-2">
|
| 363 |
-
服务网址
|
| 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="用于生成图片链接的服务地址。建议使用域名以保护服务器IP,如无图片需求可留空">?</span>
|
| 365 |
-
</label>
|
| 366 |
-
<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">
|
| 367 |
-
</div>
|
| 368 |
-
</div>
|
| 369 |
-
<div class="mt-6 flex justify-end">
|
| 370 |
-
<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>
|
| 371 |
</div>
|
| 372 |
</div>
|
|
|
|
| 373 |
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
<
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
<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>
|
| 417 |
-
</label>
|
| 418 |
-
<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">
|
| 419 |
-
<option value="false">关闭</option>
|
| 420 |
-
<option value="true">开启</option>
|
| 421 |
-
</select>
|
| 422 |
-
</div>
|
| 423 |
-
<div>
|
| 424 |
-
<label class="text-sm font-medium flex items-center gap-1 mb-2">
|
| 425 |
-
数据块间隔超时 (秒)
|
| 426 |
-
<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>
|
| 427 |
-
</label>
|
| 428 |
-
<input id="cfgStreamChunkTimeout" type="number" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="120">
|
| 429 |
</div>
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 436 |
</div>
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 443 |
</div>
|
| 444 |
</div>
|
| 445 |
-
|
| 446 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 447 |
</div>
|
| 448 |
</div>
|
| 449 |
</div>
|
|
@@ -464,14 +495,14 @@
|
|
| 464 |
</div>
|
| 465 |
<div class="p-5 space-y-4">
|
| 466 |
<div class="space-y-2">
|
| 467 |
-
<label class="text-sm font-medium">Token 类型</label>
|
| 468 |
<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">
|
| 469 |
<option value="sso">SSO</option>
|
| 470 |
<option value="ssoSuper">SuperSSO</option>
|
| 471 |
</select>
|
| 472 |
</div>
|
| 473 |
<div class="space-y-2">
|
| 474 |
-
<label class="text-sm font-medium">Token 列表 <span class="text-muted-foreground">(每行一个)</span></label>
|
| 475 |
<textarea
|
| 476 |
id="addTokenList"
|
| 477 |
rows="12"
|
|
@@ -507,17 +538,7 @@
|
|
| 507 |
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)}},
|
| 508 |
updateRemaining=()=>{const r=calcRemaining();['Total','Normal','Heavy'].forEach(k=>$(`stat${k}Remaining`).textContent=r[k.toLowerCase()]===0?'-':r[k.toLowerCase()].toLocaleString())}
|
| 509 |
|
| 510 |
-
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
|
| 511 |
-
<tr class="transition-colors">
|
| 512 |
-
<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>
|
| 513 |
-
<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>
|
| 514 |
-
<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>
|
| 515 |
-
<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>
|
| 516 |
-
<td class="py-2.5 px-3 align-middle w-20 text-xs tabular-nums">${t.remaining_queries===-1?'-':t.remaining_queries}</td>
|
| 517 |
-
<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>
|
| 518 |
-
<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>
|
| 519 |
-
<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>
|
| 520 |
-
</tr>`).join('');updateBatchActions()},
|
| 521 |
toggleToken=t=>selectedTokens[selectedTokens.has(t)?'delete':'add'](t)||updateBatchActions(),
|
| 522 |
toggleSelectAll=()=>{const sa=$('selectAll');sa.checked?filteredTokens.forEach(t=>selectedTokens.add(t.token)):selectedTokens.clear();renderTokens()},
|
| 523 |
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},
|
|
@@ -530,18 +551,21 @@
|
|
| 530 |
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)}},
|
| 531 |
copyToken=async(t,e)=>{e.stopPropagation();try{await navigator.clipboard.writeText(t);showToast('Token 已复制到剪贴板','success')}catch(err){console.error('复制失败:',err);showToast('复制失败,请手动复制','error')}}
|
| 532 |
|
| 533 |
-
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.
|
| 534 |
-
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
|
| 535 |
logout=async()=>{if(!confirm('确定要退出登录吗?'))return;try{await apiRequest('/api/logout',{method:'POST'})}catch(e){console.error('登出失败:',e)}finally{localStorage.removeItem('adminToken');location.href='/login'}},
|
| 536 |
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()},
|
| 537 |
-
|
|
|
|
| 538 |
loadCacheSize=async()=>{try{const r=await apiRequest('/api/cache/size');if(!r)return;const d=await r.json();if(d.success){$('imageCacheSize').value=d.data.image_size||'0 MB';$('videoCacheSize').value=d.data.video_size||'0 MB';$('totalCacheSize').value=d.data.total_size||'0 MB'}}catch(e){console.error('加载缓存大小失败:',e);$('imageCacheSize').value='0 MB';$('videoCacheSize').value='0 MB';$('totalCacheSize').value='0 MB'}},
|
| 539 |
-
clearImageCache=async()=>{if(!confirm('确定要清理图片缓存吗?此操作将删除所有图片缓存文件!'))return;try{const r=await apiRequest('/api/cache/clear/images',{method:'POST'});if(!r)return;const d=await r.json();
|
| 540 |
-
clearVideoCache=async()=>{if(!confirm('确定要清理视频缓存吗?此操作将删除所有视频缓存文件!'))return;try{const r=await apiRequest('/api/cache/clear/videos',{method:'POST'});if(!r)return;const d=await r.json();
|
| 541 |
-
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();
|
| 542 |
-
saveGlobalSettings=async()=>{const gc={admin_username:$('cfgAdminUser').value,log_level:$('cfgLogLevel').value,image_cache_max_size_mb:parseInt($('cfgImageCacheMaxSize').value)||500,video_cache_max_size_mb:parseInt($('cfgVideoCacheMaxSize').value)||1000,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')}},
|
| 543 |
-
saveGrokSettings=async()=>{const kc={api_key:$('cfgApiKey').value,proxy_url
|
| 544 |
-
|
|
|
|
|
|
|
| 545 |
</script>
|
| 546 |
</body>
|
| 547 |
</html>
|
|
|
|
| 4 |
<meta charset="UTF-8">
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
<title>管理控制台 - Grok2API</title>
|
| 7 |
+
<link rel="icon" type="image/png" href="/static/favicon.png">
|
| 8 |
<script src="https://cdn.tailwindcss.com"></script>
|
| 9 |
<style>
|
| 10 |
+
@keyframes slide-up{from{transform:translateY(100%);opacity:0}to{transform:translateY(0);opacity:1}}
|
| 11 |
+
.animate-slide-up{animation:slide-up .3s ease-out}
|
| 12 |
+
.tab-btn{transition:all .2s ease}
|
| 13 |
+
.hover-card{position:relative;display:inline-block}
|
| 14 |
+
.hover-card-trigger{cursor:pointer}
|
| 15 |
+
.hover-card-content{position:absolute;left:50%;transform:translateX(-50%);background:hsl(0 0% 3.9%);color:hsl(0 0% 98%);padding:8px 12px;border-radius:6px;font-size:12px;font-weight:500;white-space:nowrap;z-index:9999;pointer-events:none;opacity:0;visibility:hidden;transition:opacity .2s ease,transform .2s ease;box-shadow:0 4px 12px rgba(0,0,0,.25);border:1px solid hsl(0 0% 14.9%)}
|
| 16 |
+
.hover-card:hover .hover-card-content{opacity:1;visibility:visible}
|
| 17 |
+
.hover-card-content.top{bottom:100%;transform:translateX(-50%) translateY(-8px)}
|
| 18 |
+
.hover-card-content.bottom{top:100%;transform:translateX(-50%) translateY(8px)}
|
| 19 |
+
.hover-card-content::after{content:'';position:absolute;left:50%;transform:translateX(-50%);border:6px solid transparent;z-index:1000}
|
| 20 |
+
.hover-card-content.top::after{top:100%;border-top-color:hsl(0 0% 3.9%)}
|
| 21 |
+
.hover-card-content.bottom::after{bottom:100%;border-bottom-color:hsl(0 0% 3.9%)}
|
| 22 |
+
.hover-card-trigger:hover+.hover-card-content{opacity:1;visibility:visible}
|
| 23 |
+
[title]{position:relative}
|
| 24 |
+
[title]:hover::after{content:attr(title);position:absolute;bottom:100%;left:50%;transform:translateX(-50%) translateY(-4px);background:hsl(0 0% 3.9%);color:hsl(0 0% 98%);padding:8px 12px;border-radius:4px;font-size:11px;white-space:pre-line;max-width:500px;min-width:300px;word-wrap:break-word;z-index:1000;pointer-events:none;box-shadow:0 2px 8px rgba(0,0,0,.15)}
|
| 25 |
+
[title]:hover::before{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:4px solid transparent;border-top-color:hsl(0 0% 3.9%);z-index:1000}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
</style>
|
| 27 |
<script>
|
| 28 |
+
tailwind.config={theme:{extend:{colors:{border:"hsl(0 0% 89%)",input:"hsl(0 0% 89%)",ring:"hsl(0 0% 3.9%)",background:"hsl(0 0% 100%)",foreground:"hsl(0 0% 3.9%)",primary:{DEFAULT:"hsl(0 0% 9%)",foreground:"hsl(0 0% 98%)"},secondary:{DEFAULT:"hsl(0 0% 96.1%)",foreground:"hsl(0 0% 9%)"},muted:{DEFAULT:"hsl(0 0% 96.1%)",foreground:"hsl(0 0% 45.1%)"},accent:{DEFAULT:"hsl(0 0% 96.1%)",foreground:"hsl(0 0% 9%)"},destructive:{DEFAULT:"hsl(0 84.2% 60.2%)",foreground:"hsl(0 0% 98%)"}}}}}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
</script>
|
| 30 |
</head>
|
| 31 |
<body class="h-full bg-background text-foreground antialiased">
|
| 32 |
<!-- 导航栏 -->
|
| 33 |
<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">
|
| 34 |
<div class="mx-auto flex h-14 max-w-7xl items-center px-6">
|
| 35 |
+
<div class="mr-4 flex items-baseline gap-3">
|
| 36 |
+
<span class="font-bold text-xl">Grok2API</span>
|
| 37 |
+
<span class="text-xs text-gray-400">by @Chenyme</span>
|
| 38 |
</div>
|
| 39 |
+
<div class="flex flex-1 items-center justify-end">
|
| 40 |
+
<div class="flex items-center gap-2">
|
| 41 |
+
<div class="hover-card">
|
| 42 |
+
<span id="storageMode" class="hover-card-trigger inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium bg-muted text-muted-foreground border">
|
| 43 |
+
<svg class="h-3 w-3 mr-1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 44 |
+
<ellipse cx="12" cy="5" rx="9" ry="3"/>
|
| 45 |
+
<path d="m21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/>
|
| 46 |
+
<path d="m3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/>
|
| 47 |
+
</svg>
|
| 48 |
+
<span id="storageModeText">FILE</span>
|
| 49 |
+
</span>
|
| 50 |
+
<div id="storageModeTooltip" class="hover-card-content top">
|
| 51 |
+
加载中...
|
| 52 |
+
</div>
|
| 53 |
+
</div>
|
| 54 |
+
<nav class="flex items-center gap-1">
|
| 55 |
+
<a
|
| 56 |
+
href="https://github.com/chenyme/grok2api/issues"
|
| 57 |
+
target="_blank"
|
| 58 |
+
class="inline-flex items-center justify-center text-xs transition-colors hover:bg-accent hover:text-accent-foreground h-7 px-2.5 gap-1"
|
| 59 |
+
>
|
| 60 |
+
<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">
|
| 61 |
+
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
| 62 |
+
</svg>
|
| 63 |
+
反馈
|
| 64 |
+
</a>
|
| 65 |
+
<button
|
| 66 |
onclick="logout()"
|
| 67 |
+
class="inline-flex items-center justify-center text-xs transition-colors hover:bg-accent hover:text-accent-foreground h-7 px-2.5 gap-1"
|
| 68 |
>
|
| 69 |
+
<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">
|
| 70 |
+
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
|
| 71 |
+
<polyline points="16 17 21 12 16 7"/>
|
| 72 |
+
<line x1="21" y1="12" x2="9" y2="12"/>
|
| 73 |
+
</svg>
|
| 74 |
退出
|
| 75 |
</button>
|
| 76 |
</nav>
|
|
|
|
| 244 |
|
| 245 |
<!-- 全局设置面板 -->
|
| 246 |
<div id="panelSettings" class="hidden">
|
| 247 |
+
<!-- 全局配置区域 -->
|
| 248 |
+
<div class="mb-8">
|
| 249 |
+
<div class="flex items-center justify-between mb-6">
|
| 250 |
+
<h2 class="text-xl font-bold">全局配置</h2>
|
| 251 |
+
<button onclick="saveGlobalSettings()" class="inline-flex items-center justify-center rounded-md text-sm font-medium bg-secondary text-secondary-foreground hover:bg-black hover:text-white h-9 px-4 transition-colors">保存配置</button>
|
| 252 |
+
</div>
|
| 253 |
+
<div class="grid gap-4 lg:grid-cols-3">
|
| 254 |
+
<!-- 系统设置 -->
|
| 255 |
+
<div class="rounded-lg border border-border bg-background p-6">
|
| 256 |
+
<h3 class="text-sm font-semibold mb-4">系统设置</h3>
|
| 257 |
+
<div class="space-y-4">
|
| 258 |
+
<div>
|
| 259 |
+
<label class="text-sm font-medium text-muted-foreground mb-2 flex items-center gap-1">
|
| 260 |
+
登陆账户
|
| 261 |
+
<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>
|
| 262 |
+
</label>
|
| 263 |
+
<input id="cfgAdminUser" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="admin">
|
| 264 |
+
</div>
|
| 265 |
+
<div>
|
| 266 |
+
<label class="text-sm font-medium text-muted-foreground mb-2 flex items-center gap-1">
|
| 267 |
+
登陆密码
|
| 268 |
+
<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>
|
| 269 |
+
</label>
|
| 270 |
+
<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="留空则不修改">
|
| 271 |
+
</div>
|
| 272 |
+
<div>
|
| 273 |
+
<label class="text-sm font-medium text-muted-foreground mb-2 flex items-center gap-1">
|
| 274 |
+
日志级别
|
| 275 |
+
<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>
|
| 276 |
+
</label>
|
| 277 |
+
<select id="cfgLogLevel" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm">
|
| 278 |
+
<option>DEBUG</option><option>INFO</option><option>WARNING</option><option>ERROR</option>
|
| 279 |
+
</select>
|
| 280 |
+
</div>
|
| 281 |
</div>
|
| 282 |
+
</div>
|
| 283 |
+
|
| 284 |
+
<!-- 媒体设置 -->
|
| 285 |
+
<div class="rounded-lg border border-border bg-background p-6">
|
| 286 |
+
<h3 class="text-sm font-semibold mb-4">媒体设置</h3>
|
| 287 |
+
<div class="space-y-4">
|
| 288 |
+
<div>
|
| 289 |
+
<label class="text-sm font-medium text-muted-foreground mb-2 flex items-center gap-1">
|
| 290 |
+
图片模式
|
| 291 |
+
<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="返回图片的方式。URL:图片链接,支持图片缓存 | Base64:base64编码,不支持缓存">?</span>
|
| 292 |
+
</label>
|
| 293 |
+
<select id="cfgImageMode" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm">
|
| 294 |
+
<option value="url">URL (图片链接)</option>
|
| 295 |
+
<option value="base64">Base64 (base64编码)</option>
|
| 296 |
+
</select>
|
| 297 |
+
</div>
|
| 298 |
+
<div>
|
| 299 |
+
<label class="text-sm font-medium text-muted-foreground mb-2 flex items-center gap-1">
|
| 300 |
+
服务网址
|
| 301 |
+
<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="服务器的公网访问地址,用于构建图片URL链接(仅在图片模式为URL时需要)">?</span>
|
| 302 |
+
</label>
|
| 303 |
+
<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">
|
| 304 |
+
</div>
|
| 305 |
+
<div class="grid grid-cols-2 gap-3">
|
| 306 |
+
<div>
|
| 307 |
+
<label class="text-sm font-medium text-muted-foreground mb-2 flex items-center gap-1">
|
| 308 |
+
图片缓存 (MB)
|
| 309 |
+
<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="图片缓存的最大容量(MB),超过后会自动清理旧缓存">?</span>
|
| 310 |
+
</label>
|
| 311 |
+
<input id="cfgImageCacheMaxSize" type="number" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="500">
|
| 312 |
+
</div>
|
| 313 |
+
<div>
|
| 314 |
+
<label class="text-sm font-medium text-muted-foreground mb-2 flex items-center gap-1">
|
| 315 |
+
视频缓存 (MB)
|
| 316 |
+
<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="视频缓存的最大容量(MB),超过后会自动清理旧缓存">?</span>
|
| 317 |
+
</label>
|
| 318 |
+
<input id="cfgVideoCacheMaxSize" type="number" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="1000">
|
| 319 |
+
</div>
|
| 320 |
+
</div>
|
| 321 |
</div>
|
| 322 |
+
</div>
|
| 323 |
+
|
| 324 |
+
<!-- 缓存管理 -->
|
| 325 |
+
<div class="rounded-lg border border-border bg-background p-6">
|
| 326 |
+
<h3 class="text-sm font-semibold mb-4">缓存管理</h3>
|
| 327 |
+
<div class="space-y-4">
|
| 328 |
+
<div>
|
| 329 |
+
<label class="text-sm font-medium text-muted-foreground mb-2 block">图片缓存</label>
|
| 330 |
+
<div class="flex gap-2">
|
| 331 |
<input id="imageCacheSize" readonly class="flex h-9 flex-1 rounded-md border border-input bg-muted px-3 py-2 text-sm" placeholder="0 MB">
|
| 332 |
+
<button onclick="clearImageCache()" class="inline-flex items-center justify-center rounded-md text-sm font-medium bg-secondary text-secondary-foreground hover:bg-red-600 hover:text-white h-9 w-9 p-0 transition-colors">
|
| 333 |
<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">
|
| 334 |
+
<path d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2M10 11v6M14 11v6"/>
|
|
|
|
|
|
|
| 335 |
</svg>
|
| 336 |
</button>
|
| 337 |
</div>
|
| 338 |
+
</div>
|
| 339 |
+
<div>
|
| 340 |
+
<label class="text-sm font-medium text-muted-foreground mb-2 block">视频缓存</label>
|
| 341 |
+
<div class="flex gap-2">
|
| 342 |
<input id="videoCacheSize" readonly class="flex h-9 flex-1 rounded-md border border-input bg-muted px-3 py-2 text-sm" placeholder="0 MB">
|
| 343 |
+
<button onclick="clearVideoCache()" class="inline-flex items-center justify-center rounded-md text-sm font-medium bg-secondary text-secondary-foreground hover:bg-red-600 hover:text-white h-9 w-9 p-0 transition-colors">
|
| 344 |
<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">
|
| 345 |
+
<path d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2M10 11v6M14 11v6"/>
|
|
|
|
| 346 |
</svg>
|
| 347 |
</button>
|
| 348 |
</div>
|
| 349 |
+
</div>
|
| 350 |
+
<div>
|
| 351 |
+
<label class="text-sm font-medium text-muted-foreground mb-2 block">所有缓存</label>
|
| 352 |
+
<div class="flex gap-2">
|
| 353 |
<input id="totalCacheSize" readonly class="flex h-9 flex-1 rounded-md border border-input bg-muted px-3 py-2 text-sm font-medium" placeholder="0 MB">
|
| 354 |
+
<button onclick="clearCache()" class="inline-flex items-center justify-center rounded-md text-sm font-medium bg-secondary text-secondary-foreground hover:bg-red-600 hover:text-white h-9 w-9 p-0 transition-colors">
|
| 355 |
+
<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">
|
| 356 |
+
<path d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2M10 11v6M14 11v6"/>
|
|
|
|
|
|
|
|
|
|
| 357 |
</svg>
|
|
|
|
| 358 |
</button>
|
| 359 |
</div>
|
| 360 |
</div>
|
| 361 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 362 |
</div>
|
| 363 |
</div>
|
| 364 |
+
</div>
|
| 365 |
|
| 366 |
+
<!-- Grok 配置区域 -->
|
| 367 |
+
<div>
|
| 368 |
+
<div class="flex items-center justify-between mb-6">
|
| 369 |
+
<h2 class="text-xl font-bold">Grok 配置</h2>
|
| 370 |
+
<button onclick="saveGrokSettings()" class="inline-flex items-center justify-center rounded-md text-sm font-medium bg-secondary text-secondary-foreground hover:bg-black hover:text-white h-9 px-4 transition-colors">保存配置</button>
|
| 371 |
+
</div>
|
| 372 |
+
<div class="grid gap-4 lg:grid-cols-3">
|
| 373 |
+
<!-- 基础设置 -->
|
| 374 |
+
<div class="rounded-lg border border-border bg-background p-6">
|
| 375 |
+
<h3 class="text-sm font-semibold mb-4">基础设置</h3>
|
| 376 |
+
<div class="space-y-4">
|
| 377 |
+
<div>
|
| 378 |
+
<label class="text-sm font-medium text-muted-foreground mb-2 flex items-center gap-1">
|
| 379 |
+
API Key
|
| 380 |
+
<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>
|
| 381 |
+
</label>
|
| 382 |
+
<input id="cfgApiKey" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="">
|
| 383 |
+
</div>
|
| 384 |
+
<div>
|
| 385 |
+
<label class="text-sm font-medium text-muted-foreground mb-2 flex items-center gap-1">
|
| 386 |
+
X Statsig ID
|
| 387 |
+
<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="Statsig统计ID,用于功能实验和统计分析">?</span>
|
| 388 |
+
</label>
|
| 389 |
+
<input id="cfgStatsigId" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="">
|
| 390 |
+
</div>
|
| 391 |
+
<div>
|
| 392 |
+
<label class="text-sm font-medium text-muted-foreground mb-2 flex items-center gap-1">
|
| 393 |
+
过滤标签
|
| 394 |
+
<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="需要过滤的响应标签,多个标签用逗号分隔。如:xaiartifact,xai:tool_usage_card">?</span>
|
| 395 |
+
</label>
|
| 396 |
+
<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">
|
| 397 |
+
</div>
|
| 398 |
+
<div>
|
| 399 |
+
<label class="text-sm font-medium text-muted-foreground mb-2 flex items-center gap-1">
|
| 400 |
+
临时会话
|
| 401 |
+
<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>
|
| 402 |
+
</label>
|
| 403 |
+
<select id="cfgTemporary" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm">
|
| 404 |
+
<option value="false">关闭</option>
|
| 405 |
+
<option value="true">开启</option>
|
| 406 |
+
</select>
|
| 407 |
+
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 408 |
</div>
|
| 409 |
+
</div>
|
| 410 |
+
|
| 411 |
+
<!-- 代理设置 -->
|
| 412 |
+
<div class="rounded-lg border border-border bg-background p-6">
|
| 413 |
+
<h3 class="text-sm font-semibold mb-4">代理设置</h3>
|
| 414 |
+
<div class="space-y-4">
|
| 415 |
+
<div>
|
| 416 |
+
<label class="text-sm font-medium text-muted-foreground mb-2 flex items-center gap-1">
|
| 417 |
+
CF Clearance
|
| 418 |
+
<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验证cookie,用于绕过Cloudflare人机验证。">?</span>
|
| 419 |
+
</label>
|
| 420 |
+
<input id="cfgCfClearance" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="">
|
| 421 |
+
</div>
|
| 422 |
+
<div>
|
| 423 |
+
<label class="text-sm font-medium text-muted-foreground mb-2 flex items-center gap-1">
|
| 424 |
+
Proxy Url (服务代理)
|
| 425 |
+
<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请求和上传使用的代理。支持 http、https、socks5。格式:socks5://user:pass@host:port">?</span>
|
| 426 |
+
</label>
|
| 427 |
+
<input id="cfgProxyUrl" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="socks5://username:password@127.0.0.1:7890">
|
| 428 |
+
</div>
|
| 429 |
+
<div>
|
| 430 |
+
<label class="text-sm font-medium text-muted-foreground mb-2 flex items-center gap-1">
|
| 431 |
+
Cache Proxy Url (缓存代理)
|
| 432 |
+
<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的图片/视频获取接口对IP风控要求不高,可使用便宜的大流量节点">?</span>
|
| 433 |
+
</label>
|
| 434 |
+
<input id="cfgCacheProxyUrl" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="socks5://username:password@127.0.0.1:7890">
|
| 435 |
+
</div>
|
| 436 |
</div>
|
| 437 |
+
</div>
|
| 438 |
+
|
| 439 |
+
<!-- 超时设置 -->
|
| 440 |
+
<div class="rounded-lg border border-border bg-background p-6">
|
| 441 |
+
<h3 class="text-sm font-semibold mb-4">超时设置</h3>
|
| 442 |
+
<div class="space-y-4">
|
| 443 |
+
<div>
|
| 444 |
+
<label class="text-sm font-medium text-muted-foreground mb-2 flex items-center gap-1">
|
| 445 |
+
首次响应超时 (秒)
|
| 446 |
+
<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首次返回数据的最大时间(秒)。超时后会报错,建议30-60秒">?</span>
|
| 447 |
+
</label>
|
| 448 |
+
<input id="cfgStreamFirstResponseTimeout" type="number" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="30">
|
| 449 |
+
</div>
|
| 450 |
+
<div>
|
| 451 |
+
<label class="text-sm font-medium text-muted-foreground mb-2 flex items-center gap-1">
|
| 452 |
+
流式间隔超时 (秒)
|
| 453 |
+
<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="两次数据块之间的最大间隔时间(秒)。如果超过此时间没有收到新数据则断开,建议60-180秒">?</span>
|
| 454 |
+
</label>
|
| 455 |
+
<input id="cfgStreamChunkTimeout" type="number" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="120">
|
| 456 |
+
</div>
|
| 457 |
+
<div>
|
| 458 |
+
<label class="text-sm font-medium text-muted-foreground mb-2 flex items-center gap-1">
|
| 459 |
+
生成总过程超时 (秒)
|
| 460 |
+
<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="整个对话生成的最大总时长(秒)。适用于超长对话,建议300-900秒">?</span>
|
| 461 |
+
</label>
|
| 462 |
+
<input id="cfgStreamTotalTimeout" type="number" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="600">
|
| 463 |
+
</div>
|
| 464 |
</div>
|
| 465 |
</div>
|
| 466 |
+
</div>
|
| 467 |
+
</div>
|
| 468 |
+
|
| 469 |
+
<!-- 配置提示 -->
|
| 470 |
+
<div class="mt-8 rounded border border-blue-300 bg-blue-50 px-4 py-3">
|
| 471 |
+
<div class="text-xs text-gray-800 leading-relaxed">
|
| 472 |
+
<div class="text-base font-medium text-gray-900 mb-2.5">部分说明</div>
|
| 473 |
+
<div class="space-y-1.5">
|
| 474 |
+
<div><span class="font-medium">X Statsig ID:</span>反机器人验证参数,非必要请勿修改 X Statsig ID</div>
|
| 475 |
+
<div><span class="font-medium">服务网址:</span>图片/视频链接返回时需要拼接您的服务网址(如 https://yourdomain.com),若您不使用视频功能且图片使用Base64模式则可留空</div>
|
| 476 |
+
<div><span class="font-medium">代理设置:</span>服务代理用于访问Grok API和上传图片;缓存代理专门用于下载图片和视频缓存。若仅设置服务代理,缓存将使用相同的代理;若都设置,则分别使用不同的代理</div>
|
| 477 |
+
<div><span class="font-medium">请求 403:</span>通常是被 CF 拦截了,可采用以下办法之一:1. 更换服务器IP | 2. 配置代理IP | 3.在服务器中访问 grok.com 通过 CF 验证后 F12 获取 cf_clearance</div>
|
| 478 |
</div>
|
| 479 |
</div>
|
| 480 |
</div>
|
|
|
|
| 495 |
</div>
|
| 496 |
<div class="p-5 space-y-4">
|
| 497 |
<div class="space-y-2">
|
| 498 |
+
<label class="text-sm font-medium text-muted-foreground">Token 类型</label>
|
| 499 |
<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">
|
| 500 |
<option value="sso">SSO</option>
|
| 501 |
<option value="ssoSuper">SuperSSO</option>
|
| 502 |
</select>
|
| 503 |
</div>
|
| 504 |
<div class="space-y-2">
|
| 505 |
+
<label class="text-sm font-medium text-muted-foreground">Token 列表 <span class="text-muted-foreground">(每行一个)</span></label>
|
| 506 |
<textarea
|
| 507 |
id="addTokenList"
|
| 508 |
rows="12"
|
|
|
|
| 538 |
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)}},
|
| 539 |
updateRemaining=()=>{const r=calcRemaining();['Total','Normal','Heavy'].forEach(k=>$(`stat${k}Remaining`).textContent=r[k.toLowerCase()]===0?'-':r[k.toLowerCase()].toLocaleString())}
|
| 540 |
|
| 541 |
+
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=>`<tr class="transition-colors"><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><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><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><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><td class="py-2.5 px-3 align-middle w-20 text-xs tabular-nums">${t.remaining_queries===-1?'-':t.remaining_queries}</td><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><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><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></tr>`).join('');updateBatchActions()},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 542 |
toggleToken=t=>selectedTokens[selectedTokens.has(t)?'delete':'add'](t)||updateBatchActions(),
|
| 543 |
toggleSelectAll=()=>{const sa=$('selectAll');sa.checked?filteredTokens.forEach(t=>selectedTokens.add(t.token)):selectedTokens.clear();renderTokens()},
|
| 544 |
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},
|
|
|
|
| 551 |
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)}},
|
| 552 |
copyToken=async(t,e)=>{e.stopPropagation();try{await navigator.clipboard.writeText(t);showToast('Token 已复制到剪贴板','success')}catch(err){console.error('复制失败:',err);showToast('复制失败,请手动复制','error')}}
|
| 553 |
|
| 554 |
+
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.display='none';document.body.appendChild(l);l.click();document.body.removeChild(l);URL.revokeObjectURL(l.href);showToast(`已导出 ${selectedTokens.size} 个 Token`,'success')}
|
| 555 |
+
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 .3s';setTimeout(()=>d.parentNode&&document.body.removeChild(d),300)},2000)}
|
| 556 |
logout=async()=>{if(!confirm('确定要退出登录吗?'))return;try{await apiRequest('/api/logout',{method:'POST'})}catch(e){console.error('登出失败:',e)}finally{localStorage.removeItem('adminToken');location.href='/login'}},
|
| 557 |
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()},
|
| 558 |
+
updateCacheProxyReadonly=()=>{const proxyUrl=$('cfgProxyUrl').value.trim(),cacheProxyInput=$('cfgCacheProxyUrl');if(proxyUrl){cacheProxyInput.readOnly=false;cacheProxyInput.classList.remove('bg-muted');cacheProxyInput.placeholder='socks5://username:password@127.0.0.1:7890'}else{cacheProxyInput.readOnly=true;cacheProxyInput.classList.add('bg-muted');cacheProxyInput.value='';cacheProxyInput.placeholder='设置服务代理后自动启用'}};
|
| 559 |
+
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';$('cfgImageCacheMaxSize').value=g.image_cache_max_size_mb||500;$('cfgVideoCacheMaxSize').value=g.video_cache_max_size_mb||1000;$('cfgImageMode').value=g.image_mode||'url';$('cfgBaseUrl').value=g.base_url||'';$('cfgApiKey').value=k.api_key||'';$('cfgProxyUrl').value=k.proxy_url||'';$('cfgCacheProxyUrl').value=k.cache_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';$('cfgStreamChunkTimeout').value=k.stream_chunk_timeout||120;$('cfgStreamFirstResponseTimeout').value=k.stream_first_response_timeout||30;$('cfgStreamTotalTimeout').value=k.stream_total_timeout||600;updateCacheProxyReadonly();await loadCacheSize()}}catch(e){console.error('加载配置失败:',e);showToast('加载配置失败','error')}},
|
| 560 |
loadCacheSize=async()=>{try{const r=await apiRequest('/api/cache/size');if(!r)return;const d=await r.json();if(d.success){$('imageCacheSize').value=d.data.image_size||'0 MB';$('videoCacheSize').value=d.data.video_size||'0 MB';$('totalCacheSize').value=d.data.total_size||'0 MB'}}catch(e){console.error('加载缓存大小失败:',e);$('imageCacheSize').value='0 MB';$('videoCacheSize').value='0 MB';$('totalCacheSize').value='0 MB'}},
|
| 561 |
+
clearImageCache=async()=>{if(!confirm('确定要清理图片缓存吗?此操作将删除所有图片缓存文件!'))return;try{const r=await apiRequest('/api/cache/clear/images',{method:'POST'});if(!r)return;const d=await r.json();d.success?(showToast(`图片缓存清理完成,已删除 ${d.data.deleted_count||0} 个文件`,'success'),await loadCacheSize()):showToast('清理失败: '+(d.error||'未知错误'),'error')}catch(e){showToast('清理失败: '+e.message,'error')}},
|
| 562 |
+
clearVideoCache=async()=>{if(!confirm('确定要清理视频缓存吗?此操作将删除所有视频缓存文件!'))return;try{const r=await apiRequest('/api/cache/clear/videos',{method:'POST'});if(!r)return;const d=await r.json();d.success?(showToast(`视频缓存清理完成,已删除 ${d.data.deleted_count||0} 个文件`,'success'),await loadCacheSize()):showToast('清理失败: '+(d.error||'未知错误'),'error')}catch(e){showToast('清理失败: '+e.message,'error')}},
|
| 563 |
+
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();d.success?(showToast(`缓存清理完成,已删除 ${d.data.deleted_count||0} 个文件`,'success'),await loadCacheSize()):showToast('清理失败: '+(d.error||'未知错误'),'error')}catch(e){showToast('清理失败: '+e.message,'error')}},
|
| 564 |
+
saveGlobalSettings=async()=>{const gc={admin_username:$('cfgAdminUser').value,log_level:$('cfgLogLevel').value,image_cache_max_size_mb:parseInt($('cfgImageCacheMaxSize').value)||500,video_cache_max_size_mb:parseInt($('cfgVideoCacheMaxSize').value)||1000,image_mode:$('cfgImageMode').value,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')}},
|
| 565 |
+
saveGrokSettings=async()=>{const pu=$('cfgProxyUrl').value.trim(),kc={api_key:$('cfgApiKey').value,proxy_url:pu,cache_proxy_url:pu?$('cfgCacheProxyUrl').value:'',cf_clearance:$('cfgCfClearance').value,x_statsig_id:$('cfgStatsigId').value,filtered_tags:$('cfgFilteredTags').value,temporary:$('cfgTemporary').value==='true',stream_chunk_timeout:parseInt($('cfgStreamChunkTimeout').value)||120,stream_first_response_timeout:parseInt($('cfgStreamFirstResponseTimeout').value)||30,stream_total_timeout:parseInt($('cfgStreamTotalTimeout').value)||600};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')}};
|
| 566 |
+
const updateHoverCardPosition=(cardElement)=>{const trigger=cardElement.querySelector('.hover-card-trigger'),content=cardElement.querySelector('.hover-card-content');if(!trigger||!content)return;console.log('更新悬浮卡片位置',cardElement,trigger,content);const rect=trigger.getBoundingClientRect(),windowHeight=window.innerHeight,spaceAbove=rect.top,spaceBelow=windowHeight-rect.bottom;content.classList.remove('top','bottom');const originalVisibility=getComputedStyle(content).visibility,originalOpacity=getComputedStyle(content).opacity;content.style.visibility='hidden';content.style.opacity='1';const contentHeight=content.offsetHeight;content.style.visibility=originalVisibility;content.style.opacity=originalOpacity;const position=spaceAbove>contentHeight+10?'top':spaceBelow>contentHeight+10?'bottom':'top';content.classList.add(position);console.log('悬浮卡片位置:',position,'高度:',contentHeight,'上方空间:',spaceAbove,'下方空间:',spaceBelow)},
|
| 567 |
+
loadStorageMode=async()=>{try{const r=await apiRequest('/api/storage/mode');if(!r)return;const d=await r.json();if(d.success){const mode=d.data.mode;console.log('存储模式:',mode);$('storageModeText').textContent=mode;if(mode==='MYSQL'){$('storageMode').classList.add('bg-blue-50','text-blue-700','border-blue-200');$('storageModeTooltip').textContent='数据库连接模式 - 数据持久化存储,修改配置时可能稍慢但更安全'}else if(mode==='REDIS'){$('storageMode').classList.add('bg-purple-50','text-purple-700','border-purple-200');$('storageModeTooltip').textContent='Redis缓存模式 - 高速内存存储,数据持久化且读写性能极佳'}else{$('storageMode').classList.add('bg-green-50','text-green-700','border-green-200');$('storageModeTooltip').textContent='文件存储模式 - 本地文件存储,读写速度快'};updateHoverCardPosition($('storageMode').closest('.hover-card'))}}catch(e){console.error('加载存储模式失败:',e);$('storageModeText').textContent='FILE';$('storageMode').classList.add('bg-green-50','text-green-700','border-green-200');$('storageModeTooltip').textContent='文件存储模式 - 本地文件存储,读写速度快';updateHoverCardPosition($('storageMode').closest('.hover-card'))}};
|
| 568 |
+
window.addEventListener('DOMContentLoaded',()=>{checkAuth();loadStorageMode();refreshTokens();setInterval(()=>{loadStats();updateRemaining()},30000);window.addEventListener('resize',()=>{const hoverCard=$('storageMode').closest('.hover-card');hoverCard&&updateHoverCardPosition(hoverCard)});const hoverCard=$('storageMode').closest('.hover-card'),trigger=hoverCard?.querySelector('.hover-card-trigger'),content=hoverCard?.querySelector('.hover-card-content');if(trigger&&content){trigger.addEventListener('mouseenter',()=>{content.style.opacity='1';content.style.visibility='visible'});trigger.addEventListener('mouseleave',()=>{content.style.opacity='0';content.style.visibility='hidden'})};$('cfgProxyUrl').addEventListener('input',updateCacheProxyReadonly)});
|
| 569 |
</script>
|
| 570 |
</body>
|
| 571 |
</html>
|
app/template/favicon.png
ADDED
|
|
Git LFS Details
|
app/template/login.html
CHANGED
|
@@ -4,52 +4,14 @@
|
|
| 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 |
-
|
| 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">
|
|
@@ -64,44 +26,15 @@
|
|
| 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
|
| 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
|
| 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">
|
|
@@ -112,69 +45,10 @@
|
|
| 112 |
</div>
|
| 113 |
|
| 114 |
<script>
|
| 115 |
-
const
|
| 116 |
-
const
|
| 117 |
-
|
| 118 |
-
|
| 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>
|
|
|
|
| 4 |
<meta charset="UTF-8">
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
<title>登录 - Grok2API</title>
|
| 7 |
+
<link rel="icon" type="image/png" href="/static/favicon.png">
|
| 8 |
<script src="https://cdn.tailwindcss.com"></script>
|
| 9 |
<script>
|
| 10 |
+
tailwind.config={theme:{extend:{colors:{border:"hsl(0 0% 89%)",input:"hsl(0 0% 89%)",ring:"hsl(0 0% 3.9%)",background:"hsl(0 0% 100%)",foreground:"hsl(0 0% 3.9%)",primary:{DEFAULT:"hsl(0 0% 9%)",foreground:"hsl(0 0% 98%)"},secondary:{DEFAULT:"hsl(0 0% 96.1%)",foreground:"hsl(0 0% 9%)"},muted:{DEFAULT:"hsl(0 0% 96.1%)",foreground:"hsl(0 0% 45.1%)"},destructive:{DEFAULT:"hsl(0 84.2% 60.2%)",foreground:"hsl(0 0% 98%)"}}}}}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
</script>
|
| 12 |
<style>
|
| 13 |
+
@keyframes slide-up{from{transform:translateY(100%);opacity:0}to{transform:translateY(0);opacity:1}}
|
| 14 |
+
.animate-slide-up{animation:slide-up .3s ease-out}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
</style>
|
| 16 |
</head>
|
| 17 |
<body class="h-full bg-background text-foreground antialiased">
|
|
|
|
| 26 |
<div class="sm:mx-auto sm:w-full sm:max-w-md">
|
| 27 |
<div class="bg-background py-8 px-4 sm:px-10 rounded-lg">
|
| 28 |
<form id="loginForm" class="space-y-6">
|
|
|
|
| 29 |
<div class="space-y-2">
|
| 30 |
+
<label for="username" class="text-sm font-medium">账户</label>
|
| 31 |
+
<input type="text" id="username" name="username" required class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50" placeholder="请输入账户">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
</div>
|
|
|
|
|
|
|
| 33 |
<div class="space-y-2">
|
| 34 |
+
<label for="password" class="text-sm font-medium">密码</label>
|
| 35 |
+
<input type="password" id="password" name="password" required class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50" placeholder="请输入密码">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
</div>
|
| 37 |
+
<button type="submit" id="loginButton" class="inline-flex items-center justify-center rounded-md font-medium transition-colors bg-primary text-primary-foreground hover:bg-primary/90 h-10 w-full disabled:opacity-50">登录</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
</form>
|
| 39 |
|
| 40 |
<div class="mt-6 text-center text-xs text-muted-foreground">
|
|
|
|
| 45 |
</div>
|
| 46 |
|
| 47 |
<script>
|
| 48 |
+
const form=document.getElementById('loginForm'),btn=document.getElementById('loginButton');
|
| 49 |
+
form.addEventListener('submit',async(e)=>{e.preventDefault();btn.disabled=true;btn.textContent='登录中...';try{const fd=new FormData(form),r=await fetch('/api/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:fd.get('username'),password:fd.get('password')})});const d=await r.json();d.success?(localStorage.setItem('adminToken',d.token),location.href='/manage'):showToast(d.message||'登录失败','error')}catch(e){showToast('网络错误,请稍后重试','error')}finally{btn.disabled=false;btn.textContent='登录'}});
|
| 50 |
+
function showToast(m,t='error'){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.error} 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 .3s';setTimeout(()=>d.parentNode&&document.body.removeChild(d),300)},2000)}
|
| 51 |
+
window.addEventListener('DOMContentLoaded',()=>{const t=localStorage.getItem('adminToken');t&&fetch('/api/stats',{headers:{Authorization:`Bearer ${t}`}}).then(r=>{if(r.ok)location.href='/manage'})});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
</script>
|
| 53 |
</body>
|
| 54 |
</html>
|
data/setting.toml
CHANGED
|
@@ -1,20 +1,19 @@
|
|
| 1 |
-
[global]
|
| 2 |
-
admin_username = "admin"
|
| 3 |
-
admin_password = "admin"
|
| 4 |
-
log_level = "INFO"
|
| 5 |
-
image_cache_max_size_mb = 500
|
| 6 |
-
video_cache_max_size_mb = 1000
|
| 7 |
-
base_url = ""
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
[grok]
|
| 12 |
api_key = ""
|
| 13 |
proxy_url = ""
|
| 14 |
-
stream_chunk_timeout = 120
|
| 15 |
-
stream_first_response_timeout = 30
|
| 16 |
-
stream_total_timeout = 600
|
| 17 |
-
cf_clearance = ""
|
| 18 |
temporary = true
|
|
|
|
|
|
|
| 19 |
filtered_tags = "xaiartifact,xai:tool_usage_card,grok:render"
|
| 20 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
[grok]
|
| 2 |
api_key = ""
|
| 3 |
proxy_url = ""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
temporary = true
|
| 5 |
+
cf_clearance = ""
|
| 6 |
+
x_statsig_id = "ZTpUeXBlRXJyb3I6IENhbm5vdCByZWFkIHByb3BlcnRpZXMgb2YgdW5kZWZpbmVkIChyZWFkaW5nICdjaGlsZE5vZGVzJyk="
|
| 7 |
filtered_tags = "xaiartifact,xai:tool_usage_card,grok:render"
|
| 8 |
+
stream_chunk_timeout = 120
|
| 9 |
+
stream_total_timeout = 600
|
| 10 |
+
stream_first_response_timeout = 30
|
| 11 |
+
|
| 12 |
+
[global]
|
| 13 |
+
base_url = ""
|
| 14 |
+
log_level = "DEBUG"
|
| 15 |
+
image_mode = "url"
|
| 16 |
+
admin_password = "admin"
|
| 17 |
+
admin_username = "admin"
|
| 18 |
+
image_cache_max_size_mb = 512
|
| 19 |
+
video_cache_max_size_mb = 1024
|
data/token.json
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
{
|
| 2 |
-
"
|
| 3 |
-
"
|
| 4 |
}
|
|
|
|
| 1 |
{
|
| 2 |
+
"ssoSuper": {},
|
| 3 |
+
"ssoNormal": {}
|
| 4 |
}
|
docker-compose.yml
CHANGED
|
@@ -6,6 +6,14 @@ services:
|
|
| 6 |
volumes:
|
| 7 |
- grok_data:/app/data
|
| 8 |
- ./logs:/app/logs
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
volumes:
|
| 11 |
-
grok_data:
|
|
|
|
| 6 |
volumes:
|
| 7 |
- grok_data:/app/data
|
| 8 |
- ./logs:/app/logs
|
| 9 |
+
environment:
|
| 10 |
+
# =====存储模式: file, mysql 或 redis=====
|
| 11 |
+
- STORAGE_MODE=file
|
| 12 |
+
# =====数据库连接 URL (仅在STORAGE_MODE=mysql或redis时需要)=====
|
| 13 |
+
# - DATABASE_URL=mysql://user:password@host:3306/grok2api
|
| 14 |
+
|
| 15 |
+
## MySQL格式: mysql://user:password@host:port/database
|
| 16 |
+
## Redis格式: redis://host:port/db 或 redis://user:password@host:port/db
|
| 17 |
|
| 18 |
volumes:
|
| 19 |
+
grok_data:
|
main.py
CHANGED
|
@@ -5,28 +5,75 @@ 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 |
-
|
| 18 |
-
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
|
| 22 |
# 初始化日志
|
| 23 |
-
logger.info("[
|
| 24 |
|
| 25 |
# 创建FastAPI应用
|
| 26 |
app = FastAPI(
|
| 27 |
-
title="
|
| 28 |
-
description="
|
| 29 |
-
version="1.
|
| 30 |
lifespan=lifespan
|
| 31 |
)
|
| 32 |
|
|
@@ -39,13 +86,27 @@ app.include_router(models_router, prefix="/v1")
|
|
| 39 |
app.include_router(images_router)
|
| 40 |
app.include_router(admin_router)
|
| 41 |
|
| 42 |
-
#
|
| 43 |
app.mount("/static", StaticFiles(directory="app/template"), name="template")
|
| 44 |
|
| 45 |
@app.get("/")
|
| 46 |
async def root():
|
| 47 |
"""根路径"""
|
| 48 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
|
| 50 |
|
| 51 |
if __name__ == "__main__":
|
|
|
|
| 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.core.storage import storage_manager
|
| 9 |
+
from app.core.config import setting
|
| 10 |
+
from app.services.grok.token import token_manager
|
| 11 |
from app.api.v1.chat import router as chat_router
|
| 12 |
from app.api.v1.models import router as models_router
|
| 13 |
from app.api.v1.images import router as images_router
|
| 14 |
from app.api.admin.manage import router as admin_router
|
| 15 |
|
| 16 |
+
# 导入MCP服务器(认证配置在server.py中完成)
|
| 17 |
+
from app.services.mcp import mcp
|
| 18 |
|
| 19 |
+
# 创建MCP的FastAPI应用实例
|
| 20 |
+
# 使用流式HTTP传输,支持高效的双向流式通信
|
| 21 |
+
mcp_app = mcp.http_app(stateless_http=True, transport="streamable-http")
|
| 22 |
+
|
| 23 |
+
# 2. 定义应用生命周期
|
| 24 |
@asynccontextmanager
|
| 25 |
async def lifespan(app: FastAPI):
|
| 26 |
+
"""
|
| 27 |
+
启动顺序:
|
| 28 |
+
1. 初始化核心服务 (storage, settings, token_manager)
|
| 29 |
+
2. 启动MCP服务生命周期
|
| 30 |
+
|
| 31 |
+
关闭顺序 (LIFO):
|
| 32 |
+
1. 关闭MCP服务生命周期
|
| 33 |
+
2. 关闭核心服务
|
| 34 |
+
"""
|
| 35 |
+
# --- 启动过程 ---
|
| 36 |
+
# 1. 初始化核心服务
|
| 37 |
+
await storage_manager.init()
|
| 38 |
+
|
| 39 |
+
# 设置存储到配置和token管理器
|
| 40 |
+
storage = storage_manager.get_storage()
|
| 41 |
+
setting.set_storage(storage)
|
| 42 |
+
token_manager.set_storage(storage)
|
| 43 |
+
|
| 44 |
+
# 重新加载配置和token数据
|
| 45 |
+
await setting.reload()
|
| 46 |
+
token_manager._load_data()
|
| 47 |
+
logger.info("[Grok2API] 核心服务初始化完成")
|
| 48 |
+
|
| 49 |
+
# 2. 管理MCP服务的生命周期
|
| 50 |
+
mcp_lifespan_context = mcp_app.lifespan(app)
|
| 51 |
+
await mcp_lifespan_context.__aenter__()
|
| 52 |
+
logger.info("[MCP] MCP服务初始化完成")
|
| 53 |
+
|
| 54 |
+
logger.info("[Grok2API] 应用启动成功")
|
| 55 |
+
|
| 56 |
+
try:
|
| 57 |
+
yield
|
| 58 |
+
finally:
|
| 59 |
+
# --- 关闭过程 ---
|
| 60 |
+
# 1. 退出MCP服务的生命周期
|
| 61 |
+
await mcp_lifespan_context.__aexit__(None, None, None)
|
| 62 |
+
logger.info("[MCP] MCP服务已关闭")
|
| 63 |
+
|
| 64 |
+
# 2. 关闭核心服务
|
| 65 |
+
await storage_manager.close()
|
| 66 |
+
logger.info("[Grok2API] 应用关闭成功")
|
| 67 |
|
| 68 |
|
| 69 |
# 初始化日志
|
| 70 |
+
logger.info("[Grok2API] 应用正在启动...")
|
| 71 |
|
| 72 |
# 创建FastAPI应用
|
| 73 |
app = FastAPI(
|
| 74 |
+
title="Grok2API",
|
| 75 |
+
description="Grok API 转换服务",
|
| 76 |
+
version="1.3.1",
|
| 77 |
lifespan=lifespan
|
| 78 |
)
|
| 79 |
|
|
|
|
| 86 |
app.include_router(images_router)
|
| 87 |
app.include_router(admin_router)
|
| 88 |
|
| 89 |
+
# 挂载静态文件
|
| 90 |
app.mount("/static", StaticFiles(directory="app/template"), name="template")
|
| 91 |
|
| 92 |
@app.get("/")
|
| 93 |
async def root():
|
| 94 |
"""根路径"""
|
| 95 |
+
from fastapi.responses import RedirectResponse
|
| 96 |
+
return RedirectResponse(url="/login")
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
@app.get("/health")
|
| 100 |
+
async def health_check():
|
| 101 |
+
"""健康检查接口"""
|
| 102 |
+
return {
|
| 103 |
+
"status": "healthy",
|
| 104 |
+
"service": "Grok2API",
|
| 105 |
+
"version": "1.0.3"
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
# 挂载MCP服务器
|
| 109 |
+
app.mount("", mcp_app)
|
| 110 |
|
| 111 |
|
| 112 |
if __name__ == "__main__":
|
requirements.txt
CHANGED
|
@@ -1,9 +1,13 @@
|
|
| 1 |
toml==0.10.2
|
| 2 |
-
fastapi==0.
|
| 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.
|
| 9 |
-
aiofiles==
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
toml==0.10.2
|
| 2 |
+
fastapi==0.119.0
|
| 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.2
|
| 9 |
+
aiofiles==25.1.0
|
| 10 |
+
aiomysql==0.2.0
|
| 11 |
+
redis==6.4.0
|
| 12 |
+
fastmcp==2.12.4
|
| 13 |
+
cryptography==46.0.3
|