nanoppa commited on
Commit
6ae2cda
·
verified ·
1 Parent(s): 234abc5

Upload 30 files

Browse files
.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
- async with aiofiles.open(setting.config_path, "r", encoding="utf-8") as f:
448
- content = await f.read()
449
- config = toml.loads(content)
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.get_cached_video(original_path)
33
  media_type = "video/mp4"
34
  else:
35
  # 检查图片缓存
36
- cache_path = image_cache_service.get_cached_image(original_path)
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
- return toml.load(f)[section]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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(logging.Formatter(log_format))
 
39
 
40
  # 文件处理器
41
  file_handler = RotatingFileHandler(
42
  log_file, maxBytes=10 * 1024 * 1024, backupCount=5, encoding="utf-8"
43
  )
44
  file_handler.setLevel(log_level)
45
- file_handler.setFormatter(logging.Formatter(log_format))
 
46
 
 
47
  self.logger.addHandler(console_handler)
48
  self.logger.addHandler(file_handler)
49
 
 
 
 
 
 
 
 
 
 
 
50
  LoggerManager._initialized = True
51
 
52
  def debug(self, msg: str) -> None:
 
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
- @staticmethod
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 = self._get_cache_filename(file_path)
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": cookie
68
  }
69
 
70
- # 代理配置
71
- proxy_url = setting.grok_config.get("proxy_url")
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] 开始下载: {file_url}")
76
  response = await session.get(
77
- file_url,
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 get_cached_file(self, file_path: str) -> Optional[Path]:
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
- files = []
119
- total_size = 0
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
- Args:
163
- image_path: 图片路径,如 /users/xxx/generated/xxx/image.jpg
164
- auth_token: 认证令牌
165
 
166
- Returns:
167
- 缓存文件路径,下载失败返回 None`
168
- """
169
- return await self.download_file(image_path, auth_token, timeout=30.0)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
170
 
171
- def get_cached_image(self, image_path: str) -> Optional[Path]:
172
- """获取缓存的图片路径
 
 
 
 
173
 
174
- Args:
175
- image_path: 图片路径
176
 
177
- Returns:
178
- 缓存文件路径,不存在返回 None
179
- """
180
- return self.get_cached_file(image_path)
 
 
 
 
 
 
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 get_cached_video(self, video_path: str) -> Optional[Path]:
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
- # 构建Grok请求载荷
60
- payload = GrokClient._build_payload(content, model_name, model_mode, image_attachments, is_video_model)
61
 
62
- return await GrokClient._send_request(payload, auth_token, model, stream)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- proxy_config = GrokClient._get_proxy()
 
 
 
 
 
 
152
 
153
  # 构建请求参数
154
  request_kwargs = {
@@ -157,7 +188,7 @@ class GrokClient:
157
  "impersonate": IMPERSONATE_BROWSER,
158
  "timeout": REQUEST_TIMEOUT,
159
  "stream": True,
160
- "proxies": proxy_config if proxy_config else None
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
- cache_path = await image_cache_service.download_image(f"/{img}", auth_token)
163
- if cache_path:
164
- img_path = img.replace('/', '-')
165
- base_url = setting.global_config.get("base_url", "")
166
- img_url = f"{base_url}/images/{img_path}" if base_url else f"/images/{img_path}"
167
- content += f"\n![Generated Image]({img_url})"
 
168
  else:
169
- content += f"\n![Generated Image](https://assets.grok.com/{img})"
 
 
 
 
 
 
 
 
170
  except Exception as e:
171
- logger.warning(f"[Processor] 缓存图片失败: {e}")
172
  content += f"\n![Generated Image](https://assets.grok.com/{img})"
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
- await image_cache_service.download_image(f"/{img}", auth_token)
342
- # 本地图片路径
343
- img_path = img.replace('/', '-')
344
- base_url = setting.global_config.get("base_url", "")
345
- img_url = f"{base_url}/images/{img_path}" if base_url else f"/images/{img_path}"
346
- content += f"![Generated Image]({img_url})\n"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
347
  except Exception as e:
348
- logger.warning(f"[Processor] 缓存图片失败: {e}")
349
  content += f"![Generated Image](https://assets.grok.com/{img})\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![Generated Image]({base64_str})"
170
+ else:
171
+ content += f"\n![Generated Image](https://assets.grok.com/{img})"
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![Generated Image]({img_url})"
180
+ else:
181
+ content += f"\n![Generated Image](https://assets.grok.com/{img})"
182
  except Exception as e:
183
+ logger.warning(f"[Processor] 处理图片失败: {e}")
184
  content += f"\n![Generated Image](https://assets.grok.com/{img})"
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 = "![Generated Image](data:"
363
+ markdown_suffix = ")\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"![Generated Image]({base64_str})\n")
391
+ timeout_manager.mark_chunk_received()
392
+ chunk_index += 1
393
+ else:
394
+ yield make_chunk(f"![Generated Image]({base64_str})\n")
395
+ timeout_manager.mark_chunk_received()
396
+ chunk_index += 1
397
+ else:
398
+ yield make_chunk(f"![Generated Image](https://assets.grok.com/{img})\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"![Generated Image]({img_url})\n"
409
  except Exception as e:
410
+ logger.warning(f"[Processor] 处理图片失败: {e}")
411
  content += f"![Generated Image](https://assets.grok.com/{img})\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
- async with self._file_lock:
83
- async with aiofiles.open(self.token_file, "w", encoding="utf-8") as f:
84
- await f.write(json.dumps(self.token_data, indent=2, ensure_ascii=False))
 
 
 
 
 
85
  except IOError as e:
86
  logger.error(f"[Token] 保存Token数据失败: {str(e)}")
87
  raise GrokApiException(
@@ -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: x-statsig-id失效,不影响Token状态
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错误是x-statsig-id失效,不是Token问题
319
  if status_code == STATSIG_INVALID_CODE:
320
- logger.warning(f"[Token] x-statsig-id失效 (403),需要更新配置文件中的x_statsig_id")
 
 
 
321
  return
322
 
323
  sso_value = self._extract_sso(auth_token)
 
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
- from {
11
- transform: translateY(100%);
12
- opacity: 0;
13
- }
14
- to {
15
- transform: translateY(0);
16
- opacity: 1;
17
- }
18
- }
19
- .animate-slide-up {
20
- animation: slide-up 0.3s ease-out;
21
- }
22
- .tab-btn {
23
- transition: all 0.2s ease;
24
- }
25
- [title] {
26
- position: relative;
27
- }
28
- [title]:hover::after {
29
- content: attr(title);
30
- position: absolute;
31
- bottom: 100%;
32
- left: 50%;
33
- transform: translateX(-50%) translateY(-4px);
34
- background: hsl(0 0% 3.9%);
35
- color: hsl(0 0% 98%);
36
- padding: 4px 8px;
37
- border-radius: 4px;
38
- font-size: 11px;
39
- white-space: nowrap;
40
- z-index: 1000;
41
- pointer-events: none;
42
- box-shadow: 0 2px 8px rgba(0,0,0,0.15);
43
- }
44
- [title]:hover::before {
45
- content: '';
46
- position: absolute;
47
- bottom: 100%;
48
- left: 50%;
49
- transform: translateX(-50%);
50
- border: 4px solid transparent;
51
- border-top-color: hsl(0 0% 3.9%);
52
- z-index: 1000;
53
- }
54
  </style>
55
  <script>
56
- tailwind.config = {
57
- theme: {
58
- extend: {
59
- colors: {
60
- border: "hsl(0 0% 89%)",
61
- input: "hsl(0 0% 89%)",
62
- ring: "hsl(0 0% 3.9%)",
63
- background: "hsl(0 0% 100%)",
64
- foreground: "hsl(0 0% 3.9%)",
65
- primary: {
66
- DEFAULT: "hsl(0 0% 9%)",
67
- foreground: "hsl(0 0% 98%)",
68
- },
69
- secondary: {
70
- DEFAULT: "hsl(0 0% 96.1%)",
71
- foreground: "hsl(0 0% 9%)",
72
- },
73
- muted: {
74
- DEFAULT: "hsl(0 0% 96.1%)",
75
- foreground: "hsl(0 0% 45.1%)",
76
- },
77
- accent: {
78
- DEFAULT: "hsl(0 0% 96.1%)",
79
- foreground: "hsl(0 0% 9%)",
80
- },
81
- destructive: {
82
- DEFAULT: "hsl(0 84.2% 60.2%)",
83
- foreground: "hsl(0 0% 98%)",
84
- },
85
- },
86
- }
87
- }
88
- }
89
  </script>
90
  </head>
91
  <body class="h-full bg-background text-foreground antialiased">
92
  <!-- 导航栏 -->
93
  <header class="sticky top-0 z-50 w-full border-b border-border/40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
94
  <div class="mx-auto flex h-14 max-w-7xl items-center px-6">
95
- <div class="mr-4 flex">
96
- <span class="font-bold text-lg">Grok2API</span>
 
97
  </div>
98
- <div class="flex flex-1 items-center justify-between space-x-2 md:justify-end">
99
- <nav class="flex items-center space-x-2">
100
- <button
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
101
  onclick="logout()"
102
- class="inline-flex items-center justify-center text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 hover:bg-accent hover:text-accent-foreground h-9 px-4 py-2"
103
  >
 
 
 
 
 
104
  退出
105
  </button>
106
  </nav>
@@ -274,176 +244,237 @@
274
 
275
  <!-- 全局设置面板 -->
276
  <div id="panelSettings" class="hidden">
277
- <div class="grid gap-6 lg:grid-cols-2">
278
- <!-- 全局配置 -->
279
- <div class="rounded-lg border border-border bg-background p-6">
280
- <h3 class="text-lg font-semibold mb-4">全局配置</h3>
281
- <div class="space-y-4">
282
- <div>
283
- <label class="text-sm font-medium flex items-center gap-1 mb-2">
284
- 管理账户
285
- <span class="inline-flex items-center justify-center w-3.5 h-3.5 rounded-full border border-muted-foreground text-muted-foreground cursor-help" style="font-size:10px;line-height:1" title="登录管理后台的用户名">?</span>
286
- </label>
287
- <input id="cfgAdminUser" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="admin">
288
- </div>
289
- <div>
290
- <label class="text-sm font-medium flex items-center gap-1 mb-2">
291
- 管理密码
292
- <span class="inline-flex items-center justify-center w-3.5 h-3.5 rounded-full border border-muted-foreground text-muted-foreground cursor-help" style="font-size:10px;line-height:1" title="登录管理后台的密码,留空表示不修改当前密码">?</span>
293
- </label>
294
- <input id="cfgAdminPass" type="password" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="留空则不修改">
295
- </div>
296
- <div>
297
- <label class="text-sm font-medium flex items-center gap-1 mb-2">
298
- 日志级别
299
- <span class="inline-flex items-center justify-center w-3.5 h-3.5 rounded-full border border-muted-foreground text-muted-foreground cursor-help" style="font-size:10px;line-height:1" title="日志详细程度。DEBUG:最详细 | INFO:一般信息 | WARNING:警告 | ERROR:仅错误">?</span>
300
- </label>
301
- <select id="cfgLogLevel" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm">
302
- <option>DEBUG</option><option>INFO</option><option>WARNING</option><option>ERROR</option>
303
- </select>
304
- </div>
305
- <div>
306
- <label class="text-sm font-medium flex items-center gap-1 mb-2">
307
- 图片缓存上限 (MB)
308
- <span class="inline-flex items-center justify-center w-3.5 h-3.5 rounded-full border border-muted-foreground text-muted-foreground cursor-help" style="font-size:10px;line-height:1" title="图片缓存目录的最大容量,超过后自动删除最旧的缓存文件">?</span>
309
- </label>
310
- <input id="cfgImageCacheMaxSize" type="number" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="500">
311
  </div>
312
- <div>
313
- <label class="text-sm font-medium flex items-center gap-1 mb-2">
314
- 视频缓存上限 (MB)
315
- <span class="inline-flex items-center justify-center w-3.5 h-3.5 rounded-full border border-muted-foreground text-muted-foreground cursor-help" style="font-size:10px;line-height:1" title="视频缓存目录的最大容量,超过后自动删除最旧的缓存文件">?</span>
316
- </label>
317
- <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">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
318
  </div>
319
- <div>
320
- <label class="text-sm font-medium flex items-center gap-1 mb-2">
321
- 缓存大小
322
- <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>
323
- </label>
324
- <div class="space-y-2">
325
- <div class="flex gap-2 items-center">
326
- <span class="text-sm text-muted-foreground w-12">图片:</span>
 
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-destructive text-destructive-foreground hover:bg-destructive/90 h-9 px-3 transition-colors" title="仅清除图片缓存">
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
- <rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
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
- <div class="flex gap-2 items-center">
337
- <span class="text-sm text-muted-foreground w-12">视频:</span>
 
 
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-destructive text-destructive-foreground hover:bg-destructive/90 h-9 px-3 transition-colors" title="仅清除视频缓存">
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
- <polygon points="23 7 16 12 23 17 23 7"/>
342
- <rect x="1" y="5" width="15" height="14" rx="2" ry="2"/>
343
  </svg>
344
  </button>
345
  </div>
346
- <div class="flex gap-2 items-center">
347
- <span class="text-sm text-muted-foreground w-12">总计:</span>
 
 
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-destructive text-destructive-foreground hover:bg-destructive/90 h-9 px-3 transition-colors" title="清除所有缓存(图片+视频)">
350
- <svg class="h-4 w-4 mr-1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
351
- <path d="M3 6h18"/>
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
- <!-- Grok 配置 -->
375
- <div class="rounded-lg border border-border bg-background p-6">
376
- <h3 class="text-lg font-semibold mb-4">Grok 配置</h3>
377
- <div class="space-y-4">
378
- <div>
379
- <label class="text-sm font-medium flex items-center gap-1 mb-2">
380
- API Key
381
- <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>
382
- </label>
383
- <input id="cfgApiKey" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="">
384
- </div>
385
- <div>
386
- <label class="text-sm font-medium flex items-center gap-1 mb-2">
387
- Proxy Url
388
- <span class="inline-flex items-center justify-center w-3.5 h-3.5 rounded-full border border-muted-foreground text-muted-foreground cursor-help" style="font-size:10px;line-height:1" title="HTTP代理服务器地址,用于访问Grok服务,可留空">?</span>
389
- </label>
390
- <input id="cfgProxyUrl" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="http://127.0.0.1:7890">
391
- </div>
392
- <div>
393
- <label class="text-sm font-medium flex items-center gap-1 mb-2">
394
- CF Clearance
395
- <span class="inline-flex items-center justify-center w-3.5 h-3.5 rounded-full border border-muted-foreground text-muted-foreground cursor-help" style="font-size:10px;line-height:1" title="Cloudflare安全令牌,用于绕过人机验证,可留空">?</span>
396
- </label>
397
- <input id="cfgCfClearance" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="">
398
- </div>
399
- <div>
400
- <label class="text-sm font-medium flex items-center gap-1 mb-2">
401
- X Statsig ID
402
- <span class="inline-flex items-center justify-center w-3.5 h-3.5 rounded-full border border-muted-foreground text-muted-foreground cursor-help" style="font-size:10px;line-height:1" title="Grok反机器人检测的唯一标识符,必填">?</span>
403
- </label>
404
- <input id="cfgStatsigId" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="">
405
- </div>
406
- <div>
407
- <label class="text-sm font-medium flex items-center gap-1 mb-2">
408
- Filtered_tags
409
- <span class="inline-flex items-center justify-center w-3.5 h-3.5 rounded-full border border-muted-foreground text-muted-foreground cursor-help" style="font-size:10px;line-height:1" title="过滤Grok响应中的指定标签,多个标签用逗号分隔">?</span>
410
- </label>
411
- <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">
412
- </div>
413
- <div>
414
- <label class="text-sm font-medium flex items-center gap-1 mb-2">
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
- <div>
431
- <label class="text-sm font-medium flex items-center gap-1 mb-2">
432
- 首次响应超时 (秒)
433
- <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>
434
- </label>
435
- <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">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
436
  </div>
437
- <div>
438
- <label class="text-sm font-medium flex items-center gap-1 mb-2">
439
- 总超时限制 (秒)
440
- <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="流式响应的总时长限制。设置为0表示不限制总时长">?</span>
441
- </label>
442
- <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">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
443
  </div>
444
  </div>
445
- <div class="mt-6 flex justify-end">
446
- <button onclick="saveGrokSettings()" class="inline-flex items-center justify-center rounded-md text-sm font-medium bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-6 transition-colors">保存配置</button>
 
 
 
 
 
 
 
 
 
 
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.visibility='hidden';document.body.appendChild(l);l.click();document.body.removeChild(l);URL.revokeObjectURL(l.href);showToast(`已导出 ${selectedTokens.size} 个 Token`,'success')},
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 0.3s';setTimeout(()=>d.parentNode&&document.body.removeChild(d),300)},2000)},
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
- 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;$('cfgBaseUrl').value=g.base_url||'';$('cfgApiKey').value=k.api_key||'';$('cfgProxyUrl').value=k.proxy_url||'';$('cfgCfClearance').value=k.cf_clearance||'';$('cfgStatsigId').value=k.x_statsig_id||'';$('cfgFilteredTags').value=k.filtered_tags||'';$('cfgTemporary').value=k.temporary!==false?'true':'false';$('cfgStreamChunkTimeout').value=k.stream_chunk_timeout||120;$('cfgStreamFirstResponseTimeout').value=k.stream_first_response_timeout||30;$('cfgStreamTotalTimeout').value=k.stream_total_timeout||600;await loadCacheSize()}}catch(e){console.error('加载配置失败:',e);showToast('加载配置失败','error')}},
 
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();if(d.success){showToast(`图片缓存清理完成,已删除 ${d.data.deleted_count||0} 个文件`,'success');await loadCacheSize()}else{showToast('清理失败: '+(d.error||'未知错误'),'error')}}catch(e){showToast('清理失败: '+e.message,'error')}},
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();if(d.success){showToast(`视频缓存清理完成,已删除 ${d.data.deleted_count||0} 个文件`,'success');await loadCacheSize()}else{showToast('清理失败: '+(d.error||'未知错误'),'error')}}catch(e){showToast('清理失败: '+e.message,'error')}},
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();if(d.success){showToast(`缓存清理完成,已删除 ${d.data.deleted_count||0} 个文件`,'success');await loadCacheSize()}else{showToast('清理失败: '+(d.error||'未知错误'),'error')}}catch(e){showToast('清理失败: '+e.message,'error')}},
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:$('cfgProxyUrl').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')}};
544
- window.addEventListener('DOMContentLoaded',()=>{checkAuth();refreshTokens();setInterval(()=>{loadStats();updateRemaining()},30000)});
 
 
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

  • SHA256: d92973dce171d4f418c4c9a883cd754c6e9828a0f1f7cdf7af19d4896b852306
  • Pointer size: 131 Bytes
  • Size of remote file: 104 kB
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
- from {
42
- transform: translateY(100%);
43
- opacity: 0;
44
- }
45
- to {
46
- transform: translateY(0);
47
- opacity: 1;
48
- }
49
- }
50
- .animate-slide-up {
51
- animation: slide-up 0.3s ease-out;
52
- }
53
  </style>
54
  </head>
55
  <body class="h-full bg-background text-foreground antialiased">
@@ -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 leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
70
- 账户
71
- </label>
72
- <input
73
- type="text"
74
- id="username"
75
- name="username"
76
- required
77
- class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50 transition-colors"
78
- placeholder="请输入用户名"
79
- >
80
  </div>
81
-
82
- <!-- 密码 -->
83
  <div class="space-y-2">
84
- <label for="password" class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
85
- 密码
86
- </label>
87
- <input
88
- type="password"
89
- id="password"
90
- name="password"
91
- required
92
- class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50 transition-colors"
93
- placeholder="请输入密码"
94
- >
95
  </div>
96
-
97
- <!-- 登录按钮 -->
98
- <button
99
- type="submit"
100
- id="loginButton"
101
- class="inline-flex items-center justify-center whitespace-nowrap rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-0 disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-4 py-2 w-full text-sm"
102
- >
103
- 登录
104
- </button>
105
  </form>
106
 
107
  <div class="mt-6 text-center text-xs text-muted-foreground">
@@ -112,69 +45,10 @@
112
  </div>
113
 
114
  <script>
115
- const loginForm = document.getElementById('loginForm');
116
- const loginButton = document.getElementById('loginButton');
117
-
118
- loginForm.addEventListener('submit', async (e) => {
119
- e.preventDefault();
120
-
121
- loginButton.disabled = true;
122
- loginButton.textContent = '登录中...';
123
-
124
- try {
125
- const formData = new FormData(loginForm);
126
- const response = await fetch('/api/login', {
127
- method: 'POST',
128
- headers: { 'Content-Type': 'application/json' },
129
- body: JSON.stringify({
130
- username: formData.get('username'),
131
- password: formData.get('password')
132
- })
133
- });
134
-
135
- const data = await response.json();
136
-
137
- if (data.success) {
138
- localStorage.setItem('adminToken', data.token);
139
- window.location.href = '/manage';
140
- } else {
141
- showToast(data.message || '登录失败', 'error');
142
- }
143
- } catch (error) {
144
- showToast('网络错误,请稍后重试', 'error');
145
- } finally {
146
- loginButton.disabled = false;
147
- loginButton.textContent = '登录';
148
- }
149
- });
150
-
151
- function showToast(message, type = 'error') {
152
- const toast = document.createElement('div');
153
- const bgColors = {
154
- success: 'bg-green-600',
155
- error: 'bg-destructive',
156
- info: 'bg-primary'
157
- };
158
- toast.className = `fixed bottom-4 right-4 ${bgColors[type] || bgColors.error} text-white px-4 py-2.5 rounded-lg shadow-lg text-sm font-medium z-50 animate-slide-up`;
159
- toast.textContent = message;
160
- document.body.appendChild(toast);
161
- setTimeout(() => {
162
- toast.style.opacity = '0';
163
- toast.style.transition = 'opacity 0.3s';
164
- setTimeout(() => toast.parentNode && document.body.removeChild(toast), 300);
165
- }, 2000);
166
- }
167
-
168
- window.addEventListener('DOMContentLoaded', () => {
169
- const token = localStorage.getItem('adminToken');
170
- if (token) {
171
- fetch('/api/stats', {
172
- headers: { 'Authorization': `Bearer ${token}` }
173
- }).then(response => {
174
- if (response.ok) window.location.href = '/manage';
175
- });
176
- }
177
- });
178
  </script>
179
  </body>
180
  </html>
 
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
- x_statsig_id = "ZTpUeXBlRXJyb3I6IENhbm5vdCByZWFkIHByb3BlcnRpZXMgb2YgdW5kZWZpbmVkIChyZWFkaW5nICdjaGlsZE5vZGVzJyk="
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- "ssoNormal": {},
3
- "ssoSuper": {}
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
- logger.debug("[Web2API] 应用启动成功")
18
- yield
19
- logger.info("[Web2API] 应用关闭成功")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
 
21
 
22
  # 初始化日志
23
- logger.info("[Web2API] 应用正在启动...")
24
 
25
  # 创建FastAPI应用
26
  app = FastAPI(
27
- title="Web2API",
28
- description="Web服务API",
29
- version="1.0.0",
30
  lifespan=lifespan
31
  )
32
 
@@ -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
- # 挂载静态文件(注意:这个应该在API路由之后,避免拦截API请求)
43
  app.mount("/static", StaticFiles(directory="app/template"), name="template")
44
 
45
  @app.get("/")
46
  async def root():
47
  """根路径"""
48
- return {"message": "Welcome to Web2API"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
 
50
 
51
  if __name__ == "__main__":
 
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.118.2
3
  uvicorn==0.37.0
4
  python-dotenv==1.1.1
5
  curl_cffi==0.13.0
6
  requests==2.32.5
7
  starlette==0.48.0
8
- pydantic==2.12.0
9
- aiofiles==24.1.0
 
 
 
 
 
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