feat: support the mysql
This commit is contained in:
1 parent
7c13fda11c
commit
f612156e8e
10 files changed
+516
-47
No files matched your search
+33
-20
@@ -439,28 +439,13 @@ class StreamTimeoutSettings(BaseModel):
|
||||
async def update_settings(request: UpdateSettingsRequest, _: bool = Depends(verify_admin_session)) -> Dict[str, Any]:
|
||||
"""更新全局配置"""
|
||||
try:
|
||||
import toml
|
||||
import aiofiles
|
||||
logger.debug("[Admin] 更新全局配置")
|
||||
|
||||
# 异步读取现有配置
|
||||
async with aiofiles.open(setting.config_path, "r", encoding="utf-8") as f:
|
||||
content = await f.read()
|
||||
config = toml.loads(content)
|
||||
|
||||
# 更新配置
|
||||
if request.global_config:
|
||||
config["global"].update(request.global_config)
|
||||
if request.grok_config:
|
||||
config["grok"].update(request.grok_config)
|
||||
|
||||
# 异步写回配置文件
|
||||
async with aiofiles.open(setting.config_path, "w", encoding="utf-8") as f:
|
||||
await f.write(toml.dumps(config))
|
||||
|
||||
# 重新加载配置
|
||||
setting.global_config = setting.load("global")
|
||||
setting.grok_config = setting.load("grok")
|
||||
# 使用ConfigManager的save方法(支持存储抽象层)
|
||||
await setting.save(
|
||||
global_config=request.global_config,
|
||||
grok_config=request.grok_config
|
||||
)
|
||||
|
||||
logger.debug("[Admin] 配置更新成功")
|
||||
return {"success": True, "message": "配置更新成功"}
|
||||
@@ -706,3 +691,31 @@ async def get_stats(_: bool = Depends(verify_admin_session)) -> Dict[str, Any]:
|
||||
status_code=500,
|
||||
detail={"error": f"获取统计信息失败: {str(e)}", "code": "STATS_ERROR"}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/storage/mode")
|
||||
async def get_storage_mode(_: bool = Depends(verify_admin_session)) -> Dict[str, Any]:
|
||||
"""
|
||||
获取当前存储模式
|
||||
|
||||
返回当前的存储模式(file/mysql)。
|
||||
"""
|
||||
try:
|
||||
logger.debug("[Admin] 获取存储模式")
|
||||
|
||||
import os
|
||||
storage_mode = os.getenv("STORAGE_MODE", "file").upper()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": {
|
||||
"mode": storage_mode
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Admin] 获取存储模式异常 - 错误: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail={"error": f"获取存储模式失败: {str(e)}", "code": "STORAGE_MODE_ERROR"}
|
||||
)
|
||||
@@ -15,6 +15,11 @@ class ConfigManager:
|
||||
self.config_path: Path = Path(__file__).parents[2] / "data" / "setting.toml"
|
||||
self.global_config: Dict[str, Any] = self.load("global")
|
||||
self.grok_config: Dict[str, Any] = self.load("grok")
|
||||
self._storage = None
|
||||
|
||||
def set_storage(self, storage) -> None:
|
||||
"""设置存储实例"""
|
||||
self._storage = storage
|
||||
|
||||
def load(self, section: str) -> Dict[str, Any]:
|
||||
"""配置加载器"""
|
||||
@@ -23,6 +28,41 @@ class ConfigManager:
|
||||
return toml.load(f)[section]
|
||||
except Exception as e:
|
||||
raise Exception(f"[Setting] 配置加载失败: {e}")
|
||||
|
||||
async def reload(self) -> None:
|
||||
"""重新加载配置(用于从存储同步后)"""
|
||||
self.global_config = self.load("global")
|
||||
self.grok_config = self.load("grok")
|
||||
|
||||
async def save(self, global_config: Dict[str, Any] = None, grok_config: Dict[str, Any] = None) -> None:
|
||||
"""保存配置到存储"""
|
||||
if not self._storage:
|
||||
# 如果没有设置存储,使用传统文件保存方式(向后兼容)
|
||||
import aiofiles
|
||||
async with aiofiles.open(self.config_path, "r", encoding="utf-8") as f:
|
||||
content = await f.read()
|
||||
config = toml.loads(content)
|
||||
|
||||
if global_config:
|
||||
config["global"].update(global_config)
|
||||
if grok_config:
|
||||
config["grok"].update(grok_config)
|
||||
|
||||
async with aiofiles.open(self.config_path, "w", encoding="utf-8") as f:
|
||||
await f.write(toml.dumps(config))
|
||||
else:
|
||||
# 使用存储抽象层
|
||||
config_data = await self._storage.load_config()
|
||||
|
||||
if global_config:
|
||||
config_data["global"].update(global_config)
|
||||
if grok_config:
|
||||
config_data["grok"].update(grok_config)
|
||||
|
||||
await self._storage.save_config(config_data)
|
||||
|
||||
# 重新加载配置
|
||||
await self.reload()
|
||||
|
||||
# 全局设置
|
||||
setting = ConfigManager()
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
"""存储抽象层 - 支持文件和MySQL两种存储模式"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import toml
|
||||
import asyncio
|
||||
import aiofiles
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Optional, Literal
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from app.core.logger import logger
|
||||
|
||||
|
||||
StorageMode = Literal["file", "mysql"]
|
||||
|
||||
|
||||
class BaseStorage(ABC):
|
||||
"""存储基类"""
|
||||
|
||||
@abstractmethod
|
||||
async def init_db(self) -> None:
|
||||
"""初始化数据库"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def load_tokens(self) -> Dict[str, Any]:
|
||||
"""加载token数据"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def save_tokens(self, data: Dict[str, Any]) -> None:
|
||||
"""保存token数据"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def load_config(self) -> Dict[str, Any]:
|
||||
"""加载配置数据"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def save_config(self, data: Dict[str, Any]) -> None:
|
||||
"""保存配置数据"""
|
||||
pass
|
||||
|
||||
|
||||
class FileStorage(BaseStorage):
|
||||
"""文件存储实现"""
|
||||
|
||||
def __init__(self, data_dir: Path):
|
||||
self.data_dir = data_dir
|
||||
self.token_file = data_dir / "token.json"
|
||||
self.config_file = data_dir / "setting.toml"
|
||||
self._token_lock = asyncio.Lock()
|
||||
self._config_lock = asyncio.Lock()
|
||||
|
||||
async def init_db(self) -> None:
|
||||
"""初始化文件存储"""
|
||||
self.data_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 初始化token文件
|
||||
if not self.token_file.exists():
|
||||
default_tokens = {"sso": {}, "ssoSuper": {}}
|
||||
async with aiofiles.open(self.token_file, "w", encoding="utf-8") as f:
|
||||
await f.write(json.dumps(default_tokens, indent=2, ensure_ascii=False))
|
||||
logger.info("[Storage] 创建新的token文件")
|
||||
|
||||
# 初始化配置文件
|
||||
if not self.config_file.exists():
|
||||
default_config = {
|
||||
"global": {
|
||||
"api_keys": [],
|
||||
"admin_username": "admin",
|
||||
"admin_password": "admin"
|
||||
},
|
||||
"grok": {
|
||||
"proxy_url": "",
|
||||
"cf_clearance": "",
|
||||
"x_statsig_id": ""
|
||||
}
|
||||
}
|
||||
async with aiofiles.open(self.config_file, "w", encoding="utf-8") as f:
|
||||
await f.write(toml.dumps(default_config))
|
||||
logger.info("[Storage] 创建新的配置文件")
|
||||
|
||||
async def load_tokens(self) -> Dict[str, Any]:
|
||||
"""从文件加载token数据"""
|
||||
try:
|
||||
async with self._token_lock:
|
||||
if not self.token_file.exists():
|
||||
return {"sso": {}, "ssoSuper": {}}
|
||||
|
||||
async with aiofiles.open(self.token_file, "r", encoding="utf-8") as f:
|
||||
content = await f.read()
|
||||
return json.loads(content)
|
||||
except Exception as e:
|
||||
logger.error(f"[Storage] 加载token失败: {e}")
|
||||
return {"sso": {}, "ssoSuper": {}}
|
||||
|
||||
async def save_tokens(self, data: Dict[str, Any]) -> None:
|
||||
"""保存token数据到文件"""
|
||||
try:
|
||||
async with self._token_lock:
|
||||
async with aiofiles.open(self.token_file, "w", encoding="utf-8") as f:
|
||||
await f.write(json.dumps(data, indent=2, ensure_ascii=False))
|
||||
except Exception as e:
|
||||
logger.error(f"[Storage] 保存token失败: {e}")
|
||||
raise
|
||||
|
||||
async def load_config(self) -> Dict[str, Any]:
|
||||
"""从文件加载配置数据"""
|
||||
try:
|
||||
async with self._config_lock:
|
||||
if not self.config_file.exists():
|
||||
return {"global": {}, "grok": {}}
|
||||
|
||||
async with aiofiles.open(self.config_file, "r", encoding="utf-8") as f:
|
||||
content = await f.read()
|
||||
return toml.loads(content)
|
||||
except Exception as e:
|
||||
logger.error(f"[Storage] 加载配置失败: {e}")
|
||||
return {"global": {}, "grok": {}}
|
||||
|
||||
async def save_config(self, data: Dict[str, Any]) -> None:
|
||||
"""保存配置数据到文件"""
|
||||
try:
|
||||
async with self._config_lock:
|
||||
async with aiofiles.open(self.config_file, "w", encoding="utf-8") as f:
|
||||
await f.write(toml.dumps(data))
|
||||
except Exception as e:
|
||||
logger.error(f"[Storage] 保存配置失败: {e}")
|
||||
raise
|
||||
|
||||
|
||||
class MysqlStorage(BaseStorage):
|
||||
"""MySQL存储实现"""
|
||||
|
||||
def __init__(self, database_url: str, data_dir: Path):
|
||||
self.database_url = database_url
|
||||
self.data_dir = data_dir
|
||||
self.token_file = data_dir / "token.json"
|
||||
self.config_file = data_dir / "setting.toml"
|
||||
self._pool = None
|
||||
self._file_storage = FileStorage(data_dir)
|
||||
|
||||
async def init_db(self) -> None:
|
||||
"""初始化MySQL数据库和连接池"""
|
||||
try:
|
||||
import aiomysql
|
||||
from urllib.parse import urlparse, unquote
|
||||
|
||||
# 使用标准库解析URL
|
||||
parsed = urlparse(self.database_url)
|
||||
|
||||
# 解码用户名和密码(处理URL编码)
|
||||
user = unquote(parsed.username) if parsed.username else ""
|
||||
password = unquote(parsed.password) if parsed.password else ""
|
||||
host = parsed.hostname
|
||||
port = parsed.port if parsed.port else 3306
|
||||
database = parsed.path[1:] if parsed.path else "grok2api" # 去掉开头的 '/'
|
||||
|
||||
logger.info(f"[Storage] 解析数据库连接: {user}@{host}:{port}/{database}")
|
||||
|
||||
# 创建连接池
|
||||
self._pool = await aiomysql.create_pool(
|
||||
host=host,
|
||||
port=port,
|
||||
user=user,
|
||||
password=password,
|
||||
db=database,
|
||||
charset="utf8mb4",
|
||||
autocommit=True,
|
||||
maxsize=10
|
||||
)
|
||||
|
||||
logger.info(f"[Storage] MySQL连接池创建成功: {host}:{port}/{database}")
|
||||
|
||||
# 创建表
|
||||
async with self._pool.acquire() as conn:
|
||||
async with conn.cursor() as cursor:
|
||||
# 创建tokens表
|
||||
await cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS grok_tokens (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
data JSON NOT NULL,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
""")
|
||||
|
||||
# 创建settings表
|
||||
await cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS grok_settings (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
data JSON NOT NULL,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
""")
|
||||
|
||||
logger.info("[Storage] MySQL表创建/验证成功")
|
||||
|
||||
# 确保文件存储目录存在
|
||||
await self._file_storage.init_db()
|
||||
|
||||
# 从数据库同步数据到文件
|
||||
await self._sync_from_db()
|
||||
|
||||
except ImportError:
|
||||
logger.error("[Storage] aiomysql未安装,请运行: pip install aiomysql")
|
||||
raise Exception("aiomysql未安装")
|
||||
except Exception as e:
|
||||
logger.error(f"[Storage] MySQL初始化失败: {e}")
|
||||
raise
|
||||
|
||||
async def _sync_from_db(self) -> None:
|
||||
"""从数据库同步数据到文件"""
|
||||
try:
|
||||
# 同步tokens
|
||||
tokens = await self._load_from_db("grok_tokens")
|
||||
if tokens:
|
||||
await self._file_storage.save_tokens(tokens)
|
||||
logger.info("[Storage] Token数据已从数据库同步到文件")
|
||||
|
||||
# 同步settings
|
||||
settings = await self._load_from_db("grok_settings")
|
||||
if settings:
|
||||
await self._file_storage.save_config(settings)
|
||||
logger.info("[Storage] 配置数据已从数据库同步到文件")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"[Storage] 从数据库同步数据失败: {e}")
|
||||
|
||||
async def _load_from_db(self, table: str) -> Optional[Dict[str, Any]]:
|
||||
"""从数据库表加载最新数据"""
|
||||
try:
|
||||
async with self._pool.acquire() as conn:
|
||||
async with conn.cursor() as cursor:
|
||||
await cursor.execute(f"SELECT data FROM {table} ORDER BY id DESC LIMIT 1")
|
||||
result = await cursor.fetchone()
|
||||
if result:
|
||||
return json.loads(result[0])
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"[Storage] 从数据库加载{table}失败: {e}")
|
||||
return None
|
||||
|
||||
async def _save_to_db(self, table: str, data: Dict[str, Any]) -> None:
|
||||
"""保存数据到数据库表"""
|
||||
try:
|
||||
async with self._pool.acquire() as conn:
|
||||
async with conn.cursor() as cursor:
|
||||
json_data = json.dumps(data, ensure_ascii=False)
|
||||
|
||||
# 检查是否已有数据
|
||||
await cursor.execute(f"SELECT id FROM {table} ORDER BY id DESC LIMIT 1")
|
||||
result = await cursor.fetchone()
|
||||
|
||||
if result:
|
||||
# 更新现有数据
|
||||
await cursor.execute(
|
||||
f"UPDATE {table} SET data = %s WHERE id = %s",
|
||||
(json_data, result[0])
|
||||
)
|
||||
else:
|
||||
# 插入新数据
|
||||
await cursor.execute(
|
||||
f"INSERT INTO {table} (data) VALUES (%s)",
|
||||
(json_data,)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[Storage] 保存数据到{table}失败: {e}")
|
||||
raise
|
||||
|
||||
async def load_tokens(self) -> Dict[str, Any]:
|
||||
"""从文件加载token数据(已从数据库同步)"""
|
||||
return await self._file_storage.load_tokens()
|
||||
|
||||
async def save_tokens(self, data: Dict[str, Any]) -> None:
|
||||
"""保存token数据到文件和数据库"""
|
||||
# 先保存到文件
|
||||
await self._file_storage.save_tokens(data)
|
||||
# 再保存到数据库
|
||||
await self._save_to_db("grok_tokens", data)
|
||||
|
||||
async def load_config(self) -> Dict[str, Any]:
|
||||
"""从文件加载配置数据(已从数据库同步)"""
|
||||
return await self._file_storage.load_config()
|
||||
|
||||
async def save_config(self, data: Dict[str, Any]) -> None:
|
||||
"""保存配置数据到文件和数据库"""
|
||||
# 先保存到文件
|
||||
await self._file_storage.save_config(data)
|
||||
# 再保存到数据库
|
||||
await self._save_to_db("grok_settings", data)
|
||||
|
||||
async def close(self) -> None:
|
||||
"""关闭数据库连接池"""
|
||||
if self._pool:
|
||||
self._pool.close()
|
||||
await self._pool.wait_closed()
|
||||
logger.info("[Storage] MySQL连接池已关闭")
|
||||
|
||||
|
||||
class StorageManager:
|
||||
"""存储管理器 - 单例模式"""
|
||||
|
||||
_instance: Optional['StorageManager'] = None
|
||||
_storage: Optional[BaseStorage] = None
|
||||
_initialized: bool = False
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
return cls._instance
|
||||
|
||||
async def init(self) -> None:
|
||||
"""初始化存储管理器"""
|
||||
if self._initialized:
|
||||
return
|
||||
|
||||
# 读取环境变量
|
||||
storage_mode = os.getenv("STORAGE_MODE", "file").lower()
|
||||
database_url = os.getenv("DATABASE_URL", "")
|
||||
|
||||
data_dir = Path(__file__).parents[2] / "data"
|
||||
|
||||
if storage_mode == "mysql":
|
||||
if not database_url:
|
||||
logger.error("[Storage] MySQL模式需要DATABASE_URL环境变量")
|
||||
raise ValueError("MySQL模式需要DATABASE_URL环境变量")
|
||||
|
||||
logger.info(f"[Storage] 使用MySQL存储模式")
|
||||
self._storage = MysqlStorage(database_url, data_dir)
|
||||
else:
|
||||
logger.info(f"[Storage] 使用文件存储模式")
|
||||
self._storage = FileStorage(data_dir)
|
||||
|
||||
await self._storage.init_db()
|
||||
self._initialized = True
|
||||
logger.info("[Storage] 存储管理器初始化完成")
|
||||
|
||||
def get_storage(self) -> BaseStorage:
|
||||
"""获取存储实例"""
|
||||
if not self._initialized or not self._storage:
|
||||
raise RuntimeError("StorageManager未初始化,请先调用init()")
|
||||
return self._storage
|
||||
|
||||
async def close(self) -> None:
|
||||
"""关闭存储"""
|
||||
if self._storage and isinstance(self._storage, MysqlStorage):
|
||||
await self._storage.close()
|
||||
|
||||
|
||||
# 全局存储管理器实例
|
||||
storage_manager = StorageManager()
|
||||
|
||||
@@ -51,6 +51,7 @@ class GrokTokenManager:
|
||||
self.token_file = Path(__file__).parents[3] / "data" / "token.json"
|
||||
self._file_lock = asyncio.Lock()
|
||||
self.token_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._storage = None
|
||||
|
||||
# 同步加载初始数据
|
||||
self._load_data()
|
||||
@@ -58,6 +59,10 @@ class GrokTokenManager:
|
||||
|
||||
logger.debug(f"[Token] 管理器初始化完成,文件: {self.token_file}")
|
||||
|
||||
def set_storage(self, storage) -> None:
|
||||
"""设置存储实例"""
|
||||
self._storage = storage
|
||||
|
||||
def _load_data(self) -> None:
|
||||
"""同步加载Token数据(仅用于初始化)"""
|
||||
default_data = {
|
||||
@@ -77,11 +82,16 @@ class GrokTokenManager:
|
||||
self.token_data = default_data
|
||||
|
||||
async def _save_data(self) -> None:
|
||||
"""异步保存Token数据到文件"""
|
||||
"""异步保存Token数据到存储"""
|
||||
try:
|
||||
async with self._file_lock:
|
||||
async with aiofiles.open(self.token_file, "w", encoding="utf-8") as f:
|
||||
await f.write(json.dumps(self.token_data, indent=2, ensure_ascii=False))
|
||||
if not self._storage:
|
||||
# 如果没有设置存储,使用传统文件保存方式(向后兼容)
|
||||
async with self._file_lock:
|
||||
async with aiofiles.open(self.token_file, "w", encoding="utf-8") as f:
|
||||
await f.write(json.dumps(self.token_data, indent=2, ensure_ascii=False))
|
||||
else:
|
||||
# 使用存储抽象层
|
||||
await self._storage.save_tokens(self.token_data)
|
||||
except IOError as e:
|
||||
logger.error(f"[Token] 保存Token数据失败: {str(e)}")
|
||||
raise GrokApiException(
|
||||
|
||||
+29
-3
@@ -10,6 +10,16 @@
|
||||
@keyframes slide-up{from{transform:translateY(100%);opacity:0}to{transform:translateY(0);opacity:1}}
|
||||
.animate-slide-up{animation:slide-up .3s ease-out}
|
||||
.tab-btn{transition:all .2s ease}
|
||||
.hover-card{position:relative;display:inline-block}
|
||||
.hover-card-trigger{cursor:pointer}
|
||||
.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%)}
|
||||
.hover-card:hover .hover-card-content{opacity:1;visibility:visible}
|
||||
.hover-card-content.top{bottom:100%;transform:translateX(-50%) translateY(-8px)}
|
||||
.hover-card-content.bottom{top:100%;transform:translateX(-50%) translateY(8px)}
|
||||
.hover-card-content::after{content:'';position:absolute;left:50%;transform:translateX(-50%);border:6px solid transparent;z-index:1000}
|
||||
.hover-card-content.top::after{top:100%;border-top-color:hsl(0 0% 3.9%)}
|
||||
.hover-card-content.bottom::after{bottom:100%;border-bottom-color:hsl(0 0% 3.9%)}
|
||||
.hover-card-trigger:hover+.hover-card-content{opacity:1;visibility:visible}
|
||||
[title]{position:relative}
|
||||
[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:4px 8px;border-radius:4px;font-size:11px;white-space:nowrap;z-index:1000;pointer-events:none;box-shadow:0 2px 8px rgba(0,0,0,.15)}
|
||||
[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,8 +36,22 @@
|
||||
<span class="font-bold text-xl">Grok2API</span>
|
||||
<span class="text-xs text-gray-400">by @Chenyme</span>
|
||||
</div>
|
||||
<div class="flex flex-1 items-center justify-between space-x-2 md:justify-end">
|
||||
<nav class="flex items-center gap-1">
|
||||
<div class="flex flex-1 items-center justify-end">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="hover-card">
|
||||
<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">
|
||||
<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">
|
||||
<ellipse cx="12" cy="5" rx="9" ry="3"/>
|
||||
<path d="m21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/>
|
||||
<path d="m3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/>
|
||||
</svg>
|
||||
<span id="storageModeText">FILE</span>
|
||||
</span>
|
||||
<div id="storageModeTooltip" class="hover-card-content top">
|
||||
加载中...
|
||||
</div>
|
||||
</div>
|
||||
<nav class="flex items-center gap-1">
|
||||
<a
|
||||
href="https://github.com/chenyme/grok2api/issues"
|
||||
target="_blank"
|
||||
@@ -498,7 +522,9 @@
|
||||
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')}},
|
||||
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')}},
|
||||
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')}};
|
||||
window.addEventListener('DOMContentLoaded',()=>{checkAuth();refreshTokens();setInterval(()=>{loadStats();updateRemaining()},30000)});
|
||||
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)},
|
||||
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{$('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'))}};
|
||||
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'})}});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+14
-15
@@ -1,20 +1,19 @@
|
||||
[global]
|
||||
admin_username = "admin"
|
||||
admin_password = "admin"
|
||||
log_level = "INFO"
|
||||
image_mode = "url"
|
||||
image_cache_max_size_mb = 500
|
||||
video_cache_max_size_mb = 1000
|
||||
base_url = ""
|
||||
|
||||
|
||||
[grok]
|
||||
api_key = ""
|
||||
proxy_url = ""
|
||||
stream_chunk_timeout = 120
|
||||
stream_first_response_timeout = 30
|
||||
stream_total_timeout = 600
|
||||
cf_clearance = ""
|
||||
temporary = true
|
||||
cf_clearance = ""
|
||||
x_statsig_id = "ZTpUeXBlRXJyb3I6IENhbm5vdCByZWFkIHByb3BlcnRpZXMgb2YgdW5kZWZpbmVkIChyZWFkaW5nICdjaGlsZE5vZGVzJyk="
|
||||
filtered_tags = "xaiartifact,xai:tool_usage_card,grok:render"
|
||||
x_statsig_id = "ZTpUeXBlRXJyb3I6IENhbm5vdCByZWFkIHByb3BlcnRpZXMgb2YgdW5kZWZpbmVkIChyZWFkaW5nICdjaGlsZE5vZGVzJyk="
|
||||
stream_chunk_timeout = 120
|
||||
stream_total_timeout = 600
|
||||
stream_first_response_timeout = 30
|
||||
|
||||
[global]
|
||||
base_url = ""
|
||||
log_level = "DEBUG"
|
||||
image_mode = "url"
|
||||
admin_password = "admin"
|
||||
admin_username = "admin"
|
||||
image_cache_max_size_mb = 500
|
||||
video_cache_max_size_mb = 1000
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"ssoNormal": {},
|
||||
"ssoSuper": {}
|
||||
"ssoSuper": {},
|
||||
"ssoNormal": {}
|
||||
}
|
||||
+6
-1
@@ -6,6 +6,11 @@ services:
|
||||
volumes:
|
||||
- grok_data:/app/data
|
||||
- ./logs:/app/logs
|
||||
environment:
|
||||
# 数据存储模式( file/mysql 二选一 )
|
||||
- STORAGE_MODE=file
|
||||
# MySQL数据库 URL (仅在STORAGE_MODE=mysql时需要 示例:mysql://user:password@host:port/database)
|
||||
# - DATABASE_URL=mysql://
|
||||
|
||||
volumes:
|
||||
grok_data:
|
||||
grok_data:
|
||||
@@ -5,6 +5,9 @@ from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from app.core.logger import logger
|
||||
from app.core.exception import register_exception_handlers
|
||||
from app.core.storage import storage_manager
|
||||
from app.core.config import setting
|
||||
from app.services.grok.token import token_manager
|
||||
from app.api.v1.chat import router as chat_router
|
||||
from app.api.v1.models import router as models_router
|
||||
from app.api.v1.images import router as images_router
|
||||
@@ -14,8 +17,23 @@ from app.api.admin.manage import router as admin_router
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""应用生命周期管理"""
|
||||
logger.debug("[Grok2API] 应用启动成功")
|
||||
# 初始化存储管理器
|
||||
await storage_manager.init()
|
||||
|
||||
# 设置存储到配置和token管理器
|
||||
storage = storage_manager.get_storage()
|
||||
setting.set_storage(storage)
|
||||
token_manager.set_storage(storage)
|
||||
|
||||
# 重新加载配置和token数据(从存储同步)
|
||||
await setting.reload()
|
||||
token_manager._load_data()
|
||||
|
||||
logger.info("[Grok2API] 应用启动成功")
|
||||
yield
|
||||
|
||||
# 关闭存储连接
|
||||
await storage_manager.close()
|
||||
logger.info("[Grok2API] 应用关闭成功")
|
||||
|
||||
|
||||
|
||||
+2
-1
@@ -6,4 +6,5 @@ curl_cffi==0.13.0
|
||||
requests==2.32.5
|
||||
starlette==0.48.0
|
||||
pydantic==2.12.0
|
||||
aiofiles==24.1.0
|
||||
aiofiles==24.1.0
|
||||
aiomysql==0.2.0
|
||||
Reference in new issue
Block a user