Compare commits

..

28 Commits

Author SHA1 Message Date
snaily
5a44a76c48 Merge remote-tracking branch 'BetterAndBetterII/main' 2025-03-03 18:45:56 +08:00
Toddy
7b5b6c7d4c if role is tool then set to user 2025-03-03 08:23:04 +00:00
Yuzhong Zhang
68ed4da789 Update Dockerfile 2025-03-03 14:09:45 +08:00
Yuzhong Zhang
cdbca7ec62 优化dockerfile,增加docker-compose,async openai 2025-03-03 13:55:09 +08:00
Yuzhong Zhang
48d58ef2e8 异步生成完成 2025-03-03 13:41:06 +08:00
snaily
88d483c1ef Merge pull request #4 from toddyoe/main
chore: add system instruction to enhance compliance with function call
2025-02-27 19:17:39 +08:00
Toddy
8d48db026c chore: add system instruction to enhance compliance with function call 2025-02-27 10:35:25 +00:00
snaily
a592269198 Merge pull request #3 from toddyoe/main
feat: support function call
2025-02-27 16:14:50 +08:00
Toddy
18a5fe6109 fix: adapt gemini format 2025-02-27 07:35:12 +00:00
Toddy
348cbbdf2a feat: support function call 2025-02-27 05:36:39 +00:00
yinpeng
64235143dd ci: 精简 release workflow 文件
- 移除了 release-drafter 相关的步骤
- 保留了代码检出和创建 Release 的步骤
- 简化了工作流结构,提高了可读性
2025-02-15 01:06:32 +08:00
yinpeng
d566c28fa2 feat(gemini): 添加 API 密钥验证功能
- 在 gemini_routes.py 中添加 verify_key 路由,用于验证 API 密钥的有效性
- 在 keys_status 页面中添加验证按钮和相关逻辑
- 优化 keys_status 页面的样式,增加密钥验证相关 CSS 类
- 在 config.py 中添加 TEST_MODEL 设置,用于密钥验证测试
2025-02-15 01:00:47 +08:00
yinpeng
c1893d918e build: 更新发布流程并移除 release-drafter 配置
- 删除了 release-drafter.yml 文件,不再使用 release-drafter 自动生成发布说明
- 更新了 release.yml 工作流,移除了自动填充发布说明的步骤
- 保留了创建 ZIP 文件和上传构建文件的步骤,但标记为可选
2025-02-14 01:55:44 +08:00
yinpeng
4a02475cc1 ci: 优化发布流程,使用 release-drafter 自动生成发布说明 2025-02-14 01:46:24 +08:00
yinpeng
6e55a0985c ci: 优化发布流程,添加自动生成发布说明和资源打包功能 2025-02-14 01:40:20 +08:00
yinpeng
7b433aab91 refactor(static): 将 CSS 和 JS 代码分离到单独的文件中
- 将 auth.html 中的 CSS 代码提取到 auth.css 文件中
- 将 auth.html 中的 JS 代码提取到 auth.js 文件中
- 更新 auth.html,引入外部的 CSS 和 JS 文件
- 新增 keys_status.css 和 keys_status.js 文件,用于 keys_status 页面
2025-02-14 00:21:28 +08:00
yinpeng
fc7280bb18 feat: 优化滚动按钮显示逻辑,监听容器高度变化自动切换 2025-02-13 01:05:30 +08:00
yinpeng
8d9c99bda2 feat: 优化密钥状态页面滚动体验,添加容器滚动和渐变按钮样式 2025-02-13 00:49:44 +08:00
yinpeng
ab701f9415 docs: 完善 Web 界面功能文档,补充界面特性和交互细节 2025-02-12 23:40:05 +08:00
yinpeng
c3e0d4b64f feat: 添加页面底部版权信息和作者链接 2025-02-12 23:34:18 +08:00
yinpeng
5b7f4de63c feat: 优化密钥状态页面交互体验,添加分组折叠和刷新功能 2025-02-12 18:55:44 +08:00
yinpeng
ede27a5d70 refactor: 移除 retry_handler 中未使用的 KeyManager 导入 2025-02-12 17:48:09 +08:00
yinpeng
5a4619444b fix: 修复 Gemini 多段文本响应内容拼接问题 2025-02-12 17:47:03 +08:00
yinpeng
b3851441f1 refactor: 优化 RetryHandler 装饰器以支持动态 KeyManager 注入 2025-02-12 17:10:02 +08:00
yinpeng
44f956e4e4 feat: Add PWA support with manifest and ServiceWorker integration
- Mounted static files directory to serve PWA assets like manifest.json and ServiceWorker scripts.
- Updated `auth.html` and `keys_status.html` templates:
  - Added `<link>` for manifest and icons to support Progressive Web App (PWA) features.
  - Added meta tags for theme color and Apple web app capabilities.
  - Integrated ServiceWorker registration script for offline capabilities.
2025-02-12 16:20:34 +08:00
yinpeng
3aa4384b9d feat: Add responsive styles for auth and keys status pages
- Implement media queries to improve layout and UI for smaller screen sizes on `auth.html` and `keys_status.html`.
- Adjust container widths, font sizes, padding, and other styles for screen widths below 768px and 480px.
- Enhance mobile usability by making elements stack vertically, resizing fonts, and optimizing spacing for better readability and interaction.
2025-02-12 15:46:37 +08:00
yinpeng
6db4b56186 Refactor keys_status.html for improved layout and scrolling behavior
- Removed duplicated padding and simplified CSS for `body`, ensuring proper spacing with 20px padding.
- Adjusted `.container` styles:
  - Removed custom scrollbar styles and overflow-related attributes.
  - Centered the element with `margin: 20px auto`.
- Updated scroll behavior:
  - Changed scroll functions to operate on `window` instead of `.container`.
  - Modified event listeners to use `window` for detecting scroll events.
- Cleaned up redundant or unused styles and improved readability.
2025-02-12 15:30:44 +08:00
yinpeng
8e77773d5a Enhance UI/UX for keys_status.html
- Added smooth scroll functionality with "Scroll to Top" and "Scroll to Bottom" buttons.
- Introduced a `scroll-buttons` section with styled buttons for scrolling.
- Improved `#copyStatus` styling for better visibility and alignment.
- Adjusted `.container` to support scrollable content with hidden scrollbars and a max-height.
- Ensured proper z-index for new elements to prevent overlapping issues.
- Enhanced hover and active states for scroll buttons to improve user experience.
- Added event listeners to dynamically show/hide scroll buttons based on user scroll position.
2025-02-12 15:16:22 +08:00
23 changed files with 1279 additions and 481 deletions

View File

@@ -6,9 +6,10 @@ on:
- 'v*' # 当推送以 "v" 开头的标签时触发(如 v1.0.0, v2.1.0
jobs:
release:
update-release-draft:
permissions:
contents: write # 添加写入权限
contents: write
pull-requests: write
runs-on: ubuntu-latest
steps:
# Step 1: 检出代码库

View File

@@ -3,10 +3,10 @@ FROM python:3.10-slim
WORKDIR /app
# 复制所需文件到容器中
COPY ./app /app/app
COPY ./requirements.txt /app
RUN pip install --no-cache-dir -r requirements.txt
COPY ./app /app/app
ENV API_KEYS='["your_api_key_1"]'
ENV ALLOWED_TOKENS='["your_token_1"]'
ENV BASE_URL=https://generativelanguage.googleapis.com/v1beta

View File

@@ -272,28 +272,45 @@ uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
### Web界面功能
#### 验证页面
#### 验证页面 (auth.html)
- **URL**: `/auth`
- **说明**: 提供了一个简洁的Web界面用于验证访问令牌
- **功能**:
- 美观的用户界面,支持响应式设计
- **功能特点**:
- 现代化的渐变背景设计
- 响应式布局,完美支持移动端
- 毛玻璃效果的卡片设计
- 优雅的动画效果(淡入、滑动、悬浮)
- 安全的令牌验证机制
- 错误提示功能
- 支持移动端访问
- 清晰的错误提示功能
- PWA支持可安装为本地应用
- 底部版权信息和GitHub链接
- 支持暗色主题适配
#### API密钥状态管理
#### API密钥状态管理 (keys_status.html)
- **URL**: `/v1/keys/list`
- **Method**: `GET`
- **Header**: `Authorization: Bearer <your-auth-token>`
- **说明**:
- **功能特点**:
- 只有使用 `AUTH_TOKEN` 才能访问此接口
- 提供了可视化的Web界面展示API密钥状态
- 支持查看有效和无效的API密钥列表
- 显示每个密钥的失败次数统计
- 提供一键复制功能(支持复制单个密钥或批量复制
- 实时显示密钥总数统计
- 分类展示API密钥状态(有效/无效)
- 可折叠的密钥列表分组
- 每个密钥显示:
- 状态标识(有效/无效
- 密钥内容
- 失败次数统计
- 高级功能:
- 一键复制单个密钥
- 批量复制分组密钥JSON格式
- 实时刷新功能
- 回到顶部/底部快捷按钮
- 界面特性:
- 响应式设计,适配各种屏幕
- 优雅的动画效果
- 操作反馈(复制成功提示)
- PWA支持
- 暗色主题适配
### 图片生成 (Image Generation)

View File

@@ -1,10 +1,10 @@
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse
from fastapi.responses import StreamingResponse, JSONResponse
from app.core.config import settings
from app.core.logger import get_gemini_logger
from app.core.security import SecurityService
from app.schemas.gemini_models import GeminiRequest
from app.schemas.gemini_models import GeminiContent, GeminiRequest
from app.services.gemini_chat_service import GeminiChatService
from app.services.key_manager import KeyManager, get_key_manager_instance
from app.services.model_service import ModelService
@@ -47,7 +47,7 @@ async def list_models(_=Depends(security_service.verify_key),
@router.post("/models/{model_name}:generateContent")
@router_v1beta.post("/models/{model_name}:generateContent")
@RetryHandler(max_retries=3, key_manager=Depends(get_key_manager), key_arg="api_key")
@RetryHandler(max_retries=3, key_arg="api_key")
async def generate_content(
model_name: str,
request: GeminiRequest,
@@ -63,7 +63,7 @@ async def generate_content(
logger.info(f"Using API key: {api_key}")
try:
response = chat_service.generate_content(
response = await chat_service.generate_content(
model=model_name,
request=request,
api_key=api_key
@@ -77,7 +77,7 @@ async def generate_content(
@router.post("/models/{model_name}:streamGenerateContent")
@router_v1beta.post("/models/{model_name}:streamGenerateContent")
@RetryHandler(max_retries=3, key_manager=Depends(get_key_manager), key_arg="api_key")
@RetryHandler(max_retries=3, key_arg="api_key")
async def stream_generate_content(
model_name: str,
request: GeminiRequest,
@@ -102,3 +102,30 @@ async def stream_generate_content(
except Exception as e:
logger.error(f"Streaming request failed: {str(e)}")
@router.post("/verify-key/{api_key}")
async def verify_key(api_key: str):
key_manager = await get_key_manager()
chat_service = GeminiChatService(settings.BASE_URL, key_manager)
"""验证Gemini API密钥的有效性"""
logger.info("-" * 50 + "verify_gemini_key" + "-" * 50)
logger.info("Verifying API key validity")
try:
# 使用generate_content接口测试key的有效性
gemini_requset = GeminiRequest(
contents=[
GeminiContent(
role="user",
parts=[{"text": "hi"}]
)
]
)
response = await chat_service.generate_content(settings.TEST_MODEL,gemini_requset, api_key)
if response:
return JSONResponse({"status": "valid"})
return JSONResponse({"status": "invalid"})
except Exception as e:
logger.error(f"Key verification failed: {str(e)}")
return JSONResponse({"status": "invalid", "error": str(e)})

View File

@@ -46,7 +46,7 @@ async def list_models(
@router.post("/v1/chat/completions")
@router.post("/hf/v1/chat/completions")
@RetryHandler(max_retries=3, key_manager=Depends(get_key_manager), key_arg="api_key")
@RetryHandler(max_retries=3, key_arg="api_key")
async def chat_completion(
request: ChatRequest,
_=Depends(security_service.verify_authorization),

View File

@@ -16,6 +16,7 @@ class Settings(BaseSettings):
CREATE_IMAGE_MODEL: str = "imagen-3.0-generate-002"
UPLOAD_PROVIDER: str = "smms"
SMMS_SECRET_TOKEN: str = ""
TEST_MODEL: str = "gemini-1.5-flash"
def __init__(self):
super().__init__()

View File

@@ -2,6 +2,7 @@ from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from fastapi.staticfiles import StaticFiles
from app.core.logger import get_main_logger
from app.core.security import verify_auth_token
from app.services.key_manager import get_key_manager_instance
@@ -19,6 +20,9 @@ app = FastAPI()
# 配置Jinja2模板
templates = Jinja2Templates(directory="app/templates")
# 配置静态文件
app.mount("/static", StaticFiles(directory="app/static"), name="static")
# 创建 KeyManager 实例
key_manager = None

View File

@@ -24,13 +24,13 @@ class GeminiApiClient(ApiClient):
self.base_url = base_url
self.timeout = timeout
def generate_content(self, payload: Dict[str, Any], model: str, api_key: str) -> Dict[str, Any]:
async def generate_content(self, payload: Dict[str, Any], model: str, api_key: str) -> Dict[str, Any]:
timeout = httpx.Timeout(self.timeout, read=self.timeout)
if model.endswith("-search"):
model = model[:-7]
with httpx.Client(timeout=timeout) as client:
async with httpx.AsyncClient(timeout=timeout) as client:
url = f"{self.base_url}/models/{model}:generateContent?key={api_key}"
response = client.post(url, json=payload)
response = await client.post(url, json=payload)
if response.status_code != 200:
error_content = response.text
raise Exception(f"API call failed with status code {response.status_code}, {error_content}")

View File

@@ -1,14 +1,16 @@
# app/services/chat/message_converter.py
from abc import ABC, abstractmethod
from typing import List, Dict, Any
from typing import Any, Dict, List, Optional
SUPPORTED_ROLES = ["user", "model", "system"]
class MessageConverter(ABC):
"""消息转换器基类"""
@abstractmethod
def convert(self, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
def convert(self, messages: List[Dict[str, Any]]) -> tuple[List[Dict[str, Any]], Optional[Dict[str, Any]]]:
pass
@@ -30,24 +32,40 @@ def _convert_image(image_url: str) -> Dict[str, Any]:
class OpenAIMessageConverter(MessageConverter):
"""OpenAI消息格式转换器"""
def convert(self, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
def convert(self, messages: List[Dict[str, Any]]) -> tuple[List[Dict[str, Any]], Optional[Dict[str, Any]]]:
converted_messages = []
for msg in messages:
role = "user" if msg["role"] == "user" else "model"
parts = []
system_instruction = None
if isinstance(msg["content"], str):
for idx, msg in enumerate(messages):
role = msg.get("role", "")
if role not in SUPPORTED_ROLES:
if role == "tool":
role = "user"
else:
# 如果是最后一条消息,则认为是用户消息
if idx == len(messages) - 1:
role = "user"
else:
role = "model"
parts = []
if isinstance(msg["content"], str) and msg["content"]:
# 请求 gemini 接口时如果包含 content 字段但内容为空时会返回 400 错误,所以需要判断是否为空并移除
parts.append({"text": msg["content"]})
elif isinstance(msg["content"], list):
for content in msg["content"]:
if isinstance(content, str):
if isinstance(content, str) and content:
parts.append({"text": content})
elif isinstance(content, dict):
if content["type"] == "text":
if content["type"] == "text" and content["text"]:
parts.append({"text": content["text"]})
elif content["type"] == "image_url":
parts.append(_convert_image(content["image_url"]["url"]))
converted_messages.append({"role": role, "parts": parts})
if parts:
if role == "system":
system_instruction = {"role": "system", "parts": parts}
else:
converted_messages.append({"role": role, "parts": parts})
return converted_messages
return converted_messages, system_instruction

View File

@@ -1,7 +1,10 @@
# app/services/chat/response_handler.py
import json
import random
import string
from abc import ABC, abstractmethod
from typing import Dict, Any, Optional
from typing import Dict, Any, List, Optional
import time
import uuid
from app.core.config import settings
@@ -29,40 +32,38 @@ class GeminiResponseHandler(ResponseHandler):
def _handle_openai_stream_response(response: Dict[str, Any], model: str, finish_reason: str) -> Dict[str, Any]:
text = _extract_text(response, model, stream=True)
text, tool_calls = _extract_result(response, model, stream=True, gemini_format=False)
if not text and not tool_calls:
delta = {}
else:
delta = {"content": text, "role": "assistant"}
if tool_calls:
delta["tool_calls"] = tool_calls
return {
"id": f"chatcmpl-{uuid.uuid4()}",
"object": "chat.completion.chunk",
"created": int(time.time()),
"model": model,
"choices": [{
"index": 0,
"delta": {"content": text} if text else {},
"finish_reason": finish_reason
}]
"choices": [{"index": 0, "delta": delta, "finish_reason": finish_reason}],
}
def _handle_openai_normal_response(response: Dict[str, Any], model: str, finish_reason: str) -> Dict[str, Any]:
text = _extract_text(response, model, stream=False)
text, tool_calls = _extract_result(response, model, stream=False, gemini_format=False)
return {
"id": f"chatcmpl-{uuid.uuid4()}",
"object": "chat.completion",
"created": int(time.time()),
"model": model,
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"content": text
},
"finish_reason": finish_reason
}],
"usage": {
"prompt_tokens": 0,
"completion_tokens": 0,
"total_tokens": 0
}
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": text, "tool_calls": tool_calls},
"finish_reason": finish_reason,
}
],
"usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
}
@@ -127,8 +128,8 @@ def _handle_openai_normal_image_response(image_str: str,model: str,finish_reason
}
def _extract_text(response: Dict[str, Any], model: str, stream: bool = False) -> str:
text = ""
def _extract_result(response: Dict[str, Any], model: str, stream: bool = False, gemini_format: bool = False) -> tuple[str, List[Dict[str, Any]]]:
text, tool_calls = "", []
if stream:
if response.get("candidates"):
candidate = response["candidates"][0]
@@ -212,6 +213,7 @@ def _extract_text(response: Dict[str, Any], model: str, stream: bool = False) ->
else:
text = ""
text = _add_search_link_text(model, candidate, text)
tool_calls = _extract_tool_calls(parts, gemini_format)
else:
if response.get("candidates"):
candidate = response["candidates"][0]
@@ -232,23 +234,67 @@ def _extract_text(response: Dict[str, Any], model: str, stream: bool = False) ->
else:
text = candidate["content"]["parts"][0]["text"]
else:
text = candidate["content"]["parts"][0]["text"]
text = ""
for part in candidate["content"]["parts"]:
text += part.get("text", "")
text = _add_search_link_text(model, candidate, text)
tool_calls = _extract_tool_calls(candidate["content"]["parts"], gemini_format)
else:
text = "暂无返回"
return text
return text, tool_calls
def _extract_tool_calls(parts: List[Dict[str, Any]], gemini_format: bool) -> List[Dict[str, Any]]:
"""提取工具调用信息"""
if not parts or not isinstance(parts, list):
return []
letters = string.ascii_lowercase + string.digits
tool_calls = list()
for i in range(len(parts)):
part = parts[i]
if not part or not isinstance(part, dict):
continue
item = part.get("functionCall", {})
if not item or not isinstance(item, dict):
continue
if gemini_format:
tool_calls.append(part)
else:
id = f"call_{''.join(random.sample(letters, 32))}"
name = item.get("name", "")
arguments = json.dumps(item.get("args", None) or {})
tool_calls.append(
{
"index": i,
"id": id,
"type": "function",
"function": {"name": name, "arguments": arguments},
}
)
return tool_calls
def _handle_gemini_stream_response(response: Dict[str, Any], model: str, stream: bool) -> Dict[str, Any]:
text = _extract_text(response, model, stream=stream)
content = {"parts": [{"text": text}], "role": "model"}
text, tool_calls = _extract_result(response, model, stream=stream, gemini_format=True)
if tool_calls:
content = {"parts": tool_calls, "role": "model"}
else:
content = {"parts": [{"text": text}], "role": "model"}
response["candidates"][0]["content"] = content
return response
def _handle_gemini_normal_response(response: Dict[str, Any], model: str, stream: bool) -> Dict[str, Any]:
text = _extract_text(response, model, stream=stream)
content = {"parts": [{"text": text}], "role": "model"}
text, tool_calls = _extract_result(response, model, stream=stream, gemini_format=True)
if tool_calls:
content = {"parts": tool_calls, "role": "model"}
else:
content = {"parts": [{"text": text}], "role": "model"}
response["candidates"][0]["content"] = content
return response

View File

@@ -3,7 +3,6 @@
from typing import TypeVar, Callable
from functools import wraps
from app.core.logger import get_retry_logger
from app.services.key_manager import KeyManager
T = TypeVar('T')
logger = get_retry_logger()
@@ -12,9 +11,8 @@ logger = get_retry_logger()
class RetryHandler:
"""重试处理装饰器"""
def __init__(self, max_retries: int = 3, key_manager: KeyManager = None, key_arg: str = "api_key"):
def __init__(self, max_retries: int = 3, key_arg: str = "api_key"):
self.max_retries = max_retries
self.key_manager = key_manager
self.key_arg = key_arg
def __call__(self, func: Callable[..., T]) -> Callable[..., T]:
@@ -29,9 +27,11 @@ class RetryHandler:
last_exception = e
logger.warning(f"API call failed with error: {str(e)}. Attempt {attempt + 1} of {self.max_retries}")
if self.key_manager:
# 从函数参数中获取 key_manager
key_manager = kwargs.get('key_manager')
if key_manager:
old_key = kwargs.get(self.key_arg)
new_key = await self.key_manager.handle_api_failure(old_key)
new_key = await key_manager.handle_api_failure(old_key)
kwargs[self.key_arg] = new_key
logger.info(f"Switched to new API key: {new_key}")

View File

@@ -31,6 +31,12 @@ def _build_tools(model: str, payload: Dict[str, Any]) -> List[Dict[str, Any]]:
tools.append({"code_execution": {}})
if model.endswith("-search"):
tools.append({"googleSearch": {}})
if payload and isinstance(payload, dict) and "tools" in payload:
items = payload.get("tools", [])
if items and isinstance(items, list):
tools.extend(items)
return tools
@@ -73,10 +79,10 @@ class GeminiChatService:
self.key_manager = key_manager
self.response_handler = GeminiResponseHandler()
def generate_content(self, model: str, request: GeminiRequest, api_key: str) -> Dict[str, Any]:
async def generate_content(self, model: str, request: GeminiRequest, api_key: str) -> Dict[str, Any]:
"""生成内容"""
payload = _build_payload(model, request)
response = self.api_client.generate_content(payload, model, api_key)
response = await self.api_client.generate_content(payload, model, api_key)
return self.response_handler.handle_response(response, model, stream=False)
async def stream_generate_content(self, model: str, request: GeminiRequest, api_key: str) -> AsyncGenerator[str, None]:

View File

@@ -1,7 +1,8 @@
# app/services/chat_service.py
from copy import deepcopy
import json
from typing import Dict, Any, AsyncGenerator, List, Union
from typing import Dict, Any, AsyncGenerator, List, Optional, Union
from app.core.logger import get_openai_logger
from app.services.chat.message_converter import OpenAIMessageConverter
from app.services.chat.response_handler import OpenAIResponseHandler
@@ -39,6 +40,32 @@ def _build_tools(
tools.append({"code_execution": {}})
if model.endswith("-search"):
tools.append({"googleSearch": {}})
# 将 request 中的 tools 合并到 tools 中
if request.tools:
function_declarations = []
for tool in request.tools:
if not tool or not isinstance(tool, dict):
continue
if tool.get("type", "") == "function" and tool.get("function"):
function = deepcopy(tool.get("function"))
parameters = function.get("parameters", {})
if parameters.get("type") == "object" and not parameters.get("properties", {}):
function.pop("parameters", None)
function_declarations.append(function)
if function_declarations:
# 按照 function 的 name 去重
names, functions = set(), []
for item in function_declarations:
if item.get("name") not in names:
names.add(item.get("name"))
functions.append(item)
tools.append({"functionDeclarations": functions})
return tools
@@ -67,10 +94,10 @@ def _get_safety_settings(model: str) -> List[Dict[str, str]]:
def _build_payload(
request: ChatRequest, messages: List[Dict[str, Any]]
request: ChatRequest, messages: List[Dict[str, Any]], instruction: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
"""构建请求payload"""
return {
payload = {
"contents": messages,
"generationConfig": {
"temperature": request.temperature,
@@ -83,6 +110,16 @@ def _build_payload(
"safetySettings": _get_safety_settings(request.model),
}
if (
instruction
and isinstance(instruction, dict)
and instruction.get("role") == "system"
and instruction.get("parts")
):
payload["systemInstruction"] = instruction
return payload
class OpenAIChatService:
"""聊天服务"""
@@ -100,20 +137,20 @@ class OpenAIChatService:
) -> Union[Dict[str, Any], AsyncGenerator[str, None]]:
"""创建聊天完成"""
# 转换消息格式
messages = self.message_converter.convert(request.messages)
messages, instruction = self.message_converter.convert(request.messages)
# 构建请求payload
payload = _build_payload(request, messages)
payload = _build_payload(request, messages, instruction)
if request.stream:
return self._handle_stream_completion(request.model, payload, api_key)
return self._handle_normal_completion(request.model, payload, api_key)
return await self._handle_normal_completion(request.model, payload, api_key)
def _handle_normal_completion(
async def _handle_normal_completion(
self, model: str, payload: Dict[str, Any], api_key: str
) -> Dict[str, Any]:
"""处理普通聊天完成"""
response = self.api_client.generate_content(payload, model, api_key)
response = await self.api_client.generate_content(payload, model, api_key)
return self.response_handler.handle_response(
response, model, stream=False, finish_reason="stop"
)

249
app/static/css/auth.css Normal file
View File

@@ -0,0 +1,249 @@
body {
font-family: 'Roboto', sans-serif;
line-height: 1.6;
margin: 0;
padding: 0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
}
.container {
max-width: 400px;
width: 90%;
background: rgba(255, 255, 255, 0.95);
padding: 40px;
border-radius: 20px;
box-shadow: 0 15px 35px rgba(0,0,0,0.2);
backdrop-filter: blur(10px);
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
.container:hover {
transform: translateY(-5px);
box-shadow: 0 20px 40px rgba(0,0,0,0.25);
}
.logo {
text-align: center;
margin-bottom: 30px;
animation: fadeIn 1s ease;
}
.logo i {
font-size: 48px;
color: #764ba2;
margin-bottom: 15px;
}
h2 {
color: #2c3e50;
text-align: center;
margin-bottom: 30px;
font-weight: 700;
font-size: 24px;
animation: slideDown 0.5s ease;
}
form {
display: flex;
flex-direction: column;
gap: 20px;
}
.input-group {
position: relative;
animation: slideUp 0.5s ease;
}
.input-group i {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: #764ba2;
font-size: 18px;
}
input {
width: 100%;
padding: 12px 12px 12px 40px;
border: 2px solid #e0e0e0;
border-radius: 10px;
font-size: 16px;
box-sizing: border-box;
transition: all 0.3s ease;
background: rgba(255, 255, 255, 0.9);
}
input:focus {
border-color: #764ba2;
box-shadow: 0 0 10px rgba(118, 75, 162, 0.2);
outline: none;
}
button {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
padding: 14px;
border-radius: 10px;
cursor: pointer;
font-size: 16px;
font-weight: bold;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
}
button:hover {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(118, 75, 162, 0.3);
}
button:active {
transform: translateY(0);
}
button::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 0;
height: 0;
background: rgba(255, 255, 255, 0.2);
border-radius: 50%;
transform: translate(-50%, -50%);
transition: width 0.6s, height 0.6s;
}
button:active::after {
width: 200px;
height: 200px;
opacity: 0;
}
.error-message {
color: #e74c3c;
margin-top: 15px;
text-align: center;
font-weight: bold;
padding: 10px;
border-radius: 5px;
background: rgba(231, 76, 60, 0.1);
animation: shake 0.5s ease;
}
.copyright {
position: fixed;
bottom: 0;
left: 0;
width: 100%;
background: rgba(255, 255, 255, 0.9);
padding: 10px 0;
text-align: center;
font-size: 14px;
color: #2c3e50;
backdrop-filter: blur(5px);
border-top: 1px solid rgba(0,0,0,0.1);
}
.copyright a {
color: #764ba2;
text-decoration: none;
transition: color 0.3s ease;
}
.copyright a:hover {
color: #667eea;
}
.copyright img {
width: 20px;
height: 20px;
border-radius: 50%;
vertical-align: middle;
margin-right: 5px;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideDown {
from { transform: translateY(-20px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
@keyframes slideUp {
from { transform: translateY(20px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-5px); }
75% { transform: translateX(5px); }
}
@media (max-width: 768px) {
.container {
width: 85%;
padding: 30px;
}
.logo i {
font-size: 40px;
}
h2 {
font-size: 22px;
}
input {
padding: 10px 10px 10px 35px;
font-size: 15px;
}
.input-group i {
font-size: 16px;
}
button {
padding: 12px;
font-size: 15px;
}
}
@media (max-width: 480px) {
.container {
width: 90%;
padding: 25px;
}
.logo i {
font-size: 36px;
}
h2 {
font-size: 20px;
margin-bottom: 25px;
}
form {
gap: 15px;
}
input {
padding: 10px 10px 10px 32px;
font-size: 14px;
}
.input-group i {
font-size: 15px;
left: 10px;
}
button {
padding: 10px;
font-size: 14px;
}
.error-message {
font-size: 14px;
padding: 8px;
margin-top: 12px;
}
}

View File

@@ -0,0 +1,461 @@
body {
font-family: 'Roboto', sans-serif;
line-height: 1.6;
margin: 0;
padding: 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
}
.container {
max-width: 900px;
width: 95%;
background: rgba(255, 255, 255, 0.95);
padding: 40px;
border-radius: 20px;
box-shadow: 0 15px 35px rgba(0,0,0,0.2);
backdrop-filter: blur(10px);
position: relative;
margin: 20px auto;
overflow-y: auto;
max-height: calc(100vh - 40px);
scrollbar-width: none;
-ms-overflow-style: none;
}
.container::-webkit-scrollbar {
display: none;
}
h1 {
color: #2c3e50;
text-align: center;
margin-bottom: 30px;
font-weight: 700;
font-size: 32px;
position: relative;
padding-bottom: 15px;
}
h1::after {
content: '';
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 100px;
height: 4px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 2px;
}
.key-list {
margin-bottom: 30px;
background: rgba(248, 249, 250, 0.9);
padding: 25px;
border-radius: 15px;
transition: all 0.3s ease;
border: 1px solid rgba(0,0,0,0.1);
animation: fadeIn 0.5s ease forwards;
}
.key-list:hover {
transform: translateY(-5px);
box-shadow: 0 10px 20px rgba(0,0,0,0.1);
}
.key-list:nth-child(2) {
animation-delay: 0.2s;
}
.key-list h2 {
color: #2c3e50;
margin-bottom: 20px;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 1.5em;
padding-bottom: 10px;
border-bottom: 2px solid rgba(0,0,0,0.1);
cursor: pointer;
}
.key-list h2 .toggle-icon {
margin-right: 10px;
transition: transform 0.3s ease;
}
.key-list h2 .toggle-icon.collapsed {
transform: rotate(-90deg);
}
.key-list .key-content {
transition: all 0.3s ease-out;
overflow: hidden;
height: auto;
opacity: 1;
}
.key-list .key-content.collapsed {
height: 0;
opacity: 0;
padding-top: 0;
padding-bottom: 0;
}
ul {
list-style-type: none;
padding: 0;
margin: 0;
}
li {
background: white;
border: 1px solid rgba(0,0,0,0.1);
margin-bottom: 12px;
padding: 15px;
border-radius: 10px;
transition: all 0.3s ease;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 2px 5px rgba(0,0,0,0.05);
}
li:hover {
transform: translateX(5px);
box-shadow: 0 5px 15px rgba(0,0,0,0.1);
}
.key-info {
display: flex;
align-items: center;
gap: 15px;
flex: 1;
}
.key-text {
font-family: 'Roboto Mono', monospace;
color: #2c3e50;
}
.fail-count {
background: rgba(231, 76, 60, 0.1);
color: #e74c3c;
padding: 4px 10px;
border-radius: 15px;
font-size: 0.85em;
display: flex;
align-items: center;
gap: 5px;
}
.fail-count i {
font-size: 12px;
}
.key-actions {
display: flex;
gap: 10px;
align-items: center;
}
.verify-btn, .copy-btn {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
padding: 8px 15px;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
font-weight: bold;
transition: all 0.3s ease;
display: flex;
align-items: center;
gap: 5px;
}
.verify-btn {
background: linear-gradient(135deg, #2ecc71, #27ae60);
}
.verify-btn:hover {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(46, 204, 113, 0.3);
}
.verify-btn:disabled {
opacity: 0.7;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.verify-btn i {
font-size: 14px;
}
.copy-btn:hover {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(118, 75, 162, 0.3);
}
.copy-btn:active {
transform: translateY(0);
}
.copy-btn i {
font-size: 14px;
}
.total {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 15px 25px;
border-radius: 10px;
font-weight: bold;
text-align: center;
font-size: 1.2em;
margin-top: 30px;
box-shadow: 0 5px 15px rgba(0,0,0,0.1);
}
#copyStatus {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
padding: 15px 30px;
border-radius: 25px;
font-weight: bold;
opacity: 0;
transition: all 0.3s ease;
backdrop-filter: blur(5px);
box-shadow: 0 5px 15px rgba(0,0,0,0.2);
z-index: 1000;
text-align: center;
min-width: 200px;
color: white;
}
#copyStatus.success {
background: rgba(39, 174, 96, 0.95);
}
#copyStatus.error {
background: rgba(231, 76, 60, 0.95);
}
.status-badge {
padding: 4px 12px;
border-radius: 15px;
font-size: 0.9em;
font-weight: bold;
margin-right: 10px;
}
.status-valid {
background: rgba(39, 174, 96, 0.1);
color: #27ae60;
}
.status-invalid {
background: rgba(231, 76, 60, 0.1);
color: #e74c3c;
}
.scroll-buttons {
position: fixed;
right: 20px;
bottom: 20px;
display: none;
flex-direction: column;
gap: 10px;
z-index: 1000;
}
.scroll-btn {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
width: 40px;
height: 40px;
border: none;
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
transition: all 0.3s ease;
backdrop-filter: blur(5px);
box-shadow: 0 2px 10px rgba(0,0,0,0.2);
}
.scroll-btn:hover {
background: linear-gradient(135deg, #764ba2 0%, #667eea 100%);
transform: scale(1.1);
}
.scroll-btn:active {
transform: scale(0.95);
}
.refresh-btn {
position: fixed;
top: 20px;
right: 20px;
z-index: 1000;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
border: none;
padding: 10px 20px;
border-radius: 25px;
cursor: pointer;
font-size: 14px;
font-weight: bold;
transition: all 0.3s ease;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
}
.refresh-btn:hover {
transform: scale(1.05);
box-shadow: 0 8px 20px rgba(118, 75, 162, 0.3);
background: linear-gradient(135deg, #764ba2 0%, #667eea 100%);
}
.refresh-btn:active {
transform: scale(0.95);
}
.refresh-btn i {
transition: transform 0.5s ease;
}
.refresh-btn.loading i {
animation: spin 1s linear infinite;
}
.copyright {
position: fixed;
bottom: 0;
left: 0;
width: 100%;
background: rgba(255, 255, 255, 0.9);
padding: 10px 0;
text-align: center;
font-size: 14px;
color: #2c3e50;
backdrop-filter: blur(5px);
border-top: 1px solid rgba(0,0,0,0.1);
}
.copyright a {
color: #764ba2;
text-decoration: none;
transition: color 0.3s ease;
}
.copyright a:hover {
color: #667eea;
}
.copyright img {
width: 20px;
height: 20px;
border-radius: 50%;
vertical-align: middle;
margin-right: 5px;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@media (max-width: 768px) {
.container {
width: 100%;
padding: 20px;
margin: 10px auto;
}
body {
padding: 10px;
}
h1 {
font-size: 24px;
}
.key-list h2 {
font-size: 1.2em;
flex-direction: column;
gap: 10px;
align-items: flex-start;
}
.key-info {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
li {
flex-direction: column;
gap: 10px;
}
.key-actions {
width: 100%;
flex-direction: column;
}
.verify-btn, .copy-btn {
width: 100%;
justify-content: center;
}
.key-text {
word-break: break-all;
}
.scroll-buttons {
right: 10px;
bottom: 10px;
}
.scroll-btn {
width: 35px;
height: 35px;
font-size: 16px;
}
.refresh-btn {
top: 10px;
right: 10px;
padding: 8px 16px;
font-size: 12px;
}
}
@media (max-width: 480px) {
.container {
padding: 15px;
}
h1 {
font-size: 20px;
}
.key-list {
padding: 15px;
}
.status-badge {
padding: 3px 8px;
font-size: 0.8em;
}
.fail-count {
font-size: 0.8em;
}
.total {
font-size: 1em;
padding: 12px 20px;
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

18
app/static/js/auth.js Normal file
View File

@@ -0,0 +1,18 @@
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/static/service-worker.js')
.then(registration => {
console.log('ServiceWorker注册成功:', registration.scope);
})
.catch(error => {
console.log('ServiceWorker注册失败:', error);
});
});
}
document.addEventListener('DOMContentLoaded', () => {
const copyrightYear = document.querySelector('.copyright script');
if (copyrightYear) {
copyrightYear.textContent = new Date().getFullYear();
}
});

View File

@@ -0,0 +1,175 @@
function copyToClipboard(text) {
if (navigator.clipboard && navigator.clipboard.writeText) {
return navigator.clipboard.writeText(text);
} else {
return new Promise((resolve, reject) => {
const textArea = document.createElement("textarea");
textArea.value = text;
textArea.style.position = "fixed";
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
const successful = document.execCommand('copy');
document.body.removeChild(textArea);
if (successful) {
resolve();
} else {
reject(new Error('复制失败'));
}
} catch (err) {
document.body.removeChild(textArea);
reject(err);
}
});
}
}
function copyKeys(type) {
const keys = Array.from(document.querySelectorAll(`#${type}Keys .key-text`)).map(span => span.textContent.trim());
const jsonKeys = JSON.stringify(keys);
copyToClipboard(jsonKeys)
.then(() => {
showCopyStatus(`已成功复制${type === 'valid' ? '有效' : '无效'}密钥到剪贴板`);
})
.catch((err) => {
console.error('无法复制文本: ', err);
showCopyStatus('复制失败,请重试');
});
}
function copyKey(key) {
copyToClipboard(key)
.then(() => {
showCopyStatus(`已成功复制密钥到剪贴板`);
})
.catch((err) => {
console.error('无法复制文本: ', err);
showCopyStatus('复制失败,请重试');
});
}
function showCopyStatus(message, type = 'success') {
const statusElement = document.getElementById('copyStatus');
statusElement.textContent = message;
statusElement.className = type; // 设置样式类
statusElement.style.opacity = 1;
setTimeout(() => {
statusElement.style.opacity = 0;
setTimeout(() => {
statusElement.className = ''; // 清除样式类
}, 300);
}, 2000);
}
async function verifyKey(key, button) {
try {
// 禁用按钮并显示加载状态
button.disabled = true;
const originalHtml = button.innerHTML;
button.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 验证中';
const response = await fetch(`/gemini/v1beta/verify-key/${key}`, {
method: 'POST'
});
const data = await response.json();
// 根据验证结果更新UI
if (data.status === 'valid') {
showCopyStatus('密钥验证成功', 'success');
button.style.backgroundColor = '#27ae60';
} else {
showCopyStatus('密钥验证失败', 'error');
button.style.backgroundColor = '#e74c3c';
}
// 3秒后恢复按钮原始状态
setTimeout(() => {
button.innerHTML = originalHtml;
button.disabled = false;
button.style.backgroundColor = '';
}, 3000);
} catch (error) {
console.error('验证失败:', error);
showCopyStatus('验证请求失败', 'error');
button.disabled = false;
button.innerHTML = '<i class="fas fa-check-circle"></i> 验证';
}
}
function scrollToTop() {
const container = document.querySelector('.container');
container.scrollTo({
top: 0,
behavior: 'smooth'
});
}
function scrollToBottom() {
const container = document.querySelector('.container');
container.scrollTo({
top: container.scrollHeight,
behavior: 'smooth'
});
}
function updateScrollButtons() {
const container = document.querySelector('.container');
const scrollButtons = document.querySelector('.scroll-buttons');
if (container.scrollHeight > container.clientHeight) {
scrollButtons.style.display = 'flex';
} else {
scrollButtons.style.display = 'none';
}
}
function refreshPage(button) {
button.classList.add('loading');
button.disabled = true;
setTimeout(() => {
window.location.reload();
}, 300);
}
function toggleSection(header, sectionId) {
const toggleIcon = header.querySelector('.toggle-icon');
const content = header.nextElementSibling;
toggleIcon.classList.toggle('collapsed');
content.classList.toggle('collapsed');
}
// 初始化
document.addEventListener('DOMContentLoaded', () => {
// 检查滚动按钮
updateScrollButtons();
// 监听展开/折叠事件
document.querySelectorAll('.key-list h2').forEach(header => {
header.addEventListener('click', () => {
setTimeout(updateScrollButtons, 300);
});
});
// 更新版权年份
const copyrightYear = document.querySelector('.copyright script');
if (copyrightYear) {
copyrightYear.textContent = new Date().getFullYear();
}
});
// Service Worker registration
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/static/service-worker.js')
.then(registration => {
console.log('ServiceWorker注册成功:', registration.scope);
})
.catch(error => {
console.log('ServiceWorker注册失败:', error);
});
});
}

17
app/static/manifest.json Normal file
View File

@@ -0,0 +1,17 @@
{
"name": "Gemini Balance",
"short_name": "GBalance",
"description": "Gemini API密钥管理工具",
"start_url": "/",
"display": "standalone",
"background_color": "#667eea",
"theme_color": "#764ba2",
"icons": [
{
"src": "/static/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
}
]
}

View File

@@ -0,0 +1,43 @@
const CACHE_NAME = 'gbalance-cache-v1';
const urlsToCache = [
'/',
'/static/manifest.json',
'/static/icons/icon-192x192.png'
];
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => {
console.log('Opened cache');
return cache.addAll(urlsToCache);
})
);
});
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request)
.then(response => {
if (response) {
return response;
}
return fetch(event.request);
})
);
});
self.addEventListener('activate', event => {
const cacheWhitelist = [CACHE_NAME];
event.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames.map(cacheName => {
if (cacheWhitelist.indexOf(cacheName) === -1) {
return caches.delete(cacheName);
}
})
);
})
);
});

View File

@@ -4,149 +4,15 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>验证页面</title>
<link rel="manifest" href="/static/manifest.json">
<meta name="theme-color" content="#764ba2">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-mobile-web-app-title" content="GBalance">
<link rel="icon" href="/static/icons/icon-192x192.png">
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<style>
body {
font-family: 'Roboto', sans-serif;
line-height: 1.6;
margin: 0;
padding: 0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
}
.container {
max-width: 400px;
width: 90%;
background: rgba(255, 255, 255, 0.95);
padding: 40px;
border-radius: 20px;
box-shadow: 0 15px 35px rgba(0,0,0,0.2);
backdrop-filter: blur(10px);
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
.container:hover {
transform: translateY(-5px);
box-shadow: 0 20px 40px rgba(0,0,0,0.25);
}
.logo {
text-align: center;
margin-bottom: 30px;
animation: fadeIn 1s ease;
}
.logo i {
font-size: 48px;
color: #764ba2;
margin-bottom: 15px;
}
h2 {
color: #2c3e50;
text-align: center;
margin-bottom: 30px;
font-weight: 700;
font-size: 24px;
animation: slideDown 0.5s ease;
}
form {
display: flex;
flex-direction: column;
gap: 20px;
}
.input-group {
position: relative;
animation: slideUp 0.5s ease;
}
.input-group i {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: #764ba2;
font-size: 18px;
}
input {
width: 100%;
padding: 12px 12px 12px 40px;
border: 2px solid #e0e0e0;
border-radius: 10px;
font-size: 16px;
box-sizing: border-box;
transition: all 0.3s ease;
background: rgba(255, 255, 255, 0.9);
}
input:focus {
border-color: #764ba2;
box-shadow: 0 0 10px rgba(118, 75, 162, 0.2);
outline: none;
}
button {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
padding: 14px;
border-radius: 10px;
cursor: pointer;
font-size: 16px;
font-weight: bold;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
}
button:hover {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(118, 75, 162, 0.3);
}
button:active {
transform: translateY(0);
}
button::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 0;
height: 0;
background: rgba(255, 255, 255, 0.2);
border-radius: 50%;
transform: translate(-50%, -50%);
transition: width 0.6s, height 0.6s;
}
button:active::after {
width: 200px;
height: 200px;
opacity: 0;
}
.error-message {
color: #e74c3c;
margin-top: 15px;
text-align: center;
font-weight: bold;
padding: 10px;
border-radius: 5px;
background: rgba(231, 76, 60, 0.1);
animation: shake 0.5s ease;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideDown {
from { transform: translateY(-20px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
@keyframes slideUp {
from { transform: translateY(20px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-5px); }
75% { transform: translateX(5px); }
}
</style>
<link rel="stylesheet" href="/static/css/auth.css">
</head>
<body>
<div class="container">
@@ -167,5 +33,10 @@
<p class="error-message">{{ error }}</p>
{% endif %}
</div>
<div class="copyright">
© <script>document.write(new Date().getFullYear())</script> by <a href="https://linux.do/u/snaily" target="_blank"><img src="https://linux.do/user_avatar/linux.do/snaily/288/306510_2.gif" alt="snaily">snaily</a> |
<a href="https://github.com/snailyp/gemini-balance" target="_blank"><i class="fab fa-github"></i> GitHub</a>
</div>
<script src="/static/js/auth.js"></script>
</body>
</html>

View File

@@ -4,208 +4,36 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>API密钥状态</title>
<link rel="manifest" href="/static/manifest.json">
<meta name="theme-color" content="#764ba2">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-mobile-web-app-title" content="GBalance">
<link rel="icon" href="/static/icons/icon-192x192.png">
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<style>
body {
font-family: 'Roboto', sans-serif;
line-height: 1.6;
margin: 0;
padding: 0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
padding: 20px;
}
.container {
max-width: 900px;
width: 95%;
background: rgba(255, 255, 255, 0.95);
padding: 40px;
border-radius: 20px;
box-shadow: 0 15px 35px rgba(0,0,0,0.2);
backdrop-filter: blur(10px);
}
h1 {
color: #2c3e50;
text-align: center;
margin-bottom: 30px;
font-weight: 700;
font-size: 32px;
position: relative;
padding-bottom: 15px;
}
h1::after {
content: '';
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 100px;
height: 4px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 2px;
}
.key-list {
margin-bottom: 30px;
background: rgba(248, 249, 250, 0.9);
padding: 25px;
border-radius: 15px;
transition: all 0.3s ease;
border: 1px solid rgba(0,0,0,0.1);
}
.key-list:hover {
transform: translateY(-5px);
box-shadow: 0 10px 20px rgba(0,0,0,0.1);
}
.key-list h2 {
color: #2c3e50;
margin-bottom: 20px;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 1.5em;
padding-bottom: 10px;
border-bottom: 2px solid rgba(0,0,0,0.1);
}
ul {
list-style-type: none;
padding: 0;
margin: 0;
}
li {
background: white;
border: 1px solid rgba(0,0,0,0.1);
margin-bottom: 12px;
padding: 15px;
border-radius: 10px;
transition: all 0.3s ease;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 2px 5px rgba(0,0,0,0.05);
}
li:hover {
transform: translateX(5px);
box-shadow: 0 5px 15px rgba(0,0,0,0.1);
}
.key-info {
display: flex;
align-items: center;
gap: 15px;
flex: 1;
}
.key-text {
font-family: 'Roboto Mono', monospace;
color: #2c3e50;
}
.fail-count {
background: rgba(231, 76, 60, 0.1);
color: #e74c3c;
padding: 4px 10px;
border-radius: 15px;
font-size: 0.85em;
display: flex;
align-items: center;
gap: 5px;
}
.fail-count i {
font-size: 12px;
}
.copy-btn {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
padding: 8px 15px;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
font-weight: bold;
transition: all 0.3s ease;
display: flex;
align-items: center;
gap: 5px;
}
.copy-btn:hover {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(118, 75, 162, 0.3);
}
.copy-btn:active {
transform: translateY(0);
}
.copy-btn i {
font-size: 14px;
}
.total {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 15px 25px;
border-radius: 10px;
font-weight: bold;
text-align: center;
font-size: 1.2em;
margin-top: 30px;
box-shadow: 0 5px 15px rgba(0,0,0,0.1);
}
#copyStatus {
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
background: rgba(39, 174, 96, 0.9);
color: white;
padding: 12px 25px;
border-radius: 25px;
font-weight: bold;
opacity: 0;
transition: opacity 0.3s ease;
backdrop-filter: blur(5px);
box-shadow: 0 5px 15px rgba(0,0,0,0.2);
}
.status-badge {
padding: 4px 12px;
border-radius: 15px;
font-size: 0.9em;
font-weight: bold;
margin-right: 10px;
}
.status-valid {
background: rgba(39, 174, 96, 0.1);
color: #27ae60;
}
.status-invalid {
background: rgba(231, 76, 60, 0.1);
color: #e74c3c;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.key-list {
animation: fadeIn 0.5s ease forwards;
}
.key-list:nth-child(2) {
animation-delay: 0.2s;
}
</style>
<link rel="stylesheet" href="/static/css/keys_status.css">
</head>
<body>
<div class="container">
<button class="refresh-btn" onclick="refreshPage(this)">
<i class="fas fa-sync-alt"></i>
</button>
<h1>API密钥状态</h1>
<div class="key-list">
<h2>
<h2 onclick="toggleSection(this, 'validKeys')">
<span>
<i class="fas fa-chevron-down toggle-icon"></i>
<i class="fas fa-check-circle" style="color: #27ae60;"></i>
有效密钥
</span>
<button class="copy-btn" onclick="copyKeys('valid')">
<button class="copy-btn" onclick="event.stopPropagation(); copyKeys('valid')">
<i class="fas fa-copy"></i>
批量复制
</button>
</h2>
<ul id="validKeys">
<div class="key-content">
<ul id="validKeys">
{% for key, fail_count in valid_keys.items() %}
<li>
<div class="key-info">
@@ -218,26 +46,35 @@
失败: {{ fail_count }}
</span>
</div>
<button class="copy-btn" onclick="copyKey('{{ key }}')">
<i class="fas fa-copy"></i>
复制
</button>
<div class="key-actions">
<button class="verify-btn" onclick="verifyKey('{{ key }}', this)">
<i class="fas fa-check-circle"></i>
验证
</button>
<button class="copy-btn" onclick="copyKey('{{ key }}')">
<i class="fas fa-copy"></i>
复制
</button>
</div>
</li>
{% endfor %}
</ul>
</ul>
</div>
</div>
<div class="key-list">
<h2>
<h2 onclick="toggleSection(this, 'invalidKeys')">
<span>
<i class="fas fa-chevron-down toggle-icon"></i>
<i class="fas fa-times-circle" style="color: #e74c3c;"></i>
无效密钥
</span>
<button class="copy-btn" onclick="copyKeys('invalid')">
<button class="copy-btn" onclick="event.stopPropagation(); copyKeys('invalid')">
<i class="fas fa-copy"></i>
批量复制
</button>
</h2>
<ul id="invalidKeys">
<div class="key-content">
<ul id="invalidKeys">
{% for key, fail_count in invalid_keys.items() %}
<li>
<div class="key-info">
@@ -250,81 +87,42 @@
失败: {{ fail_count }}
</span>
</div>
<button class="copy-btn" onclick="copyKey('{{ key }}')">
<i class="fas fa-copy"></i>
复制
</button>
<div class="key-actions">
<button class="verify-btn" onclick="verifyKey('{{ key }}', this)">
<i class="fas fa-check-circle"></i>
验证
</button>
<button class="copy-btn" onclick="copyKey('{{ key }}')">
<i class="fas fa-copy"></i>
复制
</button>
</div>
</li>
{% endfor %}
</ul>
</ul>
</div>
</div>
<div class="total">
<i class="fas fa-key"></i> 总密钥数:{{ total }}
</div>
<div id="copyStatus"></div>
</div>
<script>
function copyToClipboard(text) {
if (navigator.clipboard && navigator.clipboard.writeText) {
return navigator.clipboard.writeText(text);
} else {
return new Promise((resolve, reject) => {
const textArea = document.createElement("textarea");
textArea.value = text;
textArea.style.position = "fixed";
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
const successful = document.execCommand('copy');
document.body.removeChild(textArea);
if (successful) {
resolve();
} else {
reject(new Error('复制失败'));
}
} catch (err) {
document.body.removeChild(textArea);
reject(err);
}
});
}
}
<div class="scroll-buttons">
<button class="scroll-btn" onclick="scrollToTop()" title="回到顶部">
<i class="fas fa-chevron-up"></i>
</button>
<button class="scroll-btn" onclick="scrollToBottom()" title="滚动到底部">
<i class="fas fa-chevron-down"></i>
</button>
</div>
function copyKeys(type) {
const keys = Array.from(document.querySelectorAll(`#${type}Keys .key-text`)).map(span => span.textContent.trim());
const jsonKeys = JSON.stringify(keys);
copyToClipboard(jsonKeys)
.then(() => {
showCopyStatus(`已成功复制${type === 'valid' ? '有效' : '无效'}密钥到剪贴板`);
})
.catch((err) => {
console.error('无法复制文本: ', err);
showCopyStatus('复制失败,请重试');
});
}
<div id="copyStatus"></div>
function copyKey(key) {
copyToClipboard(key)
.then(() => {
showCopyStatus(`已成功复制密钥到剪贴板`);
})
.catch((err) => {
console.error('无法复制文本: ', err);
showCopyStatus('复制失败,请重试');
});
}
function showCopyStatus(message) {
const statusElement = document.getElementById('copyStatus');
statusElement.textContent = message;
statusElement.style.opacity = 1;
setTimeout(() => {
statusElement.style.opacity = 0;
}, 2000);
}
</script>
<div class="copyright">
© <script>document.write(new Date().getFullYear())</script> by <a href="https://linux.do/u/snaily" target="_blank"><img src="https://linux.do/user_avatar/linux.do/snaily/288/306510_2.gif" alt="snaily">snaily</a> |
<a href="https://github.com/snailyp/gemini-balance" target="_blank"><i class="fab fa-github"></i> GitHub</a>
</div>
<script src="/static/js/keys_status.js"></script>
</body>
</html>

9
docker-compose.yml Normal file
View File

@@ -0,0 +1,9 @@
version: '3'
services:
gemini-balance:
build: .
ports:
- "8000:8000"
env_file:
- .env