Merge pull request #12 from chenyme/base64

feat: support use base64
This commit is contained in:
Chenyme authored and GitHub committed 2025-10-15 01:43:20 +08:00
commit fc0c18f205
7 files changed
+249 -388

No files matched your search

+60 -100
View File
@@ -1,6 +1,7 @@
"""缓存服务模块"""
import asyncio
import base64
from pathlib import Path
from typing import Optional
from curl_cffi.requests import AsyncSession
@@ -19,42 +20,20 @@ class CacheService:
self.cache_dir = Path(f"data/temp/{cache_type}")
self.cache_dir.mkdir(parents=True, exist_ok=True)
@staticmethod
def _get_cache_filename(file_path: str) -> str:
"""将文件路径转换为缓存文件名"""
filename = file_path.lstrip('/').replace('/', '-')
return filename
def _get_cache_path(self, file_path: str) -> Path:
def _cache_path(self, file_path: str) -> Path:
"""获取缓存文件的完整路径"""
filename = self._get_cache_filename(file_path)
filename = file_path.lstrip('/').replace('/', '-')
return self.cache_dir / filename
async def download_file(self, file_path: str, auth_token: str, timeout: float = 30.0) -> Optional[Path]:
"""下载并缓存文件
Args:
file_path: 文件路径,如 /users/xxx/generated/xxx/file.jpg
auth_token: 认证令牌
timeout: 下载超时时间(秒)
Returns:
缓存文件路径,下载失败返回 None
"""
cache_path = self._get_cache_path(file_path)
"""下载并缓存文件"""
cache_path = self._cache_path(file_path)
if cache_path.exists():
logger.debug(f"[{self.cache_type.upper()}Cache] 文件已缓存: {cache_path}")
return cache_path
file_url = f"https://assets.grok.com{file_path}"
try:
# 构建 Cookie
cf_clearance = setting.grok_config.get("cf_clearance", "")
cookie = f"{auth_token};{cf_clearance}" if cf_clearance else auth_token
# 构建请求头
headers = {
**get_dynamic_headers(pathname=file_path),
"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,16 @@ class CacheService:
"Sec-Fetch-User": "?1",
"Upgrade-Insecure-Requests": "1",
"Referer": "https://grok.com/",
"Cookie": cookie
"Cookie": f"{auth_token};{cf_clearance}" if cf_clearance else auth_token
}
# 代理配置
proxy_url = setting.grok_config.get("proxy_url")
proxies = {"http": proxy_url, "https": proxy_url} if proxy_url else {}
async with AsyncSession() as session:
logger.debug(f"[{self.cache_type.upper()}Cache] 开始下载: {file_url}")
logger.debug(f"[{self.cache_type.upper()}Cache] 开始下载: https://assets.grok.com{file_path}")
response = await session.get(
file_url,
f"https://assets.grok.com{file_path}",
headers=headers,
proxies=proxies,
timeout=timeout,
@@ -82,70 +60,44 @@ class CacheService:
impersonate="chrome133a"
)
response.raise_for_status()
cache_path.write_bytes(response.content)
logger.debug(f"[{self.cache_type.upper()}Cache] 文件已缓存: {cache_path} ({len(response.content)} bytes)")
asyncio.create_task(self.cleanup_cache())
return cache_path
except Exception as e:
logger.error(f"[{self.cache_type.upper()}Cache] 下载文件失败: {e}")
return None
def get_cached_file(self, file_path: str) -> Optional[Path]:
"""获取缓存的文件路径
Args:
file_path: 文件路径
Returns:
缓存文件路径,不存在返回 None
"""
cache_path = self._get_cache_path(file_path)
def get_cached(self, file_path: str) -> Optional[Path]:
"""获取缓存的文件路径"""
cache_path = self._cache_path(file_path)
return cache_path if cache_path.exists() else None
async def cleanup_cache(self):
"""清理缓存目录,确保不超过配置的大小限制"""
try:
# 获取配置的最大缓存大小
config_key = f"{self.cache_type}_cache_max_size_mb"
max_size_mb = setting.global_config.get(config_key, 500)
max_size_mb = setting.global_config.get(f"{self.cache_type}_cache_max_size_mb", 500)
max_size_bytes = max_size_mb * 1024 * 1024
# 获取缓存大小和修改时间
files = []
total_size = 0
files = [(fp, (stat := fp.stat()).st_size, stat.st_mtime)
for fp in self.cache_dir.glob("*") if fp.is_file()]
total_size = sum(size for _, size, _ in files)
for file_path in self.cache_dir.glob("*"):
if file_path.is_file():
size = file_path.stat().st_size
mtime = file_path.stat().st_mtime
files.append((file_path, size, mtime))
total_size += size
# 如果总大小未超限,无需清理
if total_size <= max_size_bytes:
logger.debug(f"[{self.cache_type.upper()}Cache] 缓存大小 {total_size / 1024 / 1024:.2f}MB,未超限")
return
logger.info(f"[{self.cache_type.upper()}Cache] 缓存大小 {total_size / 1024 / 1024:.2f}MB 超过限制 {max_size_mb}MB,开始清理")
# 按修改时间排序
files.sort(key=lambda x: x[2])
# 删除文件直到总大小低于限制
for file_path, size, _ in files:
if total_size <= max_size_bytes:
break
file_path.unlink()
total_size -= size
logger.debug(f"[{self.cache_type.upper()}Cache] 已删除缓存文件: {file_path}")
logger.info(f"[{self.cache_type.upper()}Cache] 缓存清理完成,当前大小 {total_size / 1024 / 1024:.2f}MB")
except Exception as e:
logger.error(f"[{self.cache_type.upper()}Cache] 清理缓存失败: {e}")
@@ -157,27 +109,51 @@ class ImageCacheService(CacheService):
super().__init__("image")
async def download_image(self, image_path: str, auth_token: str) -> Optional[Path]:
"""下载并缓存图片
Args:
image_path: 图片路径,如 /users/xxx/generated/xxx/image.jpg
auth_token: 认证令牌
Returns:
缓存文件路径,下载失败返回 None`
"""
"""下载并缓存图片"""
return await self.download_file(image_path, auth_token, timeout=30.0)
def get_cached_image(self, image_path: str) -> Optional[Path]:
"""获取缓存的图片路径
def get_cached(self, image_path: str) -> Optional[Path]:
"""获取缓存的图片路径"""
return super().get_cached(image_path)
Args:
image_path: 图片路径
@staticmethod
def to_base64(image_path: Path) -> Optional[str]:
"""将图片转换为 base64 编码"""
try:
if not image_path.exists():
logger.error(f"[ImageCache] 图片文件不存在: {image_path}")
return None
Returns:
缓存文件路径,不存在返回 None
"""
return self.get_cached_file(image_path)
with open(image_path, "rb") as f:
base64_data = base64.b64encode(f.read()).decode('utf-8')
mime_type = {'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png',
'.gif': 'image/gif', '.webp': 'image/webp'}.get(image_path.suffix.lower(), 'image/jpeg')
return f"data:{mime_type};base64,{base64_data}"
except Exception as e:
logger.error(f"[ImageCache] 图片转 base64 失败: {e}")
return None
async def download_base64(self, image_path: str, auth_token: str) -> Optional[str]:
"""下载图片并转换为 base64 编码(转换后立即删除缓存文件)"""
try:
cache_path = await self.download_file(image_path, auth_token, timeout=30.0)
if not cache_path:
return None
base64_str = self.to_base64(cache_path)
try:
cache_path.unlink()
logger.debug(f"[ImageCache] 已删除临时文件: {cache_path}")
except Exception as e:
logger.warning(f"[ImageCache] 删除临时文件失败: {e}")
return base64_str
except Exception as e:
logger.error(f"[ImageCache] 下载并转换 base64 失败: {e}")
return None
class VideoCacheService(CacheService):
@@ -187,30 +163,14 @@ class VideoCacheService(CacheService):
super().__init__("video")
async def download_video(self, video_path: str, auth_token: str) -> Optional[Path]:
"""下载并缓存视频
Args:
video_path: 视频路径,如 /users/xxx/generated/xxx/video.mp4
auth_token: 认证令牌
Returns:
缓存文件路径,下载失败返回 None
"""
"""下载并缓存视频"""
return await self.download_file(video_path, auth_token, timeout=60.0)
def get_cached_video(self, video_path: str) -> Optional[Path]:
"""获取缓存的视频路径
Args:
video_path: 视频路径
Returns:
缓存文件路径,不存在返回 None
"""
return self.get_cached_file(video_path)
def get_cached(self, video_path: str) -> Optional[Path]:
"""获取缓存的视频路径"""
return super().get_cached(video_path)
# 全局实例
image_cache_service = ImageCacheService()
video_cache_service = VideoCacheService()
+76 -17
View File
@@ -157,18 +157,30 @@ class GrokResponseProcessor:
# 提取图片数据
if images := model_response.get("generatedImageUrls"):
# 获取图片返回模式
image_mode = setting.global_config.get("image_mode", "url")
for img in images:
try:
cache_path = await image_cache_service.download_image(f"/{img}", auth_token)
if cache_path:
img_path = img.replace('/', '-')
base_url = setting.global_config.get("base_url", "")
img_url = f"{base_url}/images/{img_path}" if base_url else f"/images/{img_path}"
content += f"\n![Generated Image]({img_url})"
if image_mode == "base64":
# base64 模式:下载并转换为 base64
base64_str = await image_cache_service.download_base64(f"/{img}", auth_token)
if base64_str:
content += f"\n![Generated Image]({base64_str})"
else:
content += f"\n![Generated Image](https://assets.grok.com/{img})"
else:
content += f"\n![Generated Image](https://assets.grok.com/{img})"
# url 模式:缓存并返回链接
cache_path = await image_cache_service.download_image(f"/{img}", auth_token)
if cache_path:
img_path = img.replace('/', '-')
base_url = setting.global_config.get("base_url", "")
img_url = f"{base_url}/images/{img_path}" if base_url else f"/images/{img_path}"
content += f"\n![Generated Image]({img_url})"
else:
content += f"\n![Generated Image](https://assets.grok.com/{img})"
except Exception as e:
logger.warning(f"[Processor] 缓存图片失败: {e}")
logger.warning(f"[Processor] 处理图片失败: {e}")
content += f"\n![Generated Image](https://assets.grok.com/{img})"
# 返回 OpenAI 响应格式
@@ -333,19 +345,66 @@ class GrokResponseProcessor:
# 提取图片数据
if is_image:
if model_resp := grok_resp.get("modelResponse"):
# 获取图片返回模式
image_mode = setting.global_config.get("image_mode", "url")
# 生成图片链接并缓存
content = ""
for img in model_resp.get("generatedImageUrls", []):
try:
# 缓存图片
await image_cache_service.download_image(f"/{img}", auth_token)
# 本地图片路径
img_path = img.replace('/', '-')
base_url = setting.global_config.get("base_url", "")
img_url = f"{base_url}/images/{img_path}" if base_url else f"/images/{img_path}"
content += f"![Generated Image]({img_url})\n"
if image_mode == "base64":
# base64 模式:下载并转换为 base64
base64_str = await image_cache_service.download_base64(f"/{img}", auth_token)
if base64_str:
# 分块发送 base64 数据,每 8KB 一个 chunk
markdown_prefix = "![Generated Image](data:"
markdown_suffix = ")\n"
# 提取 data URL 的 mime 和 base64 部分
if base64_str.startswith("data:"):
parts = base64_str.split(",", 1)
if len(parts) == 2:
mime_part = parts[0] + ","
b64_data = parts[1]
# 发送前缀
yield make_chunk(markdown_prefix + mime_part)
timeout_manager.mark_chunk_received()
chunk_index += 1
# 分块发送 base64 数据
chunk_size = 8192
for i in range(0, len(b64_data), chunk_size):
chunk_data = b64_data[i:i + chunk_size]
yield make_chunk(chunk_data)
timeout_manager.mark_chunk_received()
chunk_index += 1
# 发送后缀
yield make_chunk(markdown_suffix)
timeout_manager.mark_chunk_received()
chunk_index += 1
else:
yield make_chunk(f"![Generated Image]({base64_str})\n")
timeout_manager.mark_chunk_received()
chunk_index += 1
else:
yield make_chunk(f"![Generated Image]({base64_str})\n")
timeout_manager.mark_chunk_received()
chunk_index += 1
else:
yield make_chunk(f"![Generated Image](https://assets.grok.com/{img})\n")
timeout_manager.mark_chunk_received()
chunk_index += 1
else:
# url 模式:缓存并返回链接
await image_cache_service.download_image(f"/{img}", auth_token)
# 本地图片路径
img_path = img.replace('/', '-')
base_url = setting.global_config.get("base_url", "")
img_url = f"{base_url}/images/{img_path}" if base_url else f"/images/{img_path}"
content += f"![Generated Image]({img_url})\n"
except Exception as e:
logger.warning(f"[Processor] 缓存图片失败: {e}")
logger.warning(f"[Processor] 处理图片失败: {e}")
content += f"![Generated Image](https://assets.grok.com/{img})\n"
yield make_chunk(content.strip(), "stop")
timeout_manager.mark_chunk_received()
+80 -123
View File
@@ -4,103 +4,49 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>管理控制台 - Grok2API</title>
<link rel="icon" type="image/png" href="/static/favicon.png">
<script src="https://cdn.tailwindcss.com"></script>
<style>
@keyframes slide-up {
from {
transform: translateY(100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.animate-slide-up {
animation: slide-up 0.3s ease-out;
}
.tab-btn {
transition: all 0.2s ease;
}
[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,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;
}
@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}
[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}
</style>
<script>
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%)",
},
},
}
}
}
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%)"}}}}}
</script>
</head>
<body class="h-full bg-background text-foreground antialiased">
<!-- 导航栏 -->
<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">
<div class="mx-auto flex h-14 max-w-7xl items-center px-6">
<div class="mr-4 flex">
<span class="font-bold text-lg">Grok2API</span>
<div class="mr-4 flex items-baseline gap-3">
<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 space-x-2">
<button
onclick="logout()"
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"
<nav class="flex items-center gap-1">
<a
href="https://github.com/chenyme/grok2api/issues"
target="_blank"
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"
>
<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">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>
反馈
</a>
<button
onclick="logout()"
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"
>
<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">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
<polyline points="16 17 21 12 16 7"/>
<line x1="21" y1="12" x2="9" y2="12"/>
</svg>
退出
</button>
</nav>
@@ -274,6 +220,18 @@
<!-- 全局设置面板 -->
<div id="panelSettings" class="hidden">
<!-- 配置提示 -->
<div class="mb-6 rounded border border-blue-300 bg-blue-50 px-4 py-3">
<div class="text-xs text-gray-800 leading-relaxed">
<div class="text-base font-medium text-gray-900 mb-2.5">部分说明</div>
<div class="space-y-1.5">
<div><span class="font-medium">服务网址:</span>图片/视频链接返回时需要拼接您的服务网址(如 https://yourdomain.com),若您不使用视频功能且图片使用Base64模式则可留空</div>
<div><span class="font-medium">日志报错 403:</span>通常是被 CF 拦截了,可配置代理IP、更换服务器IP或者在服务器中访问 grok.com 通过 CF 验证后 F12 获取 cf_clearance</div>
<div><span class="font-medium">X Statsig ID:</span>反机器人验证参数,勿随意修改,一般 403 是被盾了,并不是需要更新 X Statsig ID</div>
</div>
</div>
</div>
<div class="grid gap-6 lg:grid-cols-2">
<!-- 全局配置 -->
<div class="rounded-lg border border-border bg-background p-6">
@@ -302,6 +260,23 @@
<option>DEBUG</option><option>INFO</option><option>WARNING</option><option>ERROR</option>
</select>
</div>
<div>
<label class="text-sm font-medium flex items-center gap-1 mb-2">
图片模式
<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>
</label>
<select id="cfgImageMode" 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">
<option value="url">URL (图片链接)</option>
<option value="base64">Base64 (base64编码)</option>
</select>
</div>
<div>
<label class="text-sm font-medium flex items-center gap-1 mb-2">
服务网址
<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,如无图片需求可留空。注意:Base64模式下无需填写此项">?</span>
</label>
<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">
</div>
<div>
<label class="text-sm font-medium flex items-center gap-1 mb-2">
图片缓存上限 (MB)
@@ -318,8 +293,8 @@
</div>
<div>
<label class="text-sm font-medium flex items-center gap-1 mb-2">
缓存大小
<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>
项目缓存清理
<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>
</label>
<div class="space-y-2">
<div class="flex gap-2 items-center">
@@ -347,24 +322,16 @@
<span class="text-sm text-muted-foreground w-12">总计:</span>
<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">
<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="清除所有缓存(图片+视频)">
<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">
<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">
<path d="M3 6h18"/>
<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"/>
<line x1="10" y1="11" x2="10" y2="17"/>
<line x1="14" y1="11" x2="14" y2="17"/>
</svg>
清除全部
</button>
</div>
</div>
</div>
<div>
<label class="text-sm font-medium flex items-center gap-1 mb-2">
服务网址
<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>
</label>
<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">
</div>
</div>
<div class="mt-6 flex justify-end">
<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>
@@ -405,14 +372,14 @@
</div>
<div>
<label class="text-sm font-medium flex items-center gap-1 mb-2">
Filtered_tags
Filtered Tags
<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>
</label>
<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">
</div>
<div>
<label class="text-sm font-medium flex items-center gap-1 mb-2">
临时会话
临时会话模式
<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>
</label>
<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">
@@ -420,13 +387,6 @@
<option value="true">开启</option>
</select>
</div>
<div>
<label class="text-sm font-medium flex items-center gap-1 mb-2">
数据块间隔超时 (秒)
<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>
</label>
<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">
</div>
<div>
<label class="text-sm font-medium flex items-center gap-1 mb-2">
首次响应超时 (秒)
@@ -436,7 +396,14 @@
</div>
<div>
<label class="text-sm font-medium flex items-center gap-1 mb-2">
总超时限制 (秒)
流式间隔超时 (秒)
<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>
</label>
<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">
</div>
<div>
<label class="text-sm font-medium flex items-center gap-1 mb-2">
生成总过程超时 (秒)
<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>
</label>
<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">
@@ -507,17 +474,7 @@
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)}},
updateRemaining=()=>{const r=calcRemaining();['Total','Normal','Heavy'].forEach(k=>$(`stat${k}Remaining`).textContent=r[k.toLowerCase()]===0?'-':r[k.toLowerCase()].toLocaleString())}
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()},
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()},
toggleToken=t=>selectedTokens[selectedTokens.has(t)?'delete':'add'](t)||updateBatchActions(),
toggleSelectAll=()=>{const sa=$('selectAll');sa.checked?filteredTokens.forEach(t=>selectedTokens.add(t.token)):selectedTokens.clear();renderTokens()},
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,16 +487,16 @@
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)}},
copyToken=async(t,e)=>{e.stopPropagation();try{await navigator.clipboard.writeText(t);showToast('Token 已复制到剪贴板','success')}catch(err){console.error('复制失败:',err);showToast('复制失败,请手动复制','error')}}
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')},
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)},
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')}
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)}
logout=async()=>{if(!confirm('确定要退出登录吗?'))return;try{await apiRequest('/api/logout',{method:'POST'})}catch(e){console.error('登出失败:',e)}finally{localStorage.removeItem('adminToken');location.href='/login'}},
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()},
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')}},
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||'';$('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')}},
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'}},
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')}},
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')}},
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')}},
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')}},
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')}},
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')}},
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)});
</script>
Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

+13 -139
View File
@@ -4,52 +4,14 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>登录 - Grok2API</title>
<link rel="icon" type="image/png" href="/static/favicon.png">
<script src="https://cdn.tailwindcss.com"></script>
<script>
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%)",
},
},
}
}
}
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%)"}}}}}
</script>
<style>
@keyframes slide-up {
from {
transform: translateY(100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.animate-slide-up {
animation: slide-up 0.3s ease-out;
}
@keyframes slide-up{from{transform:translateY(100%);opacity:0}to{transform:translateY(0);opacity:1}}
.animate-slide-up{animation:slide-up .3s ease-out}
</style>
</head>
<body class="h-full bg-background text-foreground antialiased">
@@ -64,44 +26,15 @@
<div class="sm:mx-auto sm:w-full sm:max-w-md">
<div class="bg-background py-8 px-4 sm:px-10 rounded-lg">
<form id="loginForm" class="space-y-6">
<!-- 账户 -->
<div class="space-y-2">
<label for="username" class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
账户
</label>
<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 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"
placeholder="请输入用户名"
>
<label for="username" class="text-sm font-medium">账户</label>
<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="请输入账户">
</div>
<!-- 密码 -->
<div class="space-y-2">
<label for="password" class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
密码
</label>
<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 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"
placeholder="请输入密码"
>
<label for="password" class="text-sm font-medium">密码</label>
<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="请输入密码">
</div>
<!-- 登录按钮 -->
<button
type="submit"
id="loginButton"
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"
>
登录
</button>
<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>
</form>
<div class="mt-6 text-center text-xs text-muted-foreground">
@@ -112,69 +45,10 @@
</div>
<script>
const loginForm = document.getElementById('loginForm');
const loginButton = document.getElementById('loginButton');
loginForm.addEventListener('submit', async (e) => {
e.preventDefault();
loginButton.disabled = true;
loginButton.textContent = '登录中...';
try {
const formData = new FormData(loginForm);
const response = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: formData.get('username'),
password: formData.get('password')
})
});
const data = await response.json();
if (data.success) {
localStorage.setItem('adminToken', data.token);
window.location.href = '/manage';
} else {
showToast(data.message || '登录失败', 'error');
}
} catch (error) {
showToast('网络错误,请稍后重试', 'error');
} finally {
loginButton.disabled = false;
loginButton.textContent = '登录';
}
});
function showToast(message, type = 'error') {
const toast = document.createElement('div');
const bgColors = {
success: 'bg-green-600',
error: 'bg-destructive',
info: 'bg-primary'
};
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`;
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => {
toast.style.opacity = '0';
toast.style.transition = 'opacity 0.3s';
setTimeout(() => toast.parentNode && document.body.removeChild(toast), 300);
}, 2000);
}
window.addEventListener('DOMContentLoaded', () => {
const token = localStorage.getItem('adminToken');
if (token) {
fetch('/api/stats', {
headers: { 'Authorization': `Bearer ${token}` }
}).then(response => {
if (response.ok) window.location.href = '/manage';
});
}
});
const form=document.getElementById('loginForm'),btn=document.getElementById('loginButton');
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='登录'}});
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)}
window.addEventListener('DOMContentLoaded',()=>{const t=localStorage.getItem('adminToken');t&&fetch('/api/stats',{headers:{Authorization:`Bearer ${t}`}}).then(r=>{if(r.ok)location.href='/manage'})});
</script>
</body>
</html>
+1 -1
View File
@@ -2,12 +2,12 @@
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 = ""
+19 -8
View File
@@ -14,19 +14,19 @@ from app.api.admin.manage import router as admin_router
@asynccontextmanager
async def lifespan(app: FastAPI):
"""应用生命周期管理"""
logger.debug("[Web2API] 应用启动成功")
logger.debug("[Grok2API] 应用启动成功")
yield
logger.info("[Web2API] 应用关闭成功")
logger.info("[Grok2API] 应用关闭成功")
# 初始化日志
logger.info("[Web2API] 应用正在启动...")
logger.info("[Grok2API] 应用正在启动...")
# 创建FastAPI应用
app = FastAPI(
title="Web2API",
description="Web服务API",
version="1.0.0",
title="Grok2API",
description="Grok API 转换服务",
version="1.0.3",
lifespan=lifespan
)
@@ -44,8 +44,19 @@ app.mount("/static", StaticFiles(directory="app/template"), name="template")
@app.get("/")
async def root():
"""根路径"""
return {"message": "Welcome to Web2API"}
"""根路径 - 重定向到登录页"""
from fastapi.responses import RedirectResponse
return RedirectResponse(url="/login")
@app.get("/health")
async def health_check():
"""健康检查接口"""
return {
"status": "healthy",
"service": "Grok2API",
"version": "1.0.3"
}
if __name__ == "__main__":