Compare commits

...

41 Commits

Author SHA1 Message Date
snaily
cb40848c04 chore: 更新版本号至2.1.0 2025-04-26 03:34:06 +08:00
snaily
7098c8755f refactor: 改进调度器启动逻辑并清理日志
- 修改 `key_checker.py` 中的调度器启动逻辑,确保即使实例存在但未运行时也能启动。
- 在 `key_checker.py` 中添加了调度器启动和状态日志。
- 移除了 `application.py` 中数据库断开连接和调度器停止时的冗余关闭日志。
2025-04-26 03:27:13 +08:00
snaily
705d602dee refactor: 集中版本逻辑并添加版本检查API
- 将 `get_current_version` 函数从 `application.py` 移动到 `helpers.py` 以实现更好的代码组织和可重用性。
- 在 `version_routes.py` 中引入新的 API 端点 `/api/version/check`,以提供当前版本、最新可用版本和更新状态。
- 更新了 `base.html`,通过调用新的 API 端点,使用 JavaScript 异步获取和显示版本信息。这取代了以前服务器端渲染版本信息的方式,并增加了定期检查。
- 移除了应用程序启动时(`lifespan` 函数)的自动更新检查,因为版本检查现在由前端通过 API 处理。
- 在 `routes.py` 中注册了新的版本路由。
2025-04-26 03:04:40 +08:00
snaily
cd257a9406 feat(错误日志): 添加排序和删除功能
为错误日志页面增加了按 ID 排序以及单条和批量删除日志的功能。

主要变更:

后端 (Python/FastAPI):
- `services.py`:
    - `get_error_logs`: 添加 `sort_by` 和 `sort_order` 参数以支持排序。
    - 新增 `delete_error_logs`: 实现基于 ID 列表的批量删除。
    - 新增 `delete_error_log_by_id`: 实现基于单个 ID 的删除。
- `error_log_routes.py`:
    - `GET /api/logs/errors`: 添加 `sortBy` 和 `sortOrder` 查询参数以支持前端排序请求。
    - 新增 `DELETE /api/logs/errors`: 处理批量删除请求。
    - 新增 `DELETE /api/logs/errors/{log_id}`: 处理单条删除请求。
- `connection.py`: 移除了不再使用的同步 SQLAlchemy Session 相关代码。

前端 (HTML/JavaScript):
- `error_logs.html`:
    - 调整了搜索/操作区域布局,添加了批量删除按钮。
    - ID 表头增加排序图标和点击事件。
    - 表格行操作列添加了删除按钮。
    - 新增了删除确认模态框。
- `error_logs.js`:
    - 添加了处理 ID 排序点击的逻辑,更新排序状态并重新加载数据。
    - 添加了处理单条和批量删除按钮点击的逻辑。
    - 实现了删除确认模态框的显示/隐藏及确认逻辑。
    - 修改 `loadErrorLogs` 以包含排序参数。
    - 修改 `renderErrorLogs` 以添加行删除按钮和必要的 `data-log-id` 属性。
    - 更新了全选/取消全选逻辑以同步批量删除按钮状态。
2025-04-26 02:39:55 +08:00
snaily
cd54650431 feat(keys): 实现密钥状态页面的客户端分页、搜索与筛选
- 在 keys_status.html 中:
  - 重新设计有效密钥列表头部,添加密钥搜索框、失败次数筛选器和每页显示数量选择器,并优化布局。
  - 为有效和无效密钥列表添加分页控件容器。
  - 更新 CSS 样式以支持新的筛选/分页控件、Grid 布局和改进的响应式设计。
  - 移除内联的 DOMContentLoaded 初始化脚本,相关逻辑已移至 keys_status.js。
  - 为显示/隐藏密钥按钮添加 `title` 属性以提升可访问性。
  - 调整批量操作栏布局,允许换行。
- 在 keys_status.js 中:
  - 修改 `verifyKey` 函数,在验证成功或失败后通过 `showResultModal` 关闭时强制刷新页面。
  - 调整 `verifyKey` 和 `resetKeyFailCount` 中的按钮状态恢复逻辑,以适应页面刷新行为。
  - 清理了部分冗余代码和空行。
2025-04-25 23:56:48 +08:00
snaily
a5602c602e refactor:Enhances key verification and management UI
Refactors bulk key verification for improved error handling and reporting.
The UI is updated to use checkboxes for key selection and batch actions.
Adds detailed verification results modal to display success and failure details.
Improves key filtering, selection and actions for both valid and invalid keys.
Fixes visual glitches with section collapsing/expanding animations.
2025-04-25 20:34:11 +08:00
snaily
dd70fd4c44 fix(verify-keys): 修复无效秘钥的批量验证 2025-04-25 10:38:25 +08:00
snaily
dbe50628b3 feat(error-logs): 增强错误日志功能和UI交互
- 新增错误码搜索功能,支持精确匹配错误码
- 重构复制功能,支持批量选择和复制密钥
- 优化UI布局和交互体验,添加悬停复制按钮
- 重构路由结构,将log_routes.py重命名为error_log_routes.py
2025-04-23 18:31:19 +08:00
snaily
83ed0527d3 chore: 更新版本号至 2.0.11 2025-04-23 01:48:47 +08:00
snaily
ab31f4bb98 fix: 修正字段别名以保持一致性,调整 safetySettings、generationConfig 和 systemInstruction 的命名风格 2025-04-23 01:48:20 +08:00
snaily
734a8c4bc4 chore: 更新版本号至 2.0.10 2025-04-23 01:34:38 +08:00
snaily
fea3af4692 refactor: 优化代码格式,增强可读性;调整类型注解和字段命名风格 2025-04-23 01:33:47 +08:00
snaily
9302cf295e fix: 修复日志格式化器以支持文件名和行号,优化日志输出格式 2025-04-22 18:48:51 +08:00
snaily
b4f040e77a docs: 添加项目支持说明,鼓励用户通过爱发电支持项目 2025-04-22 13:08:42 +08:00
snaily
defabf4355 fix: 更新 SystemInstruction 的 parts 类型为支持 List 和单个字典;更新 base.html 添加支持作者的链接和警告信息 2025-04-22 13:04:32 +08:00
snaily
f3ed3168e4 Update README.md 2025-04-22 01:19:09 +08:00
snaily
01765b1731 refactor: 更新日志格式,增强可读性;移除初始化模块,整合初始化逻辑 2025-04-21 20:54:34 +08:00
snaily
f83f0fa768 chore:清理代码,移除不必要的注释和导入,优化日志记录和错误处理 2025-04-21 13:20:32 +08:00
snaily
a7085964e8 Update README.md 2025-04-21 10:54:25 +08:00
snaily
d3cd2856b7 Update README.md 2025-04-21 10:52:07 +08:00
snaily
353d22cc70 Update README.md 2025-04-21 10:51:51 +08:00
snaily
eb96474c19 Update README.md 2025-04-21 10:40:46 +08:00
snaily
0c48a2d74d Update README.md 2025-04-21 10:40:22 +08:00
snaily
1b23d574a5 feat: Dockerfile 中添加 VERSION 文件复制
将 VERSION 文件复制到 Docker 镜像中,以便在运行时可以访问版本信息。
2025-04-20 12:12:52 +08:00
snaily
ebc5dc571b chore: bump version to 2.0.8 2025-04-20 12:03:28 +08:00
snaily
9a7a1d7c2f feat(日志): 添加数据库日志记录并增强API重试/错误处理
- 为 Gemini 聊天(流式/非流式)、OpenAI 图像聊天(流式/非流式)和 embedding 服务的 API 调用实现全面的数据库日志记录。日志包括请求详情、成功/失败状态、状态码、延迟和错误消息。
- 重构 Gemini 流式聊天服务 (`stream_generate_content`) 以整合使用 `KeyManager` 的重试逻辑,与非流式实现保持一致,包括失败时的 API 密钥切换。
- 增强重试处理器 (`RetryHandler`) 的日志记录,以提高密钥切换和失败场景下的清晰度。
- 确保 `api_key` 正确传递给 OpenAI 图像聊天完成。
- 改进 embedding 服务中的错误处理,区分 `APIStatusError` 和通用异常,并将错误记录到数据库。
- 为 embedding 服务日志添加请求负载截断。
- 修复 Gemini `_build_payload` 中使用正确的 `model` 变量获取 `THINKING_BUDGET_MAP` 的错误。
- 移除 `ImageCreateService` 中未使用的 `paid_key` 类变量。
2025-04-20 12:02:00 +08:00
snaily
c99e090ea9 feat(stats): 添加密钥使用详情统计功能
新增功能允许用户在 Keys 状态页面点击“详情”按钮,查看指定 API 密钥在过去 24 小时内按模型分类的请求次数统计。

主要变更包括:

后端:
- 新增 `app/router/stats_routes.py`,包含 `/api/key-usage-details/{key}` API 端点用于获取密钥使用详情。
- 重构 `app/service/stats_service.py`,将统计相关函数封装到 `StatsService` 类中,并添加 `get_key_usage_details_last_24h` 方法。
- 在 `app/router/routes.py` 中注册新的 `stats_routes`,并更新对 `stats_service` 的调用方式以使用类实例。
- 更新 `app/log/logger.py` 添加 `get_scheduler_routes` 日志记录器,并在 `app/router/scheduler_routes.py` 中使用它。

前端:
- 在 `app/templates/keys_status.html` 中为每个有效和无效密钥列表项添加“详情”按钮。
- 在 `app/templates/keys_status.html` 中添加用于显示密钥使用详情的模态框 HTML 结构。
- 在 `app/static/js/keys_status.js` 中添加 JavaScript 函数 (`showKeyUsageDetails`, `closeKeyUsageDetailsModal`, `renderKeyUsageDetails`) 来处理按钮点击事件、调用后端 API、控制模态框显示/隐藏以及渲染获取到的统计数据。
2025-04-20 01:41:22 +08:00
snaily
eb311de0c2 feat: 添加思考模型配置并修复统计状态处理
- 在 README.md 中添加 THINKING_MODELS 和 THINKING_BUDGET_MAP 环境变量文档。
- 修复 stats_service.py 中的 get_api_call_details 函数,以正确处理 status_code 为 None 的情况,确保状态判断的健壮性。
2025-04-20 01:10:51 +08:00
snaily
c254077a66 feat(update): 实现应用内更新检查和版本显示
- 新增 `VERSION` 文件用于跟踪当前应用版本 (当前为 2.0.7)。
- 创建 `app/service/update/update_service.py` 服务,用于:
    - 从 `VERSION` 文件读取当前版本。
    - 通过 GitHub API 获取指定仓库 (`GITHUB_REPO_OWNER`/`GITHUB_REPO_NAME`) 的最新 Release Tag。
    - 使用 `packaging` 库比较版本,判断是否有可用更新。
- 在应用启动 (`app/core/application.py`) 时异步调用更新检查服务。
- 将当前版本和更新检查结果(是否可用、最新版本号、错误信息)存储在 `app.state.update_info` 中,供模板使用。
- 在基础模板 (`app/templates/base.html`) 的页脚动态显示当前版本。
- 如果检测到新版本,在页脚显示更新提示和指向最新 Release 的链接。
- 如果更新检查失败,在页脚显示错误提示。
- 在 `app/config/config.py` 中添加 `GITHUB_REPO_OWNER` 和 `GITHUB_REPO_NAME` 配置项,并提供默认值。
- 在 `requirements.txt` 中添加 `packaging` 依赖。
- 添加 `update_service` 专用的 logger (`app/log/logger.py`)。
- 改进配置编辑器 (`config_editor.js`, `config_editor.html`):
    - 限制预算输入框 (`budget_map`) 的值在 0 到 24576 之间。
    - 移除了预算映射项的删除按钮(预算项应随模型列表自动增删)。
    - 更新了预算输入的提示文本。
2025-04-19 23:45:33 +08:00
snaily
ef4a528611 feat(config, chat, ui): 添加思考模型及预算管理功能
引入了思考模型 (THINKING_MODELS) 和相应的预算映射 (THINKING_BUDGET_MAP) 的概念,允许在配置中指定用于特定内部处理流程(如“思考过程”)的模型及其 token 预算。

主要变更包括:

后端 (Python):
- 在 `Settings` 中添加了 `THINKING_MODELS` (List[str]) 和 `THINKING_BUDGET_MAP` (Dict[str, float]) 配置项。
- 增强了 `config._parse_db_value` 函数,以正确解析来自数据库或环境变量的列表和字典字符串(包括处理单引号和提供更详细的日志)。
- 更新了相关服务(如 `GeminiChatService`, `ModelService`, `ConfigService`)以识别和利用这些新配置。
- 调整了中间件和路由以适应可能的逻辑变更。

前端 (HTML/JavaScript):
- 在配置编辑器 (`config_editor.html`, `config_editor.js`) 中添加了新的 UI 部分来管理思考模型列表和预算映射。
- 实现了动态添加/删除思考模型的功能,并自动关联/解除关联对应的预算映射条目。
- 预算映射中的模型名称(键)是只读的,自动从思考模型列表同步;预算值(值)是可编辑的数字输入。
- 更新了表单数据的加载 (`populateForm`) 和收集 (`collectFormData`) 逻辑,以正确处理新的列表和映射类型。
- 移除了手动添加预算映射的按钮,改为自动关联。
- 改进了数组和映射项的 DOM 操作逻辑,包括使用 UUID 来关联模型和预算项。
2025-04-19 19:21:06 +08:00
snaily
f593d97381 Merge pull request #49 from toddyoe/main
chore: typo fixed for missing param
2025-04-18 23:43:54 +08:00
Toddy
053ef631c4 chore: typo fixed for missing param 2025-04-18 15:38:16 +00:00
snaily
075d20c62d chore: 已在 README.md 文件中添加了 LOG_LEVEL 环境变量的说明。 2025-04-18 22:03:23 +08:00
snaily
0768aed179 Merge branch 'main' of https://github.com/snailyp/gemini-balance 2025-04-18 21:54:04 +08:00
snaily
c2eac24175 feat: 添加可配置的日志级别
引入可配置的日志级别功能,允许用户通过配置编辑器和 `.env` 文件设置所需的日志详细程度。

主要变化:
- 在 `.env.example` 和 `app/config/config.py` 中添加了 `LOG_LEVEL` 设置。
- 修改了 `app/log/logger.py`,使其从设置中读取日志级别,并实现了对现有 logger 进行动态日志级别更新的功能。
- 更新了 `app/router/config_routes.py`,以便在保存配置后触发日志级别更新。
- 在 `app/templates/config_editor.html` 和 `app/static/js/config_editor.js` 中添加了日志级别选择的 UI 元素。
- 将 `app/router/gemini_routes.py` 和 `app/router/openai_routes.py` 中的一些日志调用从 `info` 调整为 `debug`,以降低默认输出的详细程度。
- 在 `README.md` 的“特别鸣谢”部分添加了 🎉 表情符号。
2025-04-18 21:53:54 +08:00
snaily
1c6dabcea7 更新 docker-compose.yml 2025-04-17 23:13:41 +08:00
snaily
76937aa24f chore:
增强文档: 在 README.md 文件中,新增了“特别鸣谢”部分,以感谢 PicGo、SM.MS 和 CloudFlare-ImgBed 为本项目提供的图床服务。同时,添加了“ Star History”部分,用于展示项目的 Star 历史,增强了文档的信息量和项目展示效果。
配置更正: 在配置编辑器 config_editor.html 中,更正了 Cloudflare 图床的 provider 名称。将原先的 cloudflare 更正为 cloudflare_imgbed,确保配置项名称的准确性和一致性。
2025-04-17 17:42:42 +08:00
snaily
b96ce8f15a Merge branch 'main' of https://github.com/snailyp/gemini-balance 2025-04-17 09:26:45 +08:00
snaily
87d60117c5 refactor:将 config_editor 页面中的提示(notification)样式完全统一为与 keys_status 页面一致的黑色半透明风格,无论提示类型均不会再出现绿色等色块。 2025-04-17 09:19:41 +08:00
snaily
a53a30fd38 Merge pull request #44 from yanhao98/0415-docker-compose 2025-04-16 13:57:12 +08:00
严浩
98e7fb62d5 feat(docker): 更新 MySQL 服务配置,添加健康检查 2025-04-16 10:19:40 +08:00
45 changed files with 3478 additions and 1242 deletions

View File

@@ -1,13 +1,15 @@
# MySQL数据库配置
MYSQL_HOST=
MYSQL_PORT=
MYSQL_USER=
MYSQL_PASSWORD=
MYSQL_HOST=gemini-balance-mysql
MYSQL_PORT=3306
MYSQL_USER=gemini
MYSQL_PASSWORD=change_me
MYSQL_DATABASE=default_db
API_KEYS=["AIzaSyxxxxxxxxxxxxxxxxxxx","AIzaSyxxxxxxxxxxxxxxxxxxx"]
ALLOWED_TOKENS=["sk-123456"]
# AUTH_TOKEN=sk-123456
TEST_MODEL=gemini-1.5-flash
THINKING_MODELS=["gemini-2.5-flash-preview-04-17"]
THINKING_BUDGET_MAP={"gemini-2.5-flash-preview-04-17": 4000}
IMAGE_MODELS=["gemini-2.0-flash-exp"]
SEARCH_MODELS=["gemini-2.0-flash-exp","gemini-2.0-pro-exp"]
FILTERED_MODELS=["gemini-1.0-pro-vision-latest", "gemini-pro-vision", "chat-bison-001", "text-bison-001", "embedding-gecko-001"]
@@ -38,3 +40,7 @@ STREAM_SHORT_TEXT_THRESHOLD=10
STREAM_LONG_TEXT_THRESHOLD=50
STREAM_CHUNK_SIZE=5
##########################################################################
######################### 日志配置 #######################################
# 日志级别 (debug, info, warning, error, critical),默认为 info
LOG_LEVEL=info
##########################################################################

View File

@@ -4,6 +4,7 @@ WORKDIR /app
# 复制所需文件到容器中
COPY ./requirements.txt /app
COPY ./VERSION /app
RUN pip install --no-cache-dir -r requirements.txt
COPY ./app /app/app

View File

@@ -2,6 +2,8 @@
> ⚠️ 本项目采用 CC BY-NC 4.0(署名-非商业性使用)协议,禁止任何形式的商业倒卖服务,详见 LICENSE 文件。
> 本人从未在各个平台售卖服务,如有遇到售卖此服务者,那一定是倒卖狗,大家切记不要上当受骗。
[![Python](https://img.shields.io/badge/Python-3.9%2B-blue.svg)](https://www.python.org/)
[![FastAPI](https://img.shields.io/badge/FastAPI-0.100%2B-green.svg)](https://fastapi.tiangolo.com/)
[![Uvicorn](https://img.shields.io/badge/Uvicorn-running-purple.svg)](https://www.uvicorn.org/)
@@ -156,19 +158,22 @@ app/
| `TOOLS_CODE_EXECUTION_ENABLED` | 可选,是否启用代码执行工具 | `false` |
| `SHOW_SEARCH_LINK` | 可选,是否在响应中显示搜索结果链接 | `true` |
| `SHOW_THINKING_PROCESS` | 可选,是否显示模型思考过程 | `true` |
| `THINKING_MODELS` | 可选,支持思考功能的模型列表 | `[]` |
| `THINKING_BUDGET_MAP` | 可选,思考功能预算映射 (模型名:预算值) | `{}` |
| `BASE_URL` | 可选Gemini API 基础 URL默认无需修改 | `https://generativelanguage.googleapis.com/v1beta` |
| `MAX_FAILURES` | 可选允许单个key失败的次数 | `3` |
| `MAX_RETRIES` | 可选API 请求失败时的最大重试次数 | `3` |
| `CHECK_INTERVAL_HOURS` | 可选,检查禁用 Key 是否恢复的时间间隔 (小时) | `1` |
| `TIMEZONE` | 可选,应用程序使用的时区 | `Asia/Shanghai` |
| `TIME_OUT` | 可选,请求超时时间 (秒) | `300` |
| `LOG_LEVEL` | 可选,日志级别,例如 DEBUG, INFO, WARNING, ERROR, CRITICAL | `INFO` |
| **图像生成相关** | | |
| `PAID_KEY` | 可选付费版API Key用于图片生成等高级功能 | `your-paid-api-key` |
| `CREATE_IMAGE_MODEL` | 可选,图片生成模型 | `imagen-3.0-generate-002` |
| `UPLOAD_PROVIDER` | 可选,图片上传提供商: `smms`, `picgo`, `cloudflare_imgbed` | `smms` |
| `SMMS_SECRET_TOKEN` | 可选SM.MS图床的API Token | `your-smms-token` |
| `PICGO_API_KEY` | 可选PicoGo图床的API Key | `your-picogo-apikey` |
| `CLOUDFLARE_IMGBED_URL` | 可选CloudFlare 图床上传地址 | `https://xxxxxxx.pages.dev/upload` |
| `PICGO_API_KEY` | 可选,[PicoGo](https://www.picgo.net/)图床的API Key | `your-picogo-apikey` |
| `CLOUDFLARE_IMGBED_URL` | 可选,[CloudFlare](https://github.com/MarSeventh/CloudFlare-ImgBed) 图床上传地址 | `https://xxxxxxx.pages.dev/upload` |
| `CLOUDFLARE_IMGBED_AUTH_CODE`| 可选CloudFlare图床的鉴权key | `your-cloudflare-imgber-auth-code` |
| **流式优化器相关** | | |
| `STREAM_OPTIMIZER_ENABLED` | 可选,是否启用流式输出优化 | `false` |
@@ -199,16 +204,32 @@ app/
欢迎提交 Pull Request 或 Issue。
## 🎉 特别鸣谢
特别鸣谢以下项目和平台为本项目提供图床服务:
* [PicGo](https://www.picgo.net/)
* [SM.MS](https://smms.app/)
* [CloudFlare-ImgBed](https://github.com/MarSeventh/CloudFlare-ImgBed) 开源项目
## 🙏 感谢贡献者
感谢所有为本项目做出贡献的开发者!
[![Contributors](https://contrib.rocks/image?repo=snailyp/gemini-balance)](https://github.com/snailyp/gemini-balance/graphs/contributors)
## ⭐ Star History
[![Star History Chart](https://api.star-history.com/svg?repos=snailyp/gemini-balance&type=Date)](https://star-history.com/#snailyp/gemini-balance&Date)
## 💖 友情项目
* **[OneLine](https://github.com/chengtx809/OneLine)** by [chengtx809](https://github.com/chengtx809) - OneLine一线AI驱动的热点事件时间轴生成工具
## 🎁 项目支持
如果你觉得这个项目对你有帮助,可以考虑通过 [爱发电](https://afdian.com/a/snaily) 支持我。
## 许可证
本项目采用 CC BY-NC 4.0(署名-非商业性使用)协议,禁止任何形式的商业倒卖服务,详见 LICENSE 文件。

1
VERSION Normal file
View File

@@ -0,0 +1 @@
2.1.0

View File

@@ -10,17 +10,10 @@ from pydantic_settings import BaseSettings
from sqlalchemy import insert, update, select
from app.core.constants import API_VERSION, DEFAULT_CREATE_IMAGE_MODEL, DEFAULT_FILTER_MODELS, DEFAULT_MODEL, DEFAULT_STREAM_CHUNK_SIZE, DEFAULT_STREAM_LONG_TEXT_THRESHOLD, DEFAULT_STREAM_MAX_DELAY, DEFAULT_STREAM_MIN_DELAY, DEFAULT_STREAM_SHORT_TEXT_THRESHOLD, DEFAULT_TIMEOUT, MAX_RETRIES
from app.log.logger import get_config_logger
# 延迟导入以避免循环依赖,仅在 sync_initial_settings 中使用
# from app.database.connection import database
# from app.database.models import Settings as SettingsModel
# from app.database.services import get_all_settings # get_all_settings 可能不适合启动时调用,直接查询
logger = get_config_logger()
from app.log.logger import Logger
class Settings(BaseSettings):
"""应用程序配置"""
# 数据库配置
MYSQL_HOST: str
MYSQL_PORT: int
@@ -45,6 +38,8 @@ class Settings(BaseSettings):
TOOLS_CODE_EXECUTION_ENABLED: bool = False
SHOW_SEARCH_LINK: bool = True
SHOW_THINKING_PROCESS: bool = True
THINKING_MODELS: List[str] = [] # 新增:用于思考过程的模型列表
THINKING_BUDGET_MAP: Dict[str, float] = {} # 新增:模型对应的预算映射
# 图像生成相关配置
PAID_KEY: str = ""
@@ -66,6 +61,13 @@ class Settings(BaseSettings):
# 调度器配置
CHECK_INTERVAL_HOURS: int = 1 # 默认检查间隔为1小时
TIMEZONE: str = "Asia/Shanghai" # 默认时区
# github
GITHUB_REPO_OWNER: str = "snailyp"
GITHUB_REPO_NAME: str = "gemini-balance"
# 日志配置
LOG_LEVEL: str = "INFO" # 默认日志级别
def __init__(self, **kwargs):
super().__init__(**kwargs)
@@ -78,26 +80,57 @@ settings = Settings()
def _parse_db_value(key: str, db_value: str, target_type: Type) -> Any:
"""尝试将数据库字符串值解析为目标 Python 类型"""
from app.log.logger import get_config_logger # 函数内导入
logger = get_config_logger() # 函数内初始化
try:
# 处理 List[str]
if target_type == List[str]:
# 尝试解析 JSON 列表,如果失败则按逗号分割
try:
parsed = json.loads(db_value)
if isinstance(parsed, list):
return [str(item) for item in parsed]
except json.JSONDecodeError:
# 回退到逗号分割,去除空格
return [item.strip() for item in db_value.split(',') if item.strip()]
# 如果解析后不是列表或解析失败,返回空列表或进行其他处理
logger.warning(f"Could not parse '{db_value}' as List[str] for key '{key}', falling back to comma split or empty list.")
return [item.strip() for item in db_value.split(',') if item.strip()] # Fallback
return [item.strip() for item in db_value.split(',') if item.strip()]
# 处理 Dict[str, float]
elif target_type == Dict[str, float]:
parsed_dict = {}
try:
# First attempt: standard JSON parsing
parsed = json.loads(db_value)
if isinstance(parsed, dict):
parsed_dict = {str(k): float(v) for k, v in parsed.items()}
else:
logger.warning(f"Parsed DB value for key '{key}' is not a dictionary type. Value: {db_value}")
except (json.JSONDecodeError, ValueError, TypeError) as e1:
# Second attempt: try replacing single quotes if JSONDecodeError occurred
if isinstance(e1, json.JSONDecodeError) and "'" in db_value:
logger.warning(f"Failed initial JSON parse for key '{key}'. Attempting to replace single quotes. Error: {e1}")
try:
corrected_db_value = db_value.replace("'", '"')
parsed = json.loads(corrected_db_value)
if isinstance(parsed, dict):
parsed_dict = {str(k): float(v) for k, v in parsed.items()}
else:
logger.warning(f"Parsed DB value (after quote replacement) for key '{key}' is not a dictionary type. Value: {corrected_db_value}")
except (json.JSONDecodeError, ValueError, TypeError) as e2:
logger.error(f"Could not parse '{db_value}' as Dict[str, float] for key '{key}' even after replacing quotes: {e2}. Returning empty dict.")
else:
# Log other errors (ValueError, TypeError) or JSON errors without single quotes
logger.error(f"Could not parse '{db_value}' as Dict[str, float] for key '{key}': {e1}. Returning empty dict.")
return parsed_dict # Return the parsed dict or an empty one if all attempts fail
# 处理 bool
elif target_type == bool:
return db_value.lower() in ('true', '1', 'yes', 'on')
# 处理 int
elif target_type == int:
return int(db_value)
# 处理 float
elif target_type == float:
return float(db_value)
else: # 默认为 str 或其他 pydantic 能处理的类型
# 默认为 str 或其他 pydantic 能直接处理的类型
else:
return db_value
except (ValueError, TypeError, json.JSONDecodeError) as e:
logger.warning(f"Failed to parse db_value '{db_value}' for key '{key}' as type {target_type}: {e}. Using original string value.")
@@ -110,6 +143,8 @@ async def sync_initial_settings():
2. 将数据库设置合并到内存 settings (数据库优先)。
3. 将最终的内存 settings 同步回数据库。
"""
from app.log.logger import get_config_logger # 函数内导入
logger = get_config_logger() # 函数内初始化
# 延迟导入以避免循环依赖和确保数据库连接已初始化
from app.database.connection import database
from app.database.models import Settings as SettingsModel
@@ -153,20 +188,18 @@ async def sync_initial_settings():
# 比较解析后的值和内存中的值
# 注意:对于列表等复杂类型,直接比较可能不够健壮,但这里简化处理
if parsed_db_value != memory_value:
# 检查类型是否匹配,以防解析函数返回了不兼容的类型
# 优先处理 List[str] 类型,避免直接对泛型使用 isinstance
if target_type == List[str]:
if isinstance(parsed_db_value, list):
# 可以选择性地添加对列表元素的检查,但这里保持简化
setattr(settings, key, parsed_db_value)
logger.info(f"Updated setting '{key}' in memory from database value (List[str]).")
updated_in_memory = True
else:
logger.warning(f"Parsed DB value type mismatch for key '{key}'. Expected List[str], got {type(parsed_db_value)}. Skipping update.")
# 对于其他非泛型类型,使用常规的 isinstance 检查
elif isinstance(parsed_db_value, target_type):
# 检查类型是否匹配,以防解析函数返回了不兼容的类型
type_match = False
if target_type == List[str] and isinstance(parsed_db_value, list):
type_match = True
elif target_type == Dict[str, float] and isinstance(parsed_db_value, dict):
type_match = True
elif target_type not in (List[str], Dict[str, float]) and isinstance(parsed_db_value, target_type):
type_match = True
if type_match:
setattr(settings, key, parsed_db_value)
logger.info(f"Updated setting '{key}' in memory from database value.")
logger.debug(f"Updated setting '{key}' in memory from database value ({target_type}).")
updated_in_memory = True
else:
logger.warning(f"Parsed DB value type mismatch for key '{key}'. Expected {target_type}, got {type(parsed_db_value)}. Skipping update.")
@@ -197,10 +230,12 @@ async def sync_initial_settings():
for key, value in final_memory_settings.items():
# 序列化值为字符串或 JSON 字符串
if isinstance(value, list):
db_value = json.dumps(value)
if isinstance(value, (list, dict)): # 处理列表和字典
db_value = json.dumps(value, ensure_ascii=False) # 使用 ensure_ascii=False 以支持非 ASCII 字符
elif isinstance(value, bool):
db_value = str(value).lower()
elif value is None: # 处理 None 值
db_value = "" # 或者根据需要设为 NULL 或其他标记
else:
db_value = str(value)
@@ -258,15 +293,15 @@ async def sync_initial_settings():
else:
logger.info("No setting changes detected between memory and database during initial sync.")
# 刷新日志等级
Logger.update_log_levels(final_memory_settings.get("LOG_LEVEL"))
except Exception as e:
logger.error(f"An unexpected error occurred during initial settings sync: {e}")
finally:
if database.is_connected:
try:
# Don't disconnect if it's managed elsewhere (e.g., FastAPI lifespan)
# await database.disconnect()
# logger.info("Database connection closed after initial sync.")
pass # Assume connection lifecycle is managed by the application lifespan
pass
except Exception as e:
logger.error(f"Error disconnecting database after initial sync: {e}")

View File

@@ -1,9 +1,8 @@
"""
应用程序工厂模块负责创建和配置FastAPI应用程序实例
"""
from contextlib import asynccontextmanager
from pathlib import Path # Add pathlib import
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from app.config.config import settings, sync_initial_settings
from app.log.logger import get_application_logger
@@ -11,56 +10,114 @@ from app.middleware.middleware import setup_middlewares
from app.exception.exceptions import setup_exception_handlers
from app.router.routes import setup_routers
from app.service.key.key_manager import get_key_manager_instance
from app.core.initialization import initialize_app
from app.database.connection import connect_to_db, disconnect_from_db
from app.utils.helpers import get_current_version # Import from helpers
from app.database.initialization import initialize_database
from app.scheduler.key_checker import start_scheduler, stop_scheduler # 导入调度器函数
from app.scheduler.key_checker import start_scheduler, stop_scheduler
from app.service.update.update_service import check_for_updates
logger = get_application_logger()
# Define project paths using pathlib
# Assuming this file is at app/core/application.py
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
# VERSION_FILE_PATH = PROJECT_ROOT / "VERSION" # Removed: Defined in helpers.py
STATIC_DIR = PROJECT_ROOT / "app" / "static"
TEMPLATES_DIR = PROJECT_ROOT / "app" / "templates"
# Removed _get_current_version function definition, moved to helpers.py
# 初始化模板引擎,并添加全局变量
templates = Jinja2Templates(directory="app/templates")
# 定义一个函数来更新模板全局变量
def update_template_globals(app: FastAPI, update_info: dict):
# Jinja2Templates 实例没有直接更新全局变量的方法
# 我们需要在请求上下文中传递这些变量,或者修改 Jinja 环境
# 更简单的方法是将其存储在 app.state 中,并在渲染时传递
app.state.update_info = update_info
logger.info(f"Update info stored in app.state: {update_info}")
# --- Helper functions for lifespan ---
async def _setup_database_and_config(app_settings):
"""Initializes database, syncs settings, and initializes KeyManager."""
initialize_database()
logger.info("Database initialized successfully")
await connect_to_db()
await sync_initial_settings()
# Initialize KeyManager using potentially updated settings
await get_key_manager_instance(app_settings.API_KEYS)
logger.info("Database, config sync, and KeyManager initialized successfully")
async def _shutdown_database():
"""Disconnects from the database."""
await disconnect_from_db()
def _start_scheduler():
"""Starts the background scheduler."""
try:
start_scheduler()
logger.info("Scheduler started successfully.")
except Exception as e:
logger.error(f"Failed to start scheduler: {e}")
def _stop_scheduler():
"""Stops the background scheduler."""
stop_scheduler()
async def _perform_update_check(app: FastAPI):
"""Checks for updates and stores the info in app.state."""
update_available, latest_version, error_message = await check_for_updates()
current_version = get_current_version() # Use imported function
update_info = {
"update_available": update_available,
"latest_version": latest_version,
"error_message": error_message,
"current_version": current_version
}
# Ensure app.state exists and store update info
if not hasattr(app, "state"):
from starlette.datastructures import State
app.state = State()
app.state.update_info = update_info
logger.info(f"Update check completed. Info: {update_info}")
# --- Application Lifespan ---
@asynccontextmanager
async def lifespan(app: FastAPI):
"""
应用程序生命周期管理器
Manages the application startup and shutdown events.
Args:
app: FastAPI应用实例
"""
# 启动事件
# Startup events
logger.info("Application starting up...")
try:
# 初始化数据库
initialize_database()
logger.info("Database initialized successfully")
# 连接到数据库
await connect_to_db()
# 同步初始配置DB优先然后同步回DB
await sync_initial_settings()
# Setup database, config, and KeyManager
await _setup_database_and_config(settings) # Pass settings object
# Perform update check after core components are ready
# await _perform_update_check(app) # Removed: Version check moved to frontend API call
# Start the scheduler
_start_scheduler()
# 初始化KeyManager (使用可能已从DB更新的settings)
await get_key_manager_instance(settings.API_KEYS)
logger.info("KeyManager initialized successfully")
except Exception as e:
logger.error(f"Failed to initialize application: {str(e)}")
raise
logger.critical(f"Critical error during application startup: {str(e)}", exc_info=True)
# Depending on the severity, you might want to prevent the app from fully starting
# For now, we log critically and let it yield, potentially in a broken state.
# Consider adding more robust error handling here if startup failures should halt the app.
# 启动调度器
start_scheduler()
logger.info("Scheduler started successfully.")
yield # Application runs
yield # 应用程序运行期间
# 关闭事件
# Shutdown events
logger.info("Application shutting down...")
# 停止调度器
stop_scheduler()
logger.info("Scheduler stopped.")
# 断开数据库连接
await disconnect_from_db()
_stop_scheduler()
await _shutdown_database()
def create_app() -> FastAPI:
"""
@@ -69,20 +126,33 @@ def create_app() -> FastAPI:
Returns:
FastAPI: 配置好的FastAPI应用程序实例
"""
# 初始化应用程序
initialize_app()
# Removed: initialize_app() call
# 创建FastAPI应用
# Read version from file for consistency
current_version = get_current_version() # Use imported function
app = FastAPI(
title="Gemini Balance API",
description="Gemini API代理服务支持负载均衡和密钥管理",
version="1.0.0",
version=current_version,
lifespan=lifespan
)
# Initialize app.state early to ensure it exists before lifespan potentially uses it
if not hasattr(app, "state"):
from starlette.datastructures import State
app.state = State()
# Set a default/initial state for update_info
app.state.update_info = {
"update_available": False,
"latest_version": None,
"error_message": "Initializing...",
"current_version": current_version # Use version read earlier
}
# 配置静态文件
app.mount("/static", StaticFiles(directory="app/static"), name="static")
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
# 配置中间件
setup_middlewares(app)

View File

@@ -1,40 +0,0 @@
"""
应用程序初始化模块
"""
from pathlib import Path
from typing import List
from app.log.logger import get_initialization_logger
logger = get_initialization_logger()
def ensure_directories_exist(directories: List[str]) -> None:
"""
确保指定的目录存在,如果不存在则创建
Args:
directories: 要确保存在的目录列表
"""
for directory in directories:
try:
Path(directory).mkdir(parents=True, exist_ok=True)
logger.info(f"Ensured directory exists: {directory}")
except Exception as e:
logger.error(f"Failed to create directory {directory}: {str(e)}")
def initialize_app() -> None:
"""
初始化应用程序,确保所需的目录和文件都存在
"""
# 确保必要的目录存在
required_directories = [
"app/static/css",
"app/static/js",
"app/static/icons",
"app/templates",
]
ensure_directories_exist(required_directories)
logger.info("core initialization completed")

View File

@@ -3,6 +3,7 @@
"""
from databases import Database
from sqlalchemy import create_engine, MetaData
# from sqlalchemy.orm import sessionmaker # 不再需要
from sqlalchemy.ext.declarative import declarative_base
from app.config.config import settings
@@ -31,7 +32,9 @@ Base = declarative_base(metadata=metadata)
# databases 库会自动处理连接失效后的重连尝试。
database = Database(DATABASE_URL, min_size=5, max_size=20, pool_recycle=1800) # Reduced recycle time to 30 mins
# 移除了 SessionLocal 和 get_db 函数
# --- Async connection functions for lifespan/async routes ---
async def connect_to_db():
"""
连接到数据库

View File

@@ -1,14 +1,12 @@
"""
数据库服务模块
"""
from typing import List, Optional, Dict, Any, Union
from datetime import datetime
from sqlalchemy import func, desc, asc, select, insert, update, delete
import json
from typing import Dict, List, Optional, Any, Union
from datetime import datetime # Keep this import
from sqlalchemy import select, insert, update, func
from app.database.connection import database
from app.database.models import Settings, ErrorLog, RequestLog # Import RequestLog
from app.database.models import Settings, ErrorLog, RequestLog
from app.log.logger import get_database_logger
logger = get_database_logger()
@@ -157,19 +155,25 @@ async def get_error_logs(
offset: int = 0,
key_search: Optional[str] = None,
error_search: Optional[str] = None,
error_code_search: Optional[str] = None,
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None
end_date: Optional[datetime] = None,
sort_by: str = 'id', # 新增排序字段
sort_order: str = 'desc' # 新增排序顺序 ('asc' or 'desc')
) -> List[Dict[str, Any]]:
"""
获取错误日志,支持搜索日期过滤
获取错误日志,支持搜索日期过滤和排序
Args:
limit (int): 限制数量
offset (int): 偏移量
key_search (Optional[str]): Gemini密钥搜索词 (模糊匹配)
error_search (Optional[str]): 错误类型或日志内容搜索词 (模糊匹配)
error_code_search (Optional[str]): 错误码搜索词 (精确匹配)
start_date (Optional[datetime]): 开始日期时间
end_date (Optional[datetime]): 结束日期时间
sort_by (str): 排序字段 (例如 'id', 'request_time')
sort_order (str): 排序顺序 ('asc' or 'desc')
Returns:
List[Dict[str, Any]]: 错误日志列表
@@ -198,10 +202,28 @@ async def get_error_logs(
if end_date:
# Use the datetime object directly for comparison
query = query.where(ErrorLog.request_time < end_date)
if error_code_search:
try:
# Attempt to convert search string to integer for exact match
error_code_int = int(error_code_search)
query = query.where(ErrorLog.error_code == error_code_int)
except ValueError:
# If conversion fails, log a warning and potentially skip this filter
# or handle as needed (e.g., return no results for invalid code format)
logger.warning(f"Invalid format for error_code_search: '{error_code_search}'. Expected an integer. Skipping error code filter.")
# Optionally, force no results if the format is invalid:
# query = query.where(False) # This ensures no rows are returned
# 添加排序逻辑
sort_column = getattr(ErrorLog, sort_by, ErrorLog.id) # 获取排序字段,默认为 id
if sort_order.lower() == 'asc':
query = query.order_by(asc(sort_column))
else:
query = query.order_by(desc(sort_column))
# Apply limit and offset
query = query.limit(limit).offset(offset)
# Apply ordering, limit, and offset
query = query.order_by(ErrorLog.id.desc()).limit(limit).offset(offset)
result = await database.fetch_all(query)
return [dict(row) for row in result]
except Exception as e:
@@ -212,6 +234,7 @@ async def get_error_logs(
async def get_error_logs_count(
key_search: Optional[str] = None,
error_search: Optional[str] = None,
error_code_search: Optional[str] = None, # Added error code search
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None
) -> int:
@@ -221,6 +244,7 @@ async def get_error_logs_count(
Args:
key_search (Optional[str]): Gemini密钥搜索词 (模糊匹配)
error_search (Optional[str]): 错误类型或日志内容搜索词 (模糊匹配)
error_code_search (Optional[str]): 错误码搜索词 (精确匹配)
start_date (Optional[datetime]): 开始日期时间
end_date (Optional[datetime]): 结束日期时间
@@ -243,6 +267,16 @@ async def get_error_logs_count(
if end_date:
# Use the datetime object directly for comparison
query = query.where(ErrorLog.request_time < end_date)
if error_code_search:
try:
# Attempt to convert search string to integer for exact match
error_code_int = int(error_code_search)
query = query.where(ErrorLog.error_code == error_code_int)
except ValueError:
# If conversion fails, log a warning and potentially skip this filter
logger.warning(f"Invalid format for error_code_search in count: '{error_code_search}'. Expected an integer. Skipping error code filter.")
# Optionally, force count to 0 if the format is invalid:
# return 0 # Or query = query.where(False) before fetching
count_result = await database.fetch_one(query)
return count_result[0] if count_result else 0
@@ -281,6 +315,68 @@ async def get_error_log_details(log_id: int) -> Optional[Dict[str, Any]]:
logger.exception(f"Failed to get error log details for ID {log_id}: {str(e)}")
raise
# --- 异步删除函数 (使用 databases 库) ---
async def delete_error_logs_by_ids(log_ids: List[int]) -> int:
"""
根据提供的 ID 列表批量删除错误日志 (异步)。
Args:
log_ids: 要删除的错误日志 ID 列表。
Returns:
int: 实际删除的日志数量。
"""
if not log_ids:
return 0
try:
# 使用 databases 执行删除
query = delete(ErrorLog).where(ErrorLog.id.in_(log_ids))
# execute 返回受影响的行数,但 databases 库的 execute 不直接返回 rowcount
# 我们需要先查询是否存在,或者依赖数据库约束/触发器(如果适用)
# 或者,我们可以执行删除并假设成功,除非抛出异常
# 为了简单起见,我们执行删除并记录日志,不精确返回删除数量
# 如果需要精确数量,需要先执行 SELECT COUNT(*)
await database.execute(query)
# 注意databases 的 execute 不返回 rowcount所以我们不能直接返回删除的数量
# 返回 log_ids 的长度作为尝试删除的数量,或者返回 0/1 表示操作尝试
logger.info(f"Attempted bulk deletion for error logs with IDs: {log_ids}")
return len(log_ids) # 返回尝试删除的数量
except Exception as e:
# 数据库连接或执行错误
logger.error(f"Error during bulk deletion of error logs {log_ids}: {e}", exc_info=True)
raise # Re-raise the exception for the router to handle
async def delete_error_log_by_id(log_id: int) -> bool:
"""
根据 ID 删除单个错误日志 (异步)。
Args:
log_id: 要删除的错误日志 ID。
Returns:
bool: 如果成功删除返回 True否则返回 False。
"""
try:
# 先检查是否存在 (可选,但更明确)
check_query = select(ErrorLog.id).where(ErrorLog.id == log_id)
exists = await database.fetch_one(check_query)
if not exists:
logger.warning(f"Attempted to delete non-existent error log with ID: {log_id}")
return False # 或者可以抛出 404 异常,由路由处理
# 执行删除
delete_query = delete(ErrorLog).where(ErrorLog.id == log_id)
await database.execute(delete_query)
logger.info(f"Successfully deleted error log with ID: {log_id}")
return True
except Exception as e:
logger.error(f"Error deleting error log with ID {log_id}: {e}", exc_info=True)
raise # Re-raise the exception for the router to handle
# --- RequestLog Services (保持异步) ---
# 新增函数:添加请求日志
async def add_request_log(
model_name: Optional[str],

View File

@@ -1,12 +1,30 @@
from typing import List, Optional, Dict, Any, Literal, Union
from pydantic import BaseModel
from typing import Any, Dict, List, Literal, Optional, Union
from pydantic import BaseModel, Field
from app.core.constants import DEFAULT_TEMPERATURE, DEFAULT_TOP_K, DEFAULT_TOP_P
class SafetySetting(BaseModel):
category: Optional[Literal["HARM_CATEGORY_HATE_SPEECH", "HARM_CATEGORY_DANGEROUS_CONTENT", "HARM_CATEGORY_HARASSMENT", "HARM_CATEGORY_SEXUALLY_EXPLICIT", "HARM_CATEGORY_CIVIC_INTEGRITY"]] = None
threshold: Optional[Literal["HARM_BLOCK_THRESHOLD_UNSPECIFIED", "BLOCK_LOW_AND_ABOVE", "BLOCK_MEDIUM_AND_ABOVE", "BLOCK_ONLY_HIGH", "BLOCK_NONE", "OFF"]] = None
category: Optional[
Literal[
"HARM_CATEGORY_HATE_SPEECH",
"HARM_CATEGORY_DANGEROUS_CONTENT",
"HARM_CATEGORY_HARASSMENT",
"HARM_CATEGORY_SEXUALLY_EXPLICIT",
"HARM_CATEGORY_CIVIC_INTEGRITY",
]
] = None
threshold: Optional[
Literal[
"HARM_BLOCK_THRESHOLD_UNSPECIFIED",
"BLOCK_LOW_AND_ABOVE",
"BLOCK_MEDIUM_AND_ABOVE",
"BLOCK_ONLY_HIGH",
"BLOCK_NONE",
"OFF",
]
] = None
class GenerationConfig(BaseModel):
@@ -26,7 +44,7 @@ class GenerationConfig(BaseModel):
class SystemInstruction(BaseModel):
role: str = "system"
parts: List[Dict[str, Any]]
parts: List[Dict[str, Any]] | Dict[str, Any]
class GeminiContent(BaseModel):
@@ -37,9 +55,18 @@ class GeminiContent(BaseModel):
class GeminiRequest(BaseModel):
contents: List[GeminiContent] = []
tools: Optional[Union[List[Dict[str, Any]], Dict[str, Any]]] = []
safetySettings: Optional[List[SafetySetting]] = None
generationConfig: Optional[GenerationConfig] = None
systemInstruction: Optional[SystemInstruction] = None
safetySettings: Optional[List[SafetySetting]] = Field(
default=None, alias="safety_settings"
)
generationConfig: Optional[GenerationConfig] = Field(
default=None, alias="generation_config"
)
systemInstruction: Optional[SystemInstruction] = Field(
default=None, alias="system_instruction"
)
class Config:
populate_by_name = True
class ResetSelectedKeysRequest(BaseModel):

View File

@@ -1,4 +1,3 @@
# app/services/chat/message_converter.py
from abc import ABC, abstractmethod
import json

View File

@@ -1,4 +1,3 @@
# app/services/chat/response_handler.py
import base64
import json

View File

@@ -1,4 +1,3 @@
# app/services/chat/retry_handler.py
from functools import wraps
from typing import Callable, TypeVar
@@ -23,21 +22,26 @@ class RetryHandler:
last_exception = None
for attempt in range(self.max_retries):
retries = attempt + 1
try:
return await func(*args, **kwargs)
except Exception as e:
last_exception = e
logger.warning(
f"API call failed with error: {str(e)}. Attempt {attempt + 1} of {self.max_retries}"
f"API call failed with error: {str(e)}. Attempt {retries} of {self.max_retries}"
)
# 从函数参数中获取 key_manager
key_manager = kwargs.get("key_manager")
if key_manager:
old_key = kwargs.get(self.key_arg)
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}")
new_key = await key_manager.handle_api_failure(old_key, retries)
if new_key:
kwargs[self.key_arg] = new_key
logger.info(f"Switched to new API key: {new_key}")
else:
logger.error(f"No valid API key available after {retries} retries.")
break
logger.error(
f"All retry attempts failed, raising final exception: {str(last_exception)}"

View File

@@ -1,4 +1,3 @@
# app/services/chat/stream_optimizer.py
import asyncio
import math
@@ -107,15 +106,11 @@ class StreamOptimizer:
# 计算智能延迟时间
delay = self.calculate_delay(len(text))
# if self.logger:
# self.logger.info(f"Text length: {len(text)}, delay: {delay:.4f}s")
# 根据文本长度决定输出方式
if len(text) >= self.long_text_threshold:
# 长文本:分块输出
chunks = self.split_text_into_chunks(text)
# if self.logger:
# self.logger.info(f"Long text: splitting into {len(chunks)} chunks")
for chunk_text in chunks:
chunk_response = create_response_chunk(chunk_text)
yield format_chunk(chunk_response)

View File

@@ -1,19 +1,19 @@
import logging
import platform
import sys
from typing import Dict, Optional
import platform
# ANSI转义序列颜色代码
COLORS = {
'DEBUG': '\033[34m', # 蓝色
'INFO': '\033[32m', # 绿色
'WARNING': '\033[33m', # 黄色
'ERROR': '\033[31m', # 红色
'CRITICAL': '\033[1;31m' # 红色加粗
"DEBUG": "\033[34m", # 蓝色
"INFO": "\033[32m", # 绿色
"WARNING": "\033[33m", # 黄色
"ERROR": "\033[31m", # 红色
"CRITICAL": "\033[1;31m", # 红色加粗
}
# Windows系统启用ANSI支持
if platform.system() == 'Windows':
if platform.system() == "Windows":
import ctypes
kernel32 = ctypes.windll.kernel32
@@ -27,15 +27,17 @@ class ColoredFormatter(logging.Formatter):
def format(self, record):
# 获取对应级别的颜色代码
color = COLORS.get(record.levelname, '')
color = COLORS.get(record.levelname, "")
# 添加颜色代码和重置代码
record.levelname = f"{color}{record.levelname}\033[0m"
# 创建包含文件名和行号的固定宽度字符串
record.fileloc = f"[{record.filename}:{record.lineno}]"
return super().format(record)
# 日志格式
# 日志格式 - 使用 fileloc 并设置固定宽度 (例如 30)
FORMATTER = ColoredFormatter(
"%(asctime)s - %(name)s - %(levelname)s - [%(filename)s:%(lineno)d] - %(message)s"
"%(asctime)s | %(levelname)-17s | %(fileloc)-30s | %(message)s"
)
# 日志级别映射
@@ -55,21 +57,28 @@ class Logger:
_loggers: Dict[str, logging.Logger] = {}
@staticmethod
def setup_logger(
name: str,
level: str = "debug",
) -> logging.Logger:
def setup_logger(name: str) -> logging.Logger:
"""
设置并获取logger
:param name: logger名称
:param level: 日志级别
:return: logger实例
"""
# 导入 settings 对象
from app.config.config import settings
# 从全局配置获取日志级别
log_level_str = settings.LOG_LEVEL.lower()
level = LOG_LEVELS.get(log_level_str, logging.INFO)
if name in Logger._loggers:
return Logger._loggers[name]
# 如果 logger 已存在,检查并更新其级别(如果需要)
existing_logger = Logger._loggers[name]
if existing_logger.level != level:
existing_logger.setLevel(level)
return existing_logger
logger = logging.getLogger(name)
logger.setLevel(LOG_LEVELS.get(level.lower(), logging.INFO))
logger.setLevel(level)
logger.propagate = False
# 添加控制台输出
@@ -89,6 +98,22 @@ class Logger:
"""
return Logger._loggers.get(name)
@staticmethod
def update_log_levels(log_level: str):
"""
根据当前的全局配置更新所有已创建 logger 的日志级别。
"""
log_level_str = log_level.lower()
new_level = LOG_LEVELS.get(log_level_str, logging.INFO)
updated_count = 0
for logger_name, logger_instance in Logger._loggers.items():
if logger_instance.level != new_level:
logger_instance.setLevel(new_level)
# 可选:记录级别变更日志,但注意避免在日志模块内部产生过多日志
# print(f"Updated log level for logger '{logger_name}' to {log_level_str.upper()}")
updated_count += 1
# 预定义的loggers
def get_openai_logger():
@@ -172,4 +197,12 @@ def get_log_routes_logger():
def get_stats_logger():
return Logger.setup_logger("stats")
return Logger.setup_logger("stats")
def get_update_logger():
return Logger.setup_logger("update_service")
def get_scheduler_routes():
return Logger.setup_logger("scheduler_routes")

View File

@@ -1,18 +1,11 @@
"""
应用程序入口模块
"""
import uvicorn
from app.core.application import create_app
from app.log.logger import get_main_logger
# 创建应用程序实例
app = create_app()
# 配置日志
logger = get_main_logger()
if __name__ == "__main__":
logger = get_main_logger()
logger.info("Starting application server...")
uvicorn.run(app, host="0.0.0.0", port=8001)

View File

@@ -26,7 +26,7 @@ class RequestLoggingMiddleware(BaseHTTPMiddleware):
f"Formatted request body:\n{json.dumps(formatted_body, indent=2, ensure_ascii=False)}"
)
except json.JSONDecodeError:
logger.info("Request body is not valid JSON.")
logger.error("Request body is not valid JSON.")
except Exception as e:
logger.error(f"Error reading request body: {str(e)}")

View File

@@ -6,7 +6,7 @@ from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import RedirectResponse
from app.core.security import verify_auth_token
from app.log.logger import get_config_routes_logger
from app.log.logger import get_config_routes_logger, Logger # 导入 Logger 类
from app.service.config.config_service import ConfigService
# 创建路由
@@ -31,8 +31,13 @@ async def update_config(config_data: Dict[str, Any], request: Request):
logger.warning("Unauthorized access attempt to config page")
return RedirectResponse(url="/", status_code=302)
try:
return await ConfigService.update_config(config_data)
result = await ConfigService.update_config(config_data)
# 配置更新成功后,立即更新所有 logger 的级别
Logger.update_log_levels(config_data["LOG_LEVEL"])
logger.info("Log levels updated after configuration change.") # 添加日志记录
return result
except Exception as e:
logger.error(f"Error updating config or log levels: {e}", exc_info=True) # 记录详细错误
raise HTTPException(status_code=400, detail=str(e))

View File

@@ -1,15 +1,22 @@
"""
日志路由模块
"""
from typing import List, Optional
from typing import List, Optional, Dict
from datetime import datetime
from pydantic import BaseModel
from fastapi import APIRouter, HTTPException, Request, Query, Path
from fastapi import APIRouter, HTTPException, Request, Query, Path, Body, Response, status
from app.core.security import verify_auth_token
from app.log.logger import get_log_routes_logger
# 假设这些服务函数已更新或添加
from app.database.services import get_error_logs, get_error_logs_count, get_error_log_details
from app.database.services import (
get_error_logs,
get_error_logs_count,
get_error_log_details,
delete_error_logs_by_ids, # 新增导入
delete_error_log_by_id # 新增导入
)
# Removed get_db import comment as it's fully removed now
# 创建路由
router = APIRouter(prefix="/api/logs", tags=["logs"])
@@ -38,11 +45,14 @@ async def get_error_logs_api(
offset: int = Query(0, ge=0),
key_search: Optional[str] = Query(None, description="Search term for Gemini key (partial match)"),
error_search: Optional[str] = Query(None, description="Search term for error type or log message"), # 数据库查询需处理
error_code_search: Optional[str] = Query(None, description="Search term for error code"), # Added error code search parameter
start_date: Optional[datetime] = Query(None, description="Start datetime for filtering"),
end_date: Optional[datetime] = Query(None, description="End datetime for filtering")
end_date: Optional[datetime] = Query(None, description="End datetime for filtering"),
sort_by: str = Query('id', description="Field to sort by (e.g., 'id', 'request_time')"), # 新增排序参数
sort_order: str = Query('desc', description="Sort order ('asc' or 'desc')") # 新增排序参数
):
"""
获取错误日志列表 (返回错误码)
获取错误日志列表 (返回错误码)支持过滤和排序
Args:
request: 请求对象
@@ -50,8 +60,11 @@ async def get_error_logs_api(
offset: 偏移量
key_search: 密钥搜索
error_search: 错误搜索 (可能搜索类型或日志内容由DB层决定)
error_code_search: 错误码搜索
start_date: 开始日期
end_date: 结束日期
sort_by: 排序字段
sort_order: 排序顺序
Returns:
ErrorLogListResponse: An object containing the list of logs (with error_code) and the total count.
@@ -70,14 +83,17 @@ async def get_error_logs_api(
offset=offset,
key_search=key_search,
error_search=error_search, # 数据库查询需要处理这个
error_code_search=error_code_search, # Pass error code search to DB function
start_date=start_date,
end_date=end_date,
# include_error_code=True # 如果需要显式传递
sort_by=sort_by, # 传递排序参数
sort_order=sort_order # 传递排序参数
)
# Fetch total count with the same search parameters
total_count = await get_error_logs_count(
key_search=key_search,
error_search=error_search,
error_code_search=error_code_search, # Pass error code search to DB count function
start_date=start_date,
end_date=end_date
)
@@ -123,3 +139,63 @@ async def get_error_log_detail_api(request: Request, log_id: int = Path(..., ge=
except Exception as e:
logger.exception(f"Failed to get error log details for ID {log_id}: {str(e)}")
raise HTTPException(status_code=500, detail=f"Failed to get error log details: {str(e)}")
# 新增:批量删除错误日志
@router.delete("/errors", status_code=status.HTTP_204_NO_CONTENT)
async def delete_error_logs_bulk_api(
request: Request,
payload: Dict[str, List[int]] = Body(...) # Expects {"ids": [1, 2, 3]}
# Ensure db dependency is fully removed
):
"""
批量删除错误日志 (异步)
"""
auth_token = request.cookies.get("auth_token")
if not auth_token or not verify_auth_token(auth_token):
logger.warning("Unauthorized access attempt to bulk delete error logs")
raise HTTPException(status_code=401, detail="Not authenticated")
log_ids = payload.get("ids")
if not log_ids:
raise HTTPException(status_code=400, detail="No log IDs provided for deletion.")
try:
# 调用异步服务函数
deleted_count = await delete_error_logs_by_ids(log_ids)
# 注意:异步函数返回的是尝试删除的数量,可能不是精确值
logger.info(f"Attempted bulk deletion for {deleted_count} error logs with IDs: {log_ids}")
return Response(status_code=status.HTTP_204_NO_CONTENT)
except Exception as e:
logger.exception(f"Error bulk deleting error logs with IDs {log_ids}: {str(e)}")
raise HTTPException(status_code=500, detail="Internal server error during bulk deletion")
# 新增:删除单个错误日志
@router.delete("/errors/{log_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_error_log_api(
request: Request,
log_id: int = Path(..., ge=1)
# Ensure db dependency is fully removed
):
"""
删除单个错误日志 (异步)
"""
auth_token = request.cookies.get("auth_token")
if not auth_token or not verify_auth_token(auth_token):
logger.warning(f"Unauthorized access attempt to delete error log ID: {log_id}")
raise HTTPException(status_code=401, detail="Not authenticated")
try:
# 调用异步服务函数
success = await delete_error_log_by_id(log_id)
if not success:
# 服务层现在在未找到时返回 False我们在这里转换为 404
raise HTTPException(status_code=404, detail=f"Error log with ID {log_id} not found")
logger.info(f"Successfully deleted error log with ID: {log_id}")
return Response(status_code=status.HTTP_204_NO_CONTENT)
except HTTPException as http_exc:
raise http_exc # Re-raise 404 or other HTTP exceptions
except Exception as e:
logger.exception(f"Error deleting error log with ID {log_id}: {str(e)}")
raise HTTPException(status_code=500, detail="Internal server error during deletion")

View File

@@ -83,6 +83,21 @@ async def list_models(
models_json["models"].append(item)
# 添加思考模型的非思考版本
if settings.THINKING_MODELS:
for name in settings.THINKING_MODELS:
model = model_mapping.get(name)
if not model:
continue
item = deepcopy(model)
item["name"] = f"models/{name}-non-thinking"
display_name = f'{item.get("displayName")} Non Thinking'
item["displayName"] = display_name
item["description"] = display_name
models_json["models"].append(item)
return models_json
@@ -94,12 +109,13 @@ async def generate_content(
request: GeminiRequest,
_=Depends(security_service.verify_key_or_goog_api_key),
api_key: str = Depends(get_next_working_key),
key_manager: KeyManager = Depends(get_key_manager),
chat_service: GeminiChatService = Depends(get_chat_service)
):
"""非流式生成内容"""
logger.info("-" * 50 + "gemini_generate_content" + "-" * 50)
logger.info(f"Handling Gemini content generation request for model: {model_name}")
logger.info(f"Request: \n{request.model_dump_json(indent=2)}")
logger.debug(f"Request: \n{request.model_dump_json(indent=2)}")
logger.info(f"Using API key: {api_key}")
if not model_service.check_model_support(model_name):
@@ -125,12 +141,13 @@ async def stream_generate_content(
request: GeminiRequest,
_=Depends(security_service.verify_key_or_goog_api_key),
api_key: str = Depends(get_next_working_key),
key_manager: KeyManager = Depends(get_key_manager),
chat_service: GeminiChatService = Depends(get_chat_service)
):
"""流式生成内容"""
logger.info("-" * 50 + "gemini_stream_generate_content" + "-" * 50)
logger.info(f"Handling Gemini streaming content generation for model: {model_name}")
logger.info(f"Request: \n{request.model_dump_json(indent=2)}")
logger.debug(f"Request: \n{request.model_dump_json(indent=2)}")
logger.info(f"Using API key: {api_key}")
if not model_service.check_model_support(model_name):
@@ -308,13 +325,12 @@ async def verify_selected_keys(
if not keys_to_verify:
return JSONResponse({"success": False, "message": "没有提供需要验证的密钥"}, status_code=400)
valid_count = 0
invalid_count = 0
verification_errors = {} # 存储验证过程中的错误
successful_keys = []
failed_keys = {} # 存储失败的 key 和错误信息
async def _verify_single_key(api_key: str):
"""内部函数,用于验证单个密钥并处理异常"""
nonlocal valid_count, invalid_count # 允许修改外部计数器
nonlocal successful_keys, failed_keys # 允许修改外部列表和字典
try:
# 重用单密钥验证逻辑的核心部分
gemini_request = GeminiRequest(
@@ -327,7 +343,7 @@ async def verify_selected_keys(
api_key
)
# 如果上面没有抛出异常,则认为密钥有效
valid_count += 1
successful_keys.append(api_key)
return api_key, "valid", None
except Exception as e:
error_message = str(e)
@@ -341,7 +357,7 @@ async def verify_selected_keys(
# 如果密钥不在计数中可能刚添加或从未失败初始化为1
key_manager.key_failure_counts[api_key] = 1
logger.warning(f"Bulk verification exception for key: {api_key}, initializing failure count to 1")
invalid_count += 1
failed_keys[api_key] = error_message # 记录失败的 key 和错误信息
return api_key, "invalid", error_message
# 并发执行所有密钥的验证
@@ -356,28 +372,39 @@ async def verify_selected_keys(
# 可以选择如何处理这种任务级别的错误,这里我们简单记录
# 也可以将其计入 invalid_count 或单独记录
elif result:
key, status, error = result
if status == "invalid" and error:
verification_errors[key] = error # 记录具体的验证错误信息
# result 可能是 (key, status, error) 或 Exception
if not isinstance(result, Exception) and result:
key, status, error = result
# 失败信息已在 _verify_single_key 中记录到 failed_keys
elif isinstance(result, Exception):
# 记录任务本身的异常,可以关联到一个特定的 key 如果可能的话
# 这里简化处理,只记录日志
logger.error(f"Task execution error during bulk verification: {result}")
valid_count = len(successful_keys)
invalid_count = len(failed_keys)
logger.info(f"Bulk verification finished. Valid: {valid_count}, Invalid: {invalid_count}")
# 根据是否有错误决定最终消息和状态
if verification_errors or valid_count + invalid_count != len(keys_to_verify): # 检查是否有错误或任务异常
error_summary = "; ".join([f"{k}: {v}" for k, v in verification_errors.items()])
message = f"批量验证完成,但出现问题。有效: {valid_count}, 无效: {invalid_count}。错误详情: {error_summary or '任务执行异常'}"
# 根据是否有失败的 key 决定最终消息和状态
if failed_keys:
message = f"批量验证完成。成功: {valid_count}, 失败: {invalid_count}"
# 即使有失败也认为是部分成功,返回 200 OK让前端处理详细结果
return JSONResponse({
"success": False, # 标记为失败,因为有错误
"success": True, # 表示请求处理完成,具体结果看内容
"message": message,
"valid_count": valid_count,
"invalid_count": invalid_count,
"errors": verification_errors
}, status_code=207) # 207 Multi-Status 表示部分成功/失败
"successful_keys": successful_keys,
"failed_keys": failed_keys,
"valid_count": valid_count, # 保留计数方便前端快速展示
"invalid_count": invalid_count
})
else:
# 完全成功
message = f"批量验证成功完成。所有 {valid_count} 个密钥均有效。"
return JSONResponse({
"success": True,
"message": f"批量验证成功完成。有效: {valid_count}, 无效: {invalid_count}",
"message": message,
"successful_keys": successful_keys,
"failed_keys": {},
"valid_count": valid_count,
"invalid_count": invalid_count
"invalid_count": 0
})

View File

@@ -75,7 +75,7 @@ async def chat_completion(
api_key = await key_manager.get_paid_key()
logger.info("-" * 50 + "chat_completion" + "-" * 50)
logger.info(f"Handling chat completion request for model: {request.model}")
logger.info(f"Request: \n{request.model_dump_json(indent=2)}")
logger.debug(f"Request: \n{request.model_dump_json(indent=2)}")
logger.info(f"Using API key: {api_key}")
if not model_service.check_model_support(request.model):
@@ -86,7 +86,7 @@ async def chat_completion(
try:
# 如果model是imagen3,使用paid_key
if request.model == f"{settings.CREATE_IMAGE_MODEL}-chat":
response = await chat_service.create_image_chat_completion(request=request)
response = await chat_service.create_image_chat_completion(request, api_key)
else:
response = await chat_service.create_chat_completion(request, api_key)
# 处理流式响应

View File

@@ -8,9 +8,9 @@ from fastapi.templating import Jinja2Templates
from app.core.security import verify_auth_token
from app.log.logger import get_routes_logger
from app.router import gemini_routes, openai_routes, config_routes, log_routes, scheduler_routes # 新增导入
from app.router import error_log_routes, gemini_routes, openai_routes, config_routes, scheduler_routes, stats_routes, version_routes # 新增导入 version_routes
from app.service.key.key_manager import get_key_manager_instance
from app.service.stats_service import get_api_usage_stats, get_api_call_details # <-- Import stats service and details function
from app.service.stats_service import StatsService
logger = get_routes_logger()
@@ -30,8 +30,10 @@ def setup_routers(app: FastAPI) -> None:
app.include_router(gemini_routes.router)
app.include_router(gemini_routes.router_v1beta)
app.include_router(config_routes.router)
app.include_router(log_routes.router)
app.include_router(error_log_routes.router)
app.include_router(scheduler_routes.router) # 新增包含 scheduler 路由
app.include_router(stats_routes.router) # 包含 stats API 路由
app.include_router(version_routes.router) # 包含 version API 路由
# 添加页面路由
setup_page_routes(app)
@@ -92,8 +94,8 @@ def setup_page_routes(app: FastAPI) -> None:
valid_key_count = len(keys_status["valid_keys"])
invalid_key_count = len(keys_status["invalid_keys"])
# Get API usage stats
api_stats = await get_api_usage_stats()
stats_service = StatsService()
api_stats = await stats_service.get_api_usage_stats()
logger.info(f"API stats retrieved: {api_stats}")
logger.info(f"Keys status retrieved successfully. Total keys: {total_keys}")
@@ -180,7 +182,9 @@ def setup_api_stats_routes(app: FastAPI) -> None:
return {"error": "Unauthorized"}, 401
logger.info(f"Fetching API call details for period: {period}")
details = await get_api_call_details(period)
# Use the service instance here as well
stats_service = StatsService() # Create an instance
details = await stats_service.get_api_call_details(period)
return details
except ValueError as e:
logger.warning(f"Invalid period requested for API stats details: {period} - {str(e)}")

View File

@@ -7,9 +7,9 @@ from fastapi.responses import JSONResponse
from app.core.security import verify_auth_token # 导入 verify_auth_token
from app.scheduler.key_checker import start_scheduler, stop_scheduler
from app.log.logger import get_routes_logger # 使用路由日志记录器
from app.log.logger import get_scheduler_routes # 使用路由日志记录器
logger = get_routes_logger()
logger = get_scheduler_routes()
router = APIRouter(
prefix="/api/scheduler",

View File

@@ -0,0 +1,58 @@
from fastapi import APIRouter, Depends, HTTPException, Request
from starlette import status
from app.core.security import verify_auth_token
from app.service.stats_service import StatsService
from app.log.logger import get_stats_logger
logger = get_stats_logger()
async def verify_token(request: Request):
auth_token = request.cookies.get("auth_token")
if not auth_token or not verify_auth_token(auth_token):
logger.warning("Unauthorized access attempt to scheduler API")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)
router = APIRouter(
prefix="/api",
tags=["stats"],
dependencies=[Depends(verify_token)]
)
stats_service = StatsService()
@router.get("/key-usage-details/{key}",
summary="获取指定密钥最近24小时的模型调用次数",
description="根据提供的 API 密钥返回过去24小时内每个模型被调用的次数统计。")
async def get_key_usage_details(key: str):
"""
Retrieves the model usage count for a specific API key within the last 24 hours.
Args:
key: The API key to get usage details for.
Returns:
A dictionary with model names as keys and their call counts as values.
Example: {"gemini-pro": 10, "gemini-1.5-pro-latest": 5}
Raises:
HTTPException: If an error occurs during data retrieval.
"""
try:
usage_details = await stats_service.get_key_usage_details_last_24h(key)
if usage_details is None:
# Handle case where key might be valid but has no recent usage,
# or if the service layer explicitly returns None for other reasons.
# Returning an empty dict is usually fine for the frontend.
return {}
return usage_details
except Exception as e:
logger.error(f"Error fetching key usage details for key {key[:4]}...: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"获取密钥使用详情时出错: {e}"
)

View File

@@ -0,0 +1,38 @@
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from typing import Optional
from app.service.update.update_service import check_for_updates
from app.utils.helpers import get_current_version
from app.log.logger import get_update_logger
router = APIRouter(prefix="/api/version", tags=["Version"])
logger = get_update_logger()
class VersionInfo(BaseModel):
current_version: str = Field(..., description="当前应用程序版本")
latest_version: Optional[str] = Field(None, description="可用的最新版本")
update_available: bool = Field(False, description="是否有可用更新")
error_message: Optional[str] = Field(None, description="检查更新时发生的错误信息")
@router.get("/check", response_model=VersionInfo, summary="检查应用程序更新")
async def get_version_info():
"""
检查当前应用程序版本与最新的 GitHub release 版本。
"""
try:
current_version = get_current_version() # Use imported function
update_available, latest_version, error_message = await check_for_updates()
# Log the result for debugging
logger.info(f"Version check API result: current={current_version}, latest={latest_version}, available={update_available}, error='{error_message}'")
return VersionInfo(
current_version=current_version,
latest_version=latest_version,
update_available=update_available,
error_message=error_message
)
except Exception as e:
logger.error(f"Error in /api/version/check endpoint: {e}", exc_info=True)
raise HTTPException(status_code=500, detail="检查版本信息时发生内部错误")

View File

@@ -90,8 +90,10 @@ scheduler_instance = None
def start_scheduler():
global scheduler_instance
if scheduler_instance is None:
if scheduler_instance is None or not scheduler_instance.running:
logger.info("Starting scheduler...")
scheduler_instance = setup_scheduler()
logger.info("Scheduler is already running.")
def stop_scheduler():
global scheduler_instance

View File

@@ -108,6 +108,12 @@ def _build_payload(model: str, request: GeminiRequest) -> Dict[str, Any]:
if model.endswith("-image") or model.endswith("-image-generation"):
payload.pop("systemInstruction")
payload["generationConfig"]["responseModalities"] = ["Text", "Image"]
if model.endswith("-non-thinking"):
payload["generationConfig"]["thinkingConfig"] = {"thinkingBudget": 0}
if model in settings.THINKING_BUDGET_MAP:
payload["generationConfig"]["thinkingConfig"] = {"thinkingBudget": settings.THINKING_BUDGET_MAP.get(model,1000)}
return payload
@@ -156,10 +162,6 @@ class GeminiChatService:
try:
response = await self.api_client.generate_content(payload, model, api_key)
# Assuming success if no exception is raised and response is received
# The actual status code might be within the response structure or headers,
# but api_client doesn't seem to expose it directly here.
# We'll assume 200 for success if no exception.
is_success = True
status_code = 200 # Assume 200 on success
return self.response_handler.handle_response(response, model, stream=False)
@@ -178,7 +180,7 @@ class GeminiChatService:
await add_error_log(
gemini_key=api_key,
model_name=model,
error_type="gemini_chat_service",
error_type="gemini-chat-non-stream",
error_log=error_log_msg,
error_code=status_code,
request_msg=payload
@@ -204,96 +206,90 @@ class GeminiChatService:
retries = 0
max_retries = settings.MAX_RETRIES
payload = _build_payload(model, request)
start_time = time.perf_counter() # Record start time before loop
request_datetime = datetime.datetime.now()
is_success = False
status_code = None
final_api_key = api_key # Store the initial key
final_api_key = api_key
try:
while retries < max_retries:
current_attempt_key = api_key # Key used for this attempt
final_api_key = current_attempt_key # Update final key used
try:
async for line in self.api_client.stream_generate_content(
payload, model, current_attempt_key
):
# print(line)
if line.startswith("data:"):
line = line[6:]
response_data = self.response_handler.handle_response(
json.loads(line), model, stream=True
)
text = self._extract_text_from_response(response_data)
# 如果有文本内容,且开启了流式输出优化器,则使用流式输出优化器处理
if text and settings.STREAM_OPTIMIZER_ENABLED:
# 使用流式输出优化器处理文本输出
async for (
optimized_chunk
) in gemini_optimizer.optimize_stream_output(
text,
lambda t: self._create_char_response(response_data, t),
lambda c: "data: " + json.dumps(c) + "\n\n",
):
yield optimized_chunk
else:
# 如果没有文本内容(如工具调用等),整块输出
yield "data: " + json.dumps(response_data) + "\n\n"
logger.info("Streaming completed successfully")
is_success = True
status_code = 200 # Assume 200 on success
break # Exit loop on success
except Exception as e:
retries += 1
is_success = False # Mark as failed for this attempt
error_log_msg = str(e)
logger.warning(
f"Streaming API call failed with error: {error_log_msg}. Attempt {retries} of {max_retries}"
)
# Parse error code for logging
match = re.search(r"status code (\d+)", error_log_msg)
if match:
status_code = int(match.group(1))
else:
status_code = 500 # Default if parsing fails
# Log error to error log table
await add_error_log(
gemini_key=current_attempt_key, # Log key used for this failed attempt
model_name=model,
error_log=error_log_msg,
error_code=status_code,
request_msg=payload
)
# Attempt to switch API Key
api_key = await self.key_manager.handle_api_failure(current_attempt_key, retries)
if api_key:
logger.info(f"Switched to new API key: {api_key}")
else: # No more keys or retries exceeded by handle_api_failure logic
logger.error(f"No valid API key available after {retries} retries.")
break # Exit loop if no key available
if retries >= max_retries:
logger.error(
f"Max retries ({max_retries}) reached for streaming."
while retries < max_retries:
request_datetime = datetime.datetime.now()
start_time = time.perf_counter()
current_attempt_key = api_key
final_api_key = current_attempt_key # Update final key used
try:
async for line in self.api_client.stream_generate_content(
payload, model, current_attempt_key
):
# print(line)
if line.startswith("data:"):
line = line[6:]
response_data = self.response_handler.handle_response(
json.loads(line), model, stream=True
)
break # Exit loop after max retries
finally:
# Log the final outcome of the streaming request
end_time = time.perf_counter()
latency_ms = int((end_time - start_time) * 1000)
await add_request_log(
model_name=model,
api_key=final_api_key, # Log the last key used
is_success=is_success, # Log the final success status
status_code=status_code, # Log the last known status code
latency_ms=latency_ms, # Log total time including retries
request_time=request_datetime
)
# If the loop finished due to failure, ensure an exception is raised if not already handled
if not is_success and retries >= max_retries:
# We need to raise an exception here if the loop exited due to max retries failure
# However, the original code structure doesn't explicitly raise here after the loop.
# For now, we just log. Consider raising HTTPException if needed.
pass
text = self._extract_text_from_response(response_data)
# 如果有文本内容,且开启了流式输出优化器,则使用流式输出优化器处理
if text and settings.STREAM_OPTIMIZER_ENABLED:
# 使用流式输出优化器处理文本输出
async for (
optimized_chunk
) in gemini_optimizer.optimize_stream_output(
text,
lambda t: self._create_char_response(response_data, t),
lambda c: "data: " + json.dumps(c) + "\n\n",
):
yield optimized_chunk
else:
# 如果没有文本内容(如工具调用等),整块输出
yield "data: " + json.dumps(response_data) + "\n\n"
logger.info("Streaming completed successfully")
is_success = True
status_code = 200
break
except Exception as e:
retries += 1
is_success = False
error_log_msg = str(e)
logger.warning(
f"Streaming API call failed with error: {error_log_msg}. Attempt {retries} of {max_retries}"
)
# Parse error code for logging
match = re.search(r"status code (\d+)", error_log_msg)
if match:
status_code = int(match.group(1))
else:
status_code = 500
# Log error to error log table
await add_error_log(
gemini_key=current_attempt_key, # Log key used for this failed attempt
model_name=model,
error_type="gemini-chat-stream",
error_log=error_log_msg,
error_code=status_code,
request_msg=payload
)
# Attempt to switch API Key
api_key = await self.key_manager.handle_api_failure(current_attempt_key, retries)
if api_key:
logger.info(f"Switched to new API key: {api_key}")
else: # No more keys or retries exceeded by handle_api_failure logic
logger.error(f"No valid API key available after {retries} retries.")
break # Exit loop if no key available
if retries >= max_retries:
logger.error(
f"Max retries ({max_retries}) reached for streaming."
)
break # Exit loop after max retries
finally:
# Log the final outcome of the streaming request
end_time = time.perf_counter()
latency_ms = int((end_time - start_time) * 1000)
await add_request_log(
model_name=model,
api_key=final_api_key, # Log the last key used
is_success=is_success, # Log the final success status
status_code=status_code, # Log the last known status code
latency_ms=latency_ms, # Log total time including retries
request_time=request_datetime
)

View File

@@ -130,6 +130,10 @@ def _build_payload(
payload["generationConfig"]["maxOutputTokens"] = request.max_tokens
if request.model.endswith("-image") or request.model.endswith("-image-generation"):
payload["generationConfig"]["responseModalities"] = ["Text", "Image"]
if request.model.endswith("-non-thinking"):
payload["generationConfig"]["thinkingConfig"] = {"thinkingBudget": 0}
if request.model in settings.THINKING_BUDGET_MAP:
payload["generationConfig"]["thinkingConfig"] = {"thinkingBudget": settings.THINKING_BUDGET_MAP.get(request.model,1000)}
if (
instruction
@@ -219,7 +223,7 @@ class OpenAIChatService:
await add_error_log(
gemini_key=api_key, # Note: Parameter name is gemini_key in add_error_log
model_name=model,
error_type="openai_chat_service", # Indicate service type
error_type="openai-chat-non-stream",
error_log=error_log_msg,
error_code=status_code,
request_msg=payload
@@ -243,118 +247,117 @@ class OpenAIChatService:
"""处理流式聊天完成,添加重试逻辑"""
retries = 0
max_retries = settings.MAX_RETRIES
start_time = time.perf_counter() # Record start time before loop
request_datetime = datetime.datetime.now()
is_success = False
status_code = None
final_api_key = api_key # Store the initial key
final_api_key = api_key
try:
while retries < max_retries:
current_attempt_key = api_key # Key used for this attempt
final_api_key = current_attempt_key # Update final key used
try:
tool_call_flag = False
async for line in self.api_client.stream_generate_content(
payload, model, current_attempt_key
):
# print(line)
if line.startswith("data:"):
chunk = json.loads(line[6:])
openai_chunk = self.response_handler.handle_response(
chunk, model, stream=True, finish_reason=None
)
if openai_chunk:
# 提取文本内容
text = self._extract_text_from_openai_chunk(openai_chunk)
if text and settings.STREAM_OPTIMIZER_ENABLED:
# 使用流式输出优化器处理文本输出
async for (
optimized_chunk
) in openai_optimizer.optimize_stream_output(
text,
lambda t: self._create_char_openai_chunk(
openai_chunk, t
),
lambda c: f"data: {json.dumps(c)}\n\n",
):
yield optimized_chunk
else:
# 如果没有文本内容(如工具调用等),整块输出
if "tool_calls" in json.dumps(openai_chunk):
tool_call_flag = True
yield f"data: {json.dumps(openai_chunk)}\n\n"
if tool_call_flag:
yield f"data: {json.dumps(self.response_handler.handle_response({}, model, stream=True, finish_reason='tool_calls'))}\n\n"
else:
yield f"data: {json.dumps(self.response_handler.handle_response({}, model, stream=True, finish_reason='stop'))}\n\n"
yield "data: [DONE]\n\n"
logger.info("Streaming completed successfully")
is_success = True
status_code = 200 # Assume 200 on success
break # 成功后退出循环
except Exception as e:
retries += 1
is_success = False # Mark as failed for this attempt
error_log_msg = str(e)
logger.warning(
f"Streaming API call failed with error: {error_log_msg}. Attempt {retries} of {max_retries}"
)
# Parse error code for logging
match = re.search(r"status code (\d+)", error_log_msg)
if match:
status_code = int(match.group(1))
else:
status_code = 500 # Default if parsing fails
# Log error to error log table
await add_error_log(
gemini_key=current_attempt_key, # Note: Parameter name is gemini_key
model_name=model,
error_type="openai_chat_service", # Indicate service type
error_log=error_log_msg,
error_code=status_code,
request_msg=payload
)
# Attempt to switch API Key
# Ensure key_manager is available (might need adjustment if not always passed)
if self.key_manager:
api_key = await self.key_manager.handle_api_failure(current_attempt_key, retries)
if api_key:
logger.info(f"Switched to new API key: {api_key}")
else:
logger.error(f"No valid API key available after {retries} retries.")
break # Exit loop if no key available
else:
logger.error("KeyManager not available for retry logic.")
break # Exit loop if key manager is missing
if retries >= max_retries:
logger.error(
f"Max retries ({max_retries}) reached for streaming."
while retries < max_retries:
start_time = time.perf_counter()
request_datetime = datetime.datetime.now()
current_attempt_key = api_key
final_api_key = current_attempt_key
try:
tool_call_flag = False
async for line in self.api_client.stream_generate_content(
payload, model, current_attempt_key
):
if line.startswith("data:"):
chunk = json.loads(line[6:])
openai_chunk = self.response_handler.handle_response(
chunk, model, stream=True, finish_reason=None
)
break # Exit loop after max retries
finally:
# Log the final outcome of the streaming request
end_time = time.perf_counter()
latency_ms = int((end_time - start_time) * 1000)
await add_request_log(
model_name=model,
api_key=final_api_key, # Log the last key used
is_success=is_success, # Log the final success status
status_code=status_code, # Log the last known status code
latency_ms=latency_ms, # Log total time including retries
request_time=request_datetime
)
# If the loop finished due to failure, yield error and DONE
if not is_success and retries >= max_retries:
yield f"data: {json.dumps({'error': 'Streaming failed after retries'})}\n\n"
yield "data: [DONE]\n\n"
if openai_chunk:
# 提取文本内容
text = self._extract_text_from_openai_chunk(openai_chunk)
if text and settings.STREAM_OPTIMIZER_ENABLED:
# 使用流式输出优化器处理文本输出
async for (
optimized_chunk
) in openai_optimizer.optimize_stream_output(
text,
lambda t: self._create_char_openai_chunk(
openai_chunk, t
),
lambda c: f"data: {json.dumps(c)}\n\n",
):
yield optimized_chunk
else:
# 如果没有文本内容(如工具调用等),整块输出
if "tool_calls" in json.dumps(openai_chunk):
tool_call_flag = True
yield f"data: {json.dumps(openai_chunk)}\n\n"
if tool_call_flag:
yield f"data: {json.dumps(self.response_handler.handle_response({}, model, stream=True, finish_reason='tool_calls'))}\n\n"
else:
yield f"data: {json.dumps(self.response_handler.handle_response({}, model, stream=True, finish_reason='stop'))}\n\n"
yield "data: [DONE]\n\n"
logger.info("Streaming completed successfully")
is_success = True
status_code = 200 # Assume 200 on success
break # 成功后退出循环
except Exception as e:
retries += 1
is_success = False
error_log_msg = str(e)
logger.warning(
f"Streaming API call failed with error: {error_log_msg}. Attempt {retries} of {max_retries}"
)
# Parse error code for logging
match = re.search(r"status code (\d+)", error_log_msg)
if match:
status_code = int(match.group(1))
else:
status_code = 500 # Default if parsing fails
# Log error to error log table
await add_error_log(
gemini_key=current_attempt_key,
model_name=model,
error_type="openai-chat-stream",
error_log=error_log_msg,
error_code=status_code,
request_msg=payload
)
# Attempt to switch API Key
# Ensure key_manager is available (might need adjustment if not always passed)
if self.key_manager:
api_key = await self.key_manager.handle_api_failure(current_attempt_key, retries)
if api_key:
logger.info(f"Switched to new API key: {api_key}")
else:
logger.error(f"No valid API key available after {retries} retries.")
break # Exit loop if no key available
else:
logger.error("KeyManager not available for retry logic.")
break # Exit loop if key manager is missing
if retries >= max_retries:
logger.error(
f"Max retries ({max_retries}) reached for streaming."
)
break # Exit loop after max retries
finally:
# Log the final outcome of the streaming request
end_time = time.perf_counter()
latency_ms = int((end_time - start_time) * 1000)
await add_request_log(
model_name=model,
api_key=final_api_key, # Log the last key used
is_success=is_success, # Log the final success status
status_code=status_code, # Log the last known status code
latency_ms=latency_ms, # Log total time including retries
request_time=request_datetime
)
# If the loop finished due to failure, yield error and DONE
if not is_success and retries >= max_retries:
yield f"data: {json.dumps({'error': 'Streaming failed after retries'})}\n\n"
yield "data: [DONE]\n\n"
async def create_image_chat_completion(
self,
request: ChatRequest,
api_key: str
) -> Union[Dict[str, Any], AsyncGenerator[str, None]]:
image_generate_request = ImageGenerationRequest()
@@ -364,41 +367,116 @@ class OpenAIChatService:
)
if request.stream:
return self._handle_stream_image_completion(request.model, image_res)
return self._handle_stream_image_completion(request.model, image_res, api_key)
else:
return self._handle_normal_image_completion(request.model, image_res)
return await self._handle_normal_image_completion(request.model, image_res, api_key)
async def _handle_stream_image_completion(
self, model: str, image_data: str
self, model: str, image_data: str, api_key:str
) -> AsyncGenerator[str, None]:
if image_data:
openai_chunk = self.response_handler.handle_image_chat_response(
image_data, model, stream=True, finish_reason=None
logger.info(f"Starting stream image completion for model: {model}")
start_time = time.perf_counter()
request_datetime = datetime.datetime.now() # Although not used for DB log here
is_success = False
status_code = None # Although not used for DB log here
try:
if image_data:
openai_chunk = self.response_handler.handle_image_chat_response(
image_data, model, stream=True, finish_reason=None
)
if openai_chunk:
# 提取文本内容
text = self._extract_text_from_openai_chunk(openai_chunk)
if text:
# 使用流式输出优化器处理文本输出
async for (
optimized_chunk
) in openai_optimizer.optimize_stream_output(
text,
lambda t: self._create_char_openai_chunk(openai_chunk, t),
lambda c: f"data: {json.dumps(c)}\n\n",
):
yield optimized_chunk
else:
# 如果没有文本内容如图片URL等整块输出
yield f"data: {json.dumps(openai_chunk)}\n\n"
yield f"data: {json.dumps(self.response_handler.handle_response({}, model, stream=True, finish_reason='stop'))}\n\n"
logger.info(f"Stream image completion finished successfully for model: {model}")
is_success = True
status_code = 200
yield "data: [DONE]\n\n"
except Exception as e:
is_success = False
error_log_msg = f"Stream image completion failed for model {model}: {e}"
logger.error(error_log_msg)
status_code = 500 # Default error code
await add_error_log(
gemini_key=api_key,
model_name=model,
error_type="openai-image-stream", # Specific error type
error_log=error_log_msg,
error_code=status_code,
request_msg={"image_data_truncated": image_data[:1000]} # Log truncated data
)
yield f"data: {json.dumps({'error': error_log_msg})}\n\n" # Send error to client
yield "data: [DONE]\n\n" # Still need DONE message
# Re-raising might break the stream, decide if needed
finally:
end_time = time.perf_counter()
latency_ms = int((end_time - start_time) * 1000)
logger.info(f"Stream image completion for model {model} took {latency_ms} ms. Success: {is_success}")
await add_request_log(
model_name=model,
api_key=api_key,
is_success=is_success,
status_code=status_code,
latency_ms=latency_ms,
request_time=request_datetime
)
if openai_chunk:
# 提取文本内容
text = self._extract_text_from_openai_chunk(openai_chunk)
if text:
# 使用流式输出优化器处理文本输出
async for (
optimized_chunk
) in openai_optimizer.optimize_stream_output(
text,
lambda t: self._create_char_openai_chunk(openai_chunk, t),
lambda c: f"data: {json.dumps(c)}\n\n",
):
yield optimized_chunk
else:
# 如果没有文本内容如图片URL等整块输出
yield f"data: {json.dumps(openai_chunk)}\n\n"
yield f"data: {json.dumps(self.response_handler.handle_response({}, model, stream=True, finish_reason='stop'))}\n\n"
yield "data: [DONE]\n\n"
logger.info("Image chat streaming completed successfully")
def _handle_normal_image_completion(
self, model: str, image_data: str
async def _handle_normal_image_completion(
self, model: str, image_data: str, api_key: str # Add api_key parameter
) -> Dict[str, Any]:
logger.info(f"Starting normal image completion for model: {model}")
start_time = time.perf_counter()
request_datetime = datetime.datetime.now() # Although not used for DB log here
is_success = False
status_code = None # Although not used for DB log here
result = None
return self.response_handler.handle_image_chat_response(
image_data, model, stream=False, finish_reason="stop"
)
try:
result = self.response_handler.handle_image_chat_response(
image_data, model, stream=False, finish_reason="stop"
)
logger.info(f"Normal image completion finished successfully for model: {model}")
is_success = True
status_code = 200
return result
except Exception as e:
is_success = False
error_log_msg = f"Normal image completion failed for model {model}: {e}"
logger.error(error_log_msg)
status_code = 500 # Default error code
await add_error_log(
gemini_key=api_key,
model_name=model,
error_type="openai-image-non-stream", # Specific error type
error_log=error_log_msg,
error_code=status_code,
request_msg={"image_data_truncated": image_data[:1000]} # Log truncated data
)
# Re-raise the exception so the caller knows about the failure
raise e
finally:
end_time = time.perf_counter()
latency_ms = int((end_time - start_time) * 1000)
logger.info(f"Normal image completion for model {model} took {latency_ms} ms. Success: {is_success}")
await add_request_log(
model_name=model,
api_key=api_key,
is_success=is_success,
status_code=status_code,
latency_ms=latency_ms,
request_time=request_datetime
)

View File

@@ -31,7 +31,10 @@ class GeminiApiClient(ApiClient):
model = model[:-7]
if model.endswith("-image"):
model = model[:-6]
if model.endswith("-non-thinking"):
model = model[:-13]
if "-search" in model and "-non-thinking" in model:
model = model[:-20]
return model
async def generate_content(self, payload: Dict[str, Any], model: str, api_key: str) -> Dict[str, Any]:

View File

@@ -31,7 +31,7 @@ class ConfigService:
for key, value in config_data.items():
if hasattr(settings, key):
setattr(settings, key, value)
logger.info(f"Updated setting in memory: {key}")
logger.debug(f"Updated setting in memory: {key}")
# 获取现有设置
existing_settings_raw: List[Dict[str, Any]] = await get_all_settings()
@@ -47,6 +47,8 @@ class ConfigService:
# 处理不同类型的值
if isinstance(value, list):
db_value = json.dumps(value)
elif isinstance(value, dict): # 新增对 dict 类型的处理
db_value = json.dumps(value)
elif isinstance(value, bool):
db_value = str(value).lower()
else:

View File

@@ -1,9 +1,15 @@
import datetime
import time
import re # For potential status code parsing from generic errors
from typing import List, Union
import openai
from openai import APIStatusError # Import specific error type
from openai.types import CreateEmbeddingResponse
from app.config.config import settings
from app.log.logger import get_embeddings_logger
from app.database.services import add_error_log, add_request_log # Import DB logging functions
logger = get_embeddings_logger()
@@ -13,11 +19,64 @@ class EmbeddingService:
async def create_embedding(
self, input_text: Union[str, List[str]], model: str, api_key: str
) -> CreateEmbeddingResponse:
"""Create embeddings using OpenAI API"""
"""Create embeddings using OpenAI API with database logging"""
start_time = time.perf_counter()
request_datetime = datetime.datetime.now()
is_success = False
status_code = None
response = None
error_log_msg = ""
# Prepare request message for logging (truncate if list or long string)
if isinstance(input_text, list):
request_msg_log = {"input_truncated": [str(item)[:100] + "..." if len(str(item)) > 100 else str(item) for item in input_text[:5]]}
if len(input_text) > 5:
request_msg_log["input_truncated"].append("...")
else:
request_msg_log = {"input_truncated": input_text[:1000] + "..." if len(input_text) > 1000 else input_text}
try:
client = openai.OpenAI(api_key=api_key, base_url=settings.BASE_URL)
response = client.embeddings.create(input=input_text, model=model)
is_success = True
status_code = 200 # Assume 200 OK on success
return response
except APIStatusError as e:
is_success = False
status_code = e.status_code
error_log_msg = f"OpenAI API error: {e}"
logger.error(f"Error creating embedding (APIStatusError): {error_log_msg}")
raise e # Re-raise the specific error
except Exception as e:
logger.error(f"Error creating embedding: {str(e)}")
raise
is_success = False
error_log_msg = f"Generic error: {e}"
logger.error(f"Error creating embedding (Exception): {error_log_msg}")
# Try to parse status code from generic error (less reliable)
match = re.search(r"status code (\d+)", str(e))
if match:
status_code = int(match.group(1))
else:
status_code = 500 # Default if parsing fails
raise e # Re-raise the generic error
finally:
end_time = time.perf_counter()
latency_ms = int((end_time - start_time) * 1000)
if not is_success:
# Log error to database if it failed
await add_error_log(
gemini_key=api_key, # Using gemini_key parameter name for consistency
model_name=model,
error_type="openai-embedding",
error_log=error_log_msg,
error_code=status_code,
request_msg=request_msg_log
)
# Log request outcome to database regardless of success/failure
await add_request_log(
model_name=model,
api_key=api_key,
is_success=is_success,
status_code=status_code,
latency_ms=latency_ms,
request_time=request_datetime
)

View File

@@ -17,7 +17,6 @@ logger = get_image_create_logger()
class ImageCreateService:
def __init__(self, aspect_ratio="1:1"):
self.image_model = settings.CREATE_IMAGE_MODEL
self.paid_key = settings.PAID_KEY
self.aspect_ratio = aspect_ratio
def parse_prompt_parameters(self, prompt: str) -> tuple:
@@ -53,7 +52,7 @@ class ImageCreateService:
return prompt, n, aspect_ratio
def generate_images(self, request: ImageGenerationRequest):
client = genai.Client(api_key=self.paid_key)
client = genai.Client(api_key=settings.PAID_KEY)
if request.size == "1024x1024":
self.aspect_ratio = "1:1"
@@ -89,7 +88,6 @@ class ImageCreateService:
aspect_ratio=self.aspect_ratio,
safety_filter_level="BLOCK_LOW_AND_ABOVE",
person_generation="ALLOW_ADULT",
# language="auto"
),
)

View File

@@ -24,7 +24,7 @@ class ModelService:
if model_id not in settings.FILTERED_MODELS:
filtered_models_list.append(model)
else:
logger.info(f"Filtered out model: {model_id}")
logger.debug(f"Filtered out model: {model_id}")
gemini_models["models"] = filtered_models_list
return gemini_models
@@ -70,6 +70,10 @@ class ModelService:
image_model = openai_model.copy()
image_model["id"] = f"{model_id}-image"
openai_format["data"].append(image_model)
if model_id in settings.THINKING_MODELS:
non_thinking_model = openai_model.copy()
non_thinking_model["id"] = f"{model_id}-non-thinking"
openai_format["data"].append(non_thinking_model)
if settings.CREATE_IMAGE_MODEL:
image_model = openai_model.copy()

View File

@@ -9,115 +9,166 @@ from app.log.logger import get_stats_logger
logger = get_stats_logger()
async def get_calls_in_last_seconds(seconds: int) -> int:
"""获取过去 N 秒内的调用次数 (包括成功和失败)"""
try:
cutoff_time = datetime.datetime.now() - datetime.timedelta(seconds=seconds)
query = select(func.count(RequestLog.id)).where(
RequestLog.request_time >= cutoff_time
)
count_result = await database.fetch_one(query)
return count_result[0] if count_result else 0
except Exception as e:
logger.error(f"Failed to get calls in last {seconds} seconds: {e}")
return 0 # Return 0 on error
async def get_calls_in_last_minutes(minutes: int) -> int:
"""获取过去 N 分钟内的调用次数 (包括成功和失败)"""
return await get_calls_in_last_seconds(minutes * 60)
class StatsService:
"""Service class for handling statistics related operations."""
async def get_calls_in_last_hours(hours: int) -> int:
"""获取过去 N 小时内的调用次数 (包括成功和失败)"""
return await get_calls_in_last_seconds(hours * 3600)
async def get_calls_in_last_seconds(self, seconds: int) -> int:
"""获取过去 N 内的调用次数 (包括成功和失败)"""
try:
cutoff_time = datetime.datetime.now() - datetime.timedelta(seconds=seconds)
query = select(func.count(RequestLog.id)).where(
RequestLog.request_time >= cutoff_time
)
count_result = await database.fetch_one(query)
return count_result[0] if count_result else 0
except Exception as e:
logger.error(f"Failed to get calls in last {seconds} seconds: {e}")
return 0 # Return 0 on error
async def get_calls_in_current_month() -> int:
"""获取当前自然月内的调用次数 (包括成功和失败)"""
try:
async def get_calls_in_last_minutes(self, minutes: int) -> int:
"""获取过去 N 分钟内的调用次数 (包括成功和失败)"""
return await self.get_calls_in_last_seconds(minutes * 60)
async def get_calls_in_last_hours(self, hours: int) -> int:
"""获取过去 N 小时内的调用次数 (包括成功和失败)"""
return await self.get_calls_in_last_seconds(hours * 3600)
async def get_calls_in_current_month(self) -> int:
"""获取当前自然月内的调用次数 (包括成功和失败)"""
try:
now = datetime.datetime.now()
start_of_month = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
query = select(func.count(RequestLog.id)).where(
RequestLog.request_time >= start_of_month
)
count_result = await database.fetch_one(query)
return count_result[0] if count_result else 0
except Exception as e:
logger.error(f"Failed to get calls in current month: {e}")
return 0 # Return 0 on error
async def get_api_usage_stats(self) -> dict:
"""获取所有需要的 API 使用统计数据"""
try:
calls_1m = await self.get_calls_in_last_minutes(1)
calls_1h = await self.get_calls_in_last_hours(1)
calls_24h = await self.get_calls_in_last_hours(24)
calls_month = await self.get_calls_in_current_month()
return {
"calls_1m": calls_1m,
"calls_1h": calls_1h,
"calls_24h": calls_24h,
"calls_month": calls_month,
}
except Exception as e:
logger.error(f"Failed to get API usage stats: {e}")
# Return default values on error
return {
"calls_1m": 0,
"calls_1h": 0,
"calls_24h": 0,
"calls_month": 0,
}
async def get_api_call_details(self, period: str) -> list[dict]:
"""
获取指定时间段内的 API 调用详情
Args:
period: 时间段标识 ('1m', '1h', '24h')
Returns:
包含调用详情的字典列表,每个字典包含 timestamp, key, model, status
Raises:
ValueError: 如果 period 无效
"""
now = datetime.datetime.now()
start_of_month = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
query = select(func.count(RequestLog.id)).where(
RequestLog.request_time >= start_of_month
)
count_result = await database.fetch_one(query)
return count_result[0] if count_result else 0
except Exception as e:
logger.error(f"Failed to get calls in current month: {e}")
return 0 # Return 0 on error
if period == '1m':
start_time = now - datetime.timedelta(minutes=1)
elif period == '1h':
start_time = now - datetime.timedelta(hours=1)
elif period == '24h':
start_time = now - datetime.timedelta(hours=24)
else:
raise ValueError(f"无效的时间段标识: {period}")
async def get_api_usage_stats() -> dict:
"""获取所有需要的 API 使用统计数据"""
try:
calls_1m = await get_calls_in_last_minutes(1)
calls_1h = await get_calls_in_last_hours(1)
calls_24h = await get_calls_in_last_hours(24)
calls_month = await get_calls_in_current_month()
try:
query = select(
RequestLog.request_time.label("timestamp"),
RequestLog.api_key.label("key"),
RequestLog.model_name.label("model"),
RequestLog.status_code # We might need to map this to 'success'/'failure' later
).where(
RequestLog.request_time >= start_time
).order_by(RequestLog.request_time.desc()) # Order by most recent first
return {
"calls_1m": calls_1m,
"calls_1h": calls_1h,
"calls_24h": calls_24h,
"calls_month": calls_month,
}
except Exception as e:
logger.error(f"Failed to get API usage stats: {e}")
# Return default values on error
return {
"calls_1m": 0,
"calls_1h": 0,
"calls_24h": 0,
"calls_month": 0,
}
results = await database.fetch_all(query)
# Convert results to list of dicts and map status_code
details = []
for row in results:
status = 'failure' # 默认状态为 failure如果 status_code 有效且在 200-299 范围内则更新为 success
if row['status_code'] is not None: # 检查 status_code 是否为空
status = 'success' if 200 <= row['status_code'] < 300 else 'failure'
details.append({
"timestamp": row['timestamp'].isoformat(), # Use ISO format for JS compatibility
"key": row['key'],
"model": row['model'],
"status": status
})
logger.info(f"Retrieved {len(details)} API call details for period '{period}'")
return details
async def get_api_call_details(period: str) -> list[dict]:
"""
获取指定时间段内的 API 调用详情
except Exception as e:
logger.error(f"Failed to get API call details for period '{period}': {e}")
# Re-raise the exception to be handled by the route
raise
Args:
period: 时间段标识 ('1m', '1h', '24h')
async def get_key_usage_details_last_24h(self, key: str) -> dict | None:
"""
获取指定 API 密钥在过去 24 小时内按模型统计的调用次数。
Returns:
包含调用详情的字典列表,每个字典包含 timestamp, key, model, status
Args:
key: 要查询的 API 密钥。
Raises:
ValueError: 如果 period 无效
"""
now = datetime.datetime.now()
if period == '1m':
start_time = now - datetime.timedelta(minutes=1)
elif period == '1h':
start_time = now - datetime.timedelta(hours=1)
elif period == '24h':
start_time = now - datetime.timedelta(hours=24)
else:
raise ValueError(f"无效的时间段标识: {period}")
Returns:
一个字典,其中键是模型名称,值是调用次数。
如果查询出错或没有找到记录,可能返回 None 或空字典。
Example: {"gemini-pro": 10, "gemini-1.5-pro-latest": 5}
"""
logger.info(f"Fetching usage details for key ending in ...{key[-4:]} for the last 24h.")
cutoff_time = datetime.datetime.now() - datetime.timedelta(hours=24)
try:
query = select(
RequestLog.request_time.label("timestamp"),
RequestLog.api_key.label("key"),
RequestLog.model_name.label("model"),
RequestLog.status_code # We might need to map this to 'success'/'failure' later
).where(
RequestLog.request_time >= start_time
).order_by(RequestLog.request_time.desc()) # Order by most recent first
try:
query = select(
RequestLog.model_name,
func.count(RequestLog.id).label("call_count")
).where(
RequestLog.api_key == key,
RequestLog.request_time >= cutoff_time,
RequestLog.model_name.isnot(None) # Ensure model_name is not null
).group_by(
RequestLog.model_name
).order_by(
func.count(RequestLog.id).desc() # Order by count descending
)
results = await database.fetch_all(query)
results = await database.fetch_all(query)
# Convert results to list of dicts and map status_code
details = []
for row in results:
status = 'success' if 200 <= row['status_code'] < 300 else 'failure'
details.append({
"timestamp": row['timestamp'].isoformat(), # Use ISO format for JS compatibility
"key": row['key'],
"model": row['model'],
"status": status
})
logger.info(f"Retrieved {len(details)} API call details for period '{period}'")
return details
if not results:
logger.info(f"No usage details found for key ending in ...{key[-4:]} in the last 24h.")
return {} # Return empty dict if no records found
except Exception as e:
logger.error(f"Failed to get API call details for period '{period}': {e}")
# Re-raise the exception to be handled by the route
raise
usage_details = {row['model_name']: row['call_count'] for row in results}
logger.info(f"Successfully fetched usage details for key ending in ...{key[-4:]}: {usage_details}")
return usage_details
except Exception as e:
logger.error(f"Failed to get key usage details for key ending in ...{key[-4:]}: {e}", exc_info=True)
# Depending on requirements, you might return None or raise the exception
# Raising allows the route handler to return a 500 error.
raise # Re-raise the exception

View File

@@ -0,0 +1,108 @@
import httpx
from packaging import version
from typing import Optional, Tuple
from app.config.config import settings
from app.log.logger import get_update_logger
logger = get_update_logger()
# GitHub repository details are read from settings (defined in app/config/config.py or environment variables)
# GITHUB_API_URL will be constructed inside the function to ensure settings are loaded
VERSION_FILE_PATH = "VERSION" # Path relative to project root
async def check_for_updates() -> Tuple[bool, Optional[str], Optional[str]]:
"""
通过比较当前版本与最新的 GitHub release 来检查应用程序更新。
Returns:
Tuple[bool, Optional[str], Optional[str]]: 一个元组,包含:
- bool: 如果有可用更新则为 True否则为 False。
- Optional[str]: 如果有可用更新,则为最新的版本字符串,否则为 None。
- Optional[str]: 如果检查失败,则为错误消息,否则为 None。
"""
try:
# Read current version from VERSION file
# Ensure the path is correct relative to the execution context or use absolute path if needed
# Assuming execution from project root d:/develop/pythonProjects/gemini-balance
with open(VERSION_FILE_PATH, 'r', encoding='utf-8') as f:
current_v = f.read().strip()
if not current_v:
logger.error(f"VERSION file ('{VERSION_FILE_PATH}') is empty.")
return False, None, f"VERSION file ('{VERSION_FILE_PATH}') is empty."
except FileNotFoundError:
logger.error(f"VERSION file not found at '{VERSION_FILE_PATH}'. Make sure it exists in the project root.")
return False, None, f"VERSION file not found at '{VERSION_FILE_PATH}'."
except IOError as e:
logger.error(f"Error reading VERSION file ('{VERSION_FILE_PATH}'): {e}")
return False, None, f"Error reading VERSION file ('{VERSION_FILE_PATH}')."
logger.info(f"当前应用程序版本 (from {VERSION_FILE_PATH}): {current_v}")
# Check if repository details are configured in settings
if not settings.GITHUB_REPO_OWNER or not settings.GITHUB_REPO_NAME or \
settings.GITHUB_REPO_OWNER == "your_owner" or settings.GITHUB_REPO_NAME == "your_repo":
logger.warning("GitHub repository owner/name not configured in settings. Skipping update check.")
return False, None, "Update check skipped: Repository not configured in settings."
# Construct the API URL inside the function to ensure settings are loaded
github_api_url = f"https://api.github.com/repos/{settings.GITHUB_REPO_OWNER}/{settings.GITHUB_REPO_NAME}/releases/latest"
logger.debug(f"Checking for updates at URL: {github_api_url}") # Log the URL for debugging
try:
async with httpx.AsyncClient(timeout=10.0) as client:
# 添加 User-Agent 头GitHub API 可能需要
headers = {
"Accept": "application/vnd.github.v3+json",
"User-Agent": f"{settings.GITHUB_REPO_NAME}-UpdateChecker/1.0" # Use repo name from settings for User-Agent
}
response = await client.get(github_api_url, headers=headers) # Use the locally constructed URL
response.raise_for_status() # 对错误的 HTTP 状态码4xx 或 5xx抛出异常
latest_release = response.json()
latest_v_str = latest_release.get("tag_name")
if not latest_v_str:
logger.warning("在最新的 GitHub release 响应中找不到 'tag_name'")
return False, None, "无法从 GitHub 解析最新版本。"
# 移除 tag 名称中可能存在的 'v' 前缀
if latest_v_str.startswith('v'):
latest_v_str = latest_v_str[1:]
logger.info(f"在 GitHub 上找到的最新版本: {latest_v_str}")
# 比较版本
current_version = version.parse(current_v)
latest_version = version.parse(latest_v_str)
if latest_version > current_version:
logger.info(f"有可用更新: {current_v} -> {latest_v_str}")
return True, latest_v_str, None
else:
logger.info("应用程序已是最新版本。")
return False, None, None
except httpx.HTTPStatusError as e:
logger.error(f"检查更新时发生 HTTP 错误: {e.response.status_code} - {e.response.text}")
# 避免向用户显示详细的错误文本
error_msg = f"获取更新信息失败 (HTTP {e.response.status_code})。"
if e.response.status_code == 404:
error_msg += " 请检查仓库名称是否正确或仓库是否有发布版本。"
elif e.response.status_code == 403:
error_msg += " API 速率限制或权限问题。"
return False, None, error_msg
except httpx.RequestError as e:
logger.error(f"检查更新时发生网络错误: {e}")
return False, None, "更新检查期间发生网络错误。"
except version.InvalidVersion:
# Note: latest_v_str might not be defined if the error occurs before fetching it.
# Consider adding a check or default value for logging.
latest_v_str_for_log = latest_v_str if 'latest_v_str' in locals() else 'N/A'
logger.error(f"发现无效的版本格式。当前 (from {VERSION_FILE_PATH}): '{current_v}', 最新: '{latest_v_str_for_log}'")
return False, None, "遇到无效的版本格式。"
except Exception as e:
logger.error(f"更新检查期间发生意外错误: {e}", exc_info=True)
return False, None, "发生意外错误。"

View File

@@ -1,7 +1,7 @@
document.addEventListener('DOMContentLoaded', function() {
// 初始化配置
initConfig();
// 标签切换
const tabButtons = document.querySelectorAll('.tab-btn');
tabButtons.forEach(button => {
@@ -12,7 +12,7 @@ document.addEventListener('DOMContentLoaded', function() {
switchTab(tabId);
});
});
// 上传提供商切换
const uploadProviderSelect = document.getElementById('UPLOAD_PROVIDER');
if (uploadProviderSelect) {
@@ -20,7 +20,7 @@ document.addEventListener('DOMContentLoaded', function() {
toggleProviderConfig(this.value);
});
}
// 切换按钮事件
const toggleSwitches = document.querySelectorAll('.toggle-switch');
toggleSwitches.forEach(toggleSwitch => {
@@ -33,19 +33,19 @@ document.addEventListener('DOMContentLoaded', function() {
}
});
});
// 保存按钮
const saveBtn = document.getElementById('saveBtn');
if (saveBtn) {
saveBtn.addEventListener('click', saveConfig);
}
// 重置按钮
const resetBtn = document.getElementById('resetBtn');
if (resetBtn) {
resetBtn.addEventListener('click', resetConfig);
}
// 滚动按钮
window.addEventListener('scroll', toggleScrollButtons);
@@ -197,55 +197,97 @@ document.addEventListener('DOMContentLoaded', function() {
showNotification('已生成新认证令牌', 'success');
});
}
// --- 修改:思考模型预算映射不再需要手动添加按钮 ---
// const addBudgetMapItemBtn = document.getElementById('addBudgetMapItemBtn');
// if (addBudgetMapItemBtn) {
// addBudgetMapItemBtn.addEventListener('click', addBudgetMapItem);
// }
// --- 结束:思考模型预算映射相关 ---
// 添加事件委托,处理动态添加的 THINKING_MODELS 输入框的 input 事件
const thinkingModelsContainer = document.getElementById('THINKING_MODELS_container');
if (thinkingModelsContainer) {
thinkingModelsContainer.addEventListener('input', function(event) {
if (event.target && event.target.classList.contains('array-input') && event.target.closest('.array-item[data-model-id]')) {
const modelInput = event.target;
const modelId = modelInput.closest('.array-item').getAttribute('data-model-id');
const budgetKeyInput = document.querySelector(`.map-key-input[data-model-id="${modelId}"]`);
if (budgetKeyInput) {
budgetKeyInput.value = modelInput.value;
}
}
});
}
}); // <-- DOMContentLoaded 结束括号
// --- 新增生成唯一ID ---
function generateUUID() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
// --- 结束生成唯一ID ---
// 初始化配置
async function initConfig() {
try {
showNotification('正在加载配置...', 'info');
const response = await fetch('/api/config');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const config = await response.json();
// 确保数组字段有默认值
if (!config.API_KEYS || !Array.isArray(config.API_KEYS) || config.API_KEYS.length === 0) {
config.API_KEYS = ['请在此处输入 API 密钥'];
}
if (!config.ALLOWED_TOKENS || !Array.isArray(config.ALLOWED_TOKENS) || config.ALLOWED_TOKENS.length === 0) {
config.ALLOWED_TOKENS = [''];
}
if (!config.IMAGE_MODELS || !Array.isArray(config.IMAGE_MODELS) || config.IMAGE_MODELS.length === 0) {
config.IMAGE_MODELS = ['gemini-1.5-pro-latest'];
}
if (!config.SEARCH_MODELS || !Array.isArray(config.SEARCH_MODELS) || config.SEARCH_MODELS.length === 0) {
config.SEARCH_MODELS = ['gemini-1.5-flash-latest'];
}
if (!config.FILTERED_MODELS || !Array.isArray(config.FILTERED_MODELS) || config.FILTERED_MODELS.length === 0) {
config.FILTERED_MODELS = ['gemini-1.0-pro-latest'];
}
// --- 新增:处理新字段的默认值 ---
if (!config.THINKING_MODELS || !Array.isArray(config.THINKING_MODELS)) {
config.THINKING_MODELS = []; // 默认为空数组
}
if (!config.THINKING_BUDGET_MAP || typeof config.THINKING_BUDGET_MAP !== 'object' || config.THINKING_BUDGET_MAP === null) {
config.THINKING_BUDGET_MAP = {}; // 默认为空对象
}
// --- 结束:处理新字段的默认值 ---
populateForm(config);
// 确保上传提供商有默认值
const uploadProvider = document.getElementById('UPLOAD_PROVIDER');
if (uploadProvider && !uploadProvider.value) {
uploadProvider.value = 'smms'; // 设置默认值为 smms
toggleProviderConfig('smms');
}
showNotification('配置加载成功', 'success');
} catch (error) {
console.error('加载配置失败:', error);
showNotification('加载配置失败: ' + error.message, 'error');
// 加载失败时,使用默认配置
const defaultConfig = {
API_KEYS: [''],
@@ -253,9 +295,11 @@ async function initConfig() {
IMAGE_MODELS: ['gemini-1.5-pro-latest'],
SEARCH_MODELS: ['gemini-1.5-flash-latest'],
FILTERED_MODELS: ['gemini-1.0-pro-latest'],
UPLOAD_PROVIDER: 'smms'
UPLOAD_PROVIDER: 'smms',
THINKING_MODELS: [],
THINKING_BUDGET_MAP: {}
};
populateForm(defaultConfig);
toggleProviderConfig('smms');
}
@@ -263,40 +307,105 @@ async function initConfig() {
// 填充表单
function populateForm(config) {
for (const [key, value] of Object.entries(config)) {
// 首先检查是否是数组类型
if (Array.isArray(value)) {
const container = document.getElementById(`${key}_container`);
if (container) {
// 清除现有项
const existingItems = container.querySelectorAll('.array-item');
existingItems.forEach(item => item.remove());
// 添加数组项
value.forEach(item => {
// 确保只添加非空字符串项(如果需要)
// if (item && typeof item === 'string' && item.trim() !== '') {
addArrayItemWithValue(key, item);
// }
});
}
// 处理完数组后,跳过本次循环的剩余部分
continue;
}
const modelIdMap = {}; // modelName -> modelId
// 如果不是数组,再尝试查找对应的单个元素
const element = document.getElementById(key);
if (element) {
if (typeof value === 'boolean') {
element.checked = value;
} else {
// 处理其他类型 (确保 value 不是 null 或 undefined)
element.value = value ?? ''; // 使用空字符串作为默认值
}
}
// 如果既不是数组,也找不到对应 ID 的元素,则忽略该配置项
// 1. Clear existing dynamic content first
const arrayContainers = document.querySelectorAll('.array-container');
arrayContainers.forEach(container => {
container.innerHTML = ''; // Clear all array containers
});
const budgetMapContainer = document.getElementById('THINKING_BUDGET_MAP_container');
if (budgetMapContainer) {
budgetMapContainer.innerHTML = ''; // Clear budget map container
} else {
console.error("Critical: THINKING_BUDGET_MAP_container not found!");
return; // Cannot proceed
}
// 初始化上传提供商配置 (保持不变)
// 2. Populate THINKING_MODELS and build the map
if (Array.isArray(config.THINKING_MODELS)) {
const container = document.getElementById('THINKING_MODELS_container');
if (container) {
config.THINKING_MODELS.forEach(modelName => {
if (modelName && typeof modelName === 'string' && modelName.trim()) {
const trimmedModelName = modelName.trim();
// Call addArrayItemWithValue to add the model DOM element and get its ID
const modelId = addArrayItemWithValue('THINKING_MODELS', trimmedModelName);
if (modelId) {
modelIdMap[trimmedModelName] = modelId;
} else {
console.warn(`Failed to get modelId for THINKING_MODEL: '${trimmedModelName}'`);
}
} else {
console.warn(`Invalid THINKING_MODEL entry found:`, modelName);
}
});
} else {
console.error("Critical: THINKING_MODELS_container not found!");
}
}
// 3. Populate THINKING_BUDGET_MAP using the map
let budgetItemsAdded = false;
if (config.THINKING_BUDGET_MAP && typeof config.THINKING_BUDGET_MAP === 'object') {
for (const [modelName, budgetValue] of Object.entries(config.THINKING_BUDGET_MAP)) {
if (modelName && typeof modelName === 'string') {
const trimmedModelName = modelName.trim();
const modelId = modelIdMap[trimmedModelName]; // Look up the ID
if (modelId) {
// Call the function specifically designed to add ONLY the budget map DOM element
createAndAppendBudgetMapItem(trimmedModelName, budgetValue, modelId);
budgetItemsAdded = true;
} else {
// Log if a budget entry exists but its corresponding model wasn't found/added
console.warn(`Budget map: Could not find model ID for '${trimmedModelName}'. Skipping budget item.`);
}
} else {
console.warn(`Invalid key found in THINKING_BUDGET_MAP:`, modelName);
}
}
}
// Add placeholder only if no budget items were successfully added
if (!budgetItemsAdded && budgetMapContainer) {
budgetMapContainer.innerHTML = '<div class="text-gray-500 text-sm italic">请在上方添加思考模型,预算将自动关联。</div>';
}
// 4. Populate other array fields (excluding THINKING_MODELS)
for (const [key, value] of Object.entries(config)) {
if (Array.isArray(value) && key !== 'THINKING_MODELS') {
const container = document.getElementById(`${key}_container`);
if (container) {
// Container already cleared, just add items
value.forEach(itemValue => {
if (typeof itemValue === 'string') {
addArrayItemWithValue(key, itemValue); // This adds non-thinking model array items
} else {
console.warn(`Invalid item found in array '${key}':`, itemValue);
}
});
}
}
}
// 5. Populate non-array/non-budget fields
for (const [key, value] of Object.entries(config)) {
if (!Array.isArray(value) && !(typeof value === 'object' && value !== null && key === 'THINKING_BUDGET_MAP')) {
const element = document.getElementById(key);
if (element) {
if (element.type === 'checkbox' && typeof value === 'boolean') {
element.checked = value;
} else if (element.type !== 'checkbox') {
if (key === 'LOG_LEVEL' && typeof value === 'string') {
element.value = value.toUpperCase();
} else {
element.value = (value !== null && value !== undefined) ? value : '';
}
}
}
}
}
// 6. Initialize upload provider
const uploadProvider = document.getElementById('UPLOAD_PROVIDER');
if (uploadProvider) {
toggleProviderConfig(uploadProvider.value);
@@ -427,7 +536,7 @@ function switchTab(tabId) {
button.classList.add('bg-white', 'bg-opacity-50', 'text-gray-700', 'hover:bg-opacity-70');
}
});
// 更新内容区域
const sections = document.querySelectorAll('.config-section');
sections.forEach(section => {
@@ -456,18 +565,31 @@ function toggleProviderConfig(provider) {
function addArrayItem(key) {
const container = document.getElementById(`${key}_container`);
if (!container) return;
addArrayItemWithValue(key, '');
const newItemValue = ''; // Start with an empty value for new items
const modelId = addArrayItemWithValue(key, newItemValue); // Add the DOM element
// If it's a thinking model, also add the corresponding budget map item
if (key === 'THINKING_MODELS' && modelId) {
createAndAppendBudgetMapItem(newItemValue, 0, modelId); // Default budget 0
}
}
// 添加带值的数组项
// 添加带值的数组项 (Adds array item DOM, returns modelId if it's a thinking model)
function addArrayItemWithValue(key, value) {
const container = document.getElementById(`${key}_container`);
if (!container) return;
if (!container) return null;
const isThinkingModel = key === 'THINKING_MODELS';
const modelId = isThinkingModel ? generateUUID() : null;
const arrayItem = document.createElement('div');
// 主容器使用 Flexbox
arrayItem.className = 'array-item flex items-center mb-2 gap-2'; // 添加 gap-2 来分隔元素
if (isThinkingModel) {
arrayItem.setAttribute('data-model-id', modelId); // 添加ID属性
}
// 创建一个包装器 div 来包含输入框和生成按钮
const inputWrapper = document.createElement('div');
@@ -480,6 +602,11 @@ function addArrayItemWithValue(key, value) {
input.value = value;
// 输入框占据包装器内的主要空间,移除边框和圆角,因为包装器已有
input.className = 'array-input flex-grow px-3 py-2 border-none rounded-l-md focus:outline-none'; // 移除右侧圆角
if (isThinkingModel) {
input.setAttribute('data-model-id', modelId); // 添加ID属性
input.placeholder = '思考模型名称'; // 添加占位符
}
inputWrapper.appendChild(input); // 将输入框添加到包装器
@@ -509,7 +636,21 @@ function addArrayItemWithValue(key, value) {
removeBtn.innerHTML = '<i class="fas fa-trash-alt"></i>';
removeBtn.title = '删除';
removeBtn.addEventListener('click', function() {
arrayItem.remove();
const currentArrayItem = this.closest('.array-item');
if (isThinkingModel) {
const currentModelId = currentArrayItem.getAttribute('data-model-id');
// 查找并删除对应的预算映射项
const budgetMapItem = document.querySelector(`.map-item[data-model-id="${currentModelId}"]`);
if (budgetMapItem) {
budgetMapItem.remove();
// 检查预算映射容器是否为空,如果是,则添加回占位符
const budgetContainer = document.getElementById('THINKING_BUDGET_MAP_container');
if (budgetContainer && budgetContainer.children.length === 0) {
budgetContainer.innerHTML = '<div class="text-gray-500 text-sm italic">请在上方添加思考模型,预算将自动关联。</div>';
}
}
}
currentArrayItem.remove(); // 删除模型项本身
});
// 将包装器(包含输入框和可能的生成按钮)和删除按钮添加到主容器
@@ -518,12 +659,83 @@ function addArrayItemWithValue(key, value) {
// 插入到容器末尾
container.appendChild(arrayItem);
// 返回生成的 ID (如果是思考模型) 或 null
return isThinkingModel ? modelId : null;
// Note: This function no longer automatically calls createAndAppendBudgetMapItem
}
// --- 新增:专门用于创建和添加预算映射 DOM 元素 ---
function createAndAppendBudgetMapItem(mapKey, mapValue, modelId) {
const container = document.getElementById('THINKING_BUDGET_MAP_container');
if (!container) {
console.error("Cannot add budget item: THINKING_BUDGET_MAP_container not found!");
return;
}
// If container currently only has the placeholder, clear it
const placeholder = container.querySelector('.text-gray-500.italic');
// Check if the only child is the placeholder before clearing
if (placeholder && container.children.length === 1 && container.firstChild === placeholder) {
container.innerHTML = '';
}
const mapItem = document.createElement('div');
mapItem.className = 'map-item flex items-center mb-2 gap-2';
mapItem.setAttribute('data-model-id', modelId); // Add ID attribute
// Key Input (Model Name) - Read-only
const keyInput = document.createElement('input');
keyInput.type = 'text';
keyInput.value = mapKey;
keyInput.placeholder = '模型名称 (自动关联)';
keyInput.readOnly = true;
keyInput.className = 'map-key-input flex-grow px-3 py-2 border border-gray-300 rounded-md focus:outline-none bg-gray-100 text-gray-500';
keyInput.setAttribute('data-model-id', modelId);
// Value Input (Budget) - Integer
const valueInput = document.createElement('input');
valueInput.type = 'number';
// Ensure mapValue is treated as integer, default to 0 if invalid
const intValue = parseInt(mapValue, 10);
valueInput.value = isNaN(intValue) ? 0 : intValue;
valueInput.placeholder = '预算 (整数)';
valueInput.className = 'map-value-input w-24 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50';
valueInput.min = 0; // 添加最小值
valueInput.max = 24576; // 添加最大值
valueInput.addEventListener('input', function() {
// 限制输入为0到24576之间的整数
let value = this.value.replace(/[^0-9]/g, '');
if (value !== '') {
value = parseInt(value, 10);
if (value < 0) value = 0;
if (value > 24576) value = 24576;
}
this.value = value;
});
// Remove Button - Removed for budget map items
// const removeBtn = document.createElement('button');
// removeBtn.type = 'button';
// removeBtn.className = 'remove-btn text-gray-300 cursor-not-allowed focus:outline-none'; // Kept original class for reference
// removeBtn.innerHTML = '<i class="fas fa-trash-alt"></i>';
// removeBtn.title = '请从上方模型列表删除';
// removeBtn.disabled = true;
mapItem.appendChild(keyInput);
mapItem.appendChild(valueInput);
// mapItem.appendChild(removeBtn); // Do not append the remove button
container.appendChild(mapItem);
}
// --- 结束:专门的预算映射项创建函数 ---
// 收集表单数据
function collectFormData() {
const formData = {};
// 处理普通输入
const inputs = document.querySelectorAll('input[type="text"], input[type="number"], select');
inputs.forEach(input => {
@@ -531,17 +743,18 @@ function collectFormData() {
if (input.type === 'number') {
formData[input.name] = parseFloat(input.value);
} else {
// 确保 select 元素的值也被正确收集
formData[input.name] = input.value;
}
}
});
// 处理复选框
const checkboxes = document.querySelectorAll('input[type="checkbox"]');
checkboxes.forEach(checkbox => {
formData[checkbox.name] = checkbox.checked;
});
// 处理数组
const arrayContainers = document.querySelectorAll('.array-container');
arrayContainers.forEach(container => {
@@ -549,7 +762,25 @@ function collectFormData() {
const arrayInputs = container.querySelectorAll('.array-input');
formData[key] = Array.from(arrayInputs).map(input => input.value).filter(value => value.trim() !== '');
});
// --- 新增:处理 THINKING_BUDGET_MAP ---
const budgetMapContainer = document.getElementById('THINKING_BUDGET_MAP_container');
if (budgetMapContainer) {
formData['THINKING_BUDGET_MAP'] = {};
const mapItems = budgetMapContainer.querySelectorAll('.map-item');
mapItems.forEach(item => {
const keyInput = item.querySelector('.map-key-input');
const valueInput = item.querySelector('.map-value-input');
if (keyInput && valueInput && keyInput.value.trim() !== '') {
// 将预算值解析为整数
const budgetValue = parseInt(valueInput.value, 10); // 使用基数10
// 检查是否为有效数字,如果不是则默认为 0
formData['THINKING_BUDGET_MAP'][keyInput.value.trim()] = isNaN(budgetValue) ? 0 : budgetValue;
}
});
}
// --- 结束:处理 THINKING_BUDGET_MAP ---
return formData;
}
@@ -590,7 +821,7 @@ async function saveConfig() {
// 1. 停止定时任务
await stopScheduler();
const response = await fetch('/api/config', {
method: 'PUT',
headers: {
@@ -598,16 +829,16 @@ async function saveConfig() {
},
body: JSON.stringify(formData)
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.detail || `HTTP error! status: ${response.status}`);
}
const result = await response.json();
// 移除居中的 saveStatus 提示
showNotification('配置保存成功', 'success');
// 3. 启动新的定时任务
@@ -618,21 +849,21 @@ async function saveConfig() {
// 保存失败时,也尝试重启定时任务,以防万一
await startScheduler();
// 移除居中的 saveStatus 提示
showNotification('保存配置失败: ' + error.message, 'error');
}
}
// 重置配置 (现在只负责打开模态框)
function resetConfig(event) {
function resetConfig(event) {
// 阻止事件冒泡和默认行为
if (event) {
event.preventDefault();
event.stopPropagation();
}
console.log('resetConfig called. Event target:', event ? event.target.id : 'No event');
// 确保只有当事件来自重置按钮时才显示模态框
if (!event || event.target.id === 'resetBtn' || event.currentTarget.id === 'resetBtn') {
const resetConfirmModal = document.getElementById('resetConfirmModal');
@@ -680,29 +911,17 @@ async function executeReset() {
function showNotification(message, type = 'info') {
const notification = document.getElementById('notification');
notification.textContent = message;
// 设置适当的样式
if (type === 'error') {
notification.classList.add('bg-danger-500');
notification.classList.remove('bg-black');
} else {
notification.classList.remove('bg-danger-500');
notification.classList.add('bg-black');
// 可以为不同类型设置不同的颜色
if (type === 'success') {
notification.style.backgroundColor = '#22c55e'; // 绿色
} else if (type === 'info') {
notification.style.backgroundColor = '#3b82f6'; // 蓝色
} else if (type === 'warning') {
notification.style.backgroundColor = '#f59e0b'; // 橙色
}
}
// 应用过渡效果 - 与keys_status.js中一致
// 统一样式为黑色半透明,与 keys_status.js 保持一致
notification.classList.remove('bg-danger-500');
notification.classList.add('bg-black');
notification.style.backgroundColor = 'rgba(0,0,0,0.8)';
notification.style.color = '#fff';
// 应用过渡效果
notification.style.opacity = "1";
notification.style.transform = "translate(-50%, 0)";
// 设置自动消失
setTimeout(() => {
notification.style.opacity = "0";
@@ -735,7 +954,7 @@ function scrollToBottom() {
// 切换滚动按钮显示
function toggleScrollButtons() {
const scrollButtons = document.querySelector('.scroll-buttons');
if (window.scrollY > 200) {
scrollButtons.style.display = 'flex';
} else {
@@ -755,3 +974,17 @@ function generateRandomToken() {
return result;
}
// --- 结束:生成随机令牌函数 ---
// --- 修改:添加思考模型预算映射项 (现在由添加思考模型触发) ---
// function addBudgetMapItem() {
// // 不再需要手动添加
// }
// Deprecated: This function is now effectively replaced by createAndAppendBudgetMapItem
// for the initial population logic. It delegates to the new function if called.
function addBudgetMapItemWithValue(mapKey, mapValue, modelId) {
// console.warn("Deprecated call to addBudgetMapItemWithValue, use createAndAppendBudgetMapItem instead for population.");
// Delegate to the new function which handles DOM creation
createAndAppendBudgetMapItem(mapKey, mapValue, modelId);
}
/* --- 结束:(addBudgetMapItemWithValue 已弃用) --- */

View File

@@ -17,9 +17,14 @@ let currentPage = 1;
let pageSize = 10;
// let totalPages = 1; // totalPages will be calculated dynamically based on API response if available, or based on fetched data length
let errorLogs = []; // Store fetched logs for details view
let currentSort = { // 新增:存储当前排序状态
field: 'id', // 默认按 ID 排序
order: 'desc' // 默认降序
};
let currentSearch = { // Store current search parameters
key: '',
error: '',
errorCode: '', // Added error code search
startDate: '',
endDate: ''
};
@@ -36,11 +41,24 @@ let logDetailModal;
let modalCloseBtns; // Collection of close buttons for the modal
let keySearchInput;
let errorSearchInput;
let errorCodeSearchInput; // Added error code input
let startDateInput;
let endDateInput;
let searchBtn;
let pageInput; // 新增:页码输入框
let goToPageBtn; // 新增:跳转按钮
let pageInput;
let goToPageBtn;
let selectAllCheckbox; // 新增:全选复选框
let copySelectedKeysBtn; // 新增:复制选中按钮
let deleteSelectedBtn; // 新增:批量删除按钮
let sortByIdHeader; // 新增ID 排序表头
let sortIcon; // 新增:排序图标
let selectedCountSpan; // 新增:选中计数显示
let deleteConfirmModal; // 新增:删除确认模态框
let closeDeleteConfirmModalBtn; // 新增:关闭删除模态框按钮
let cancelDeleteBtn; // 新增:取消删除按钮
let confirmDeleteBtn; // 新增:确认删除按钮
let deleteConfirmMessage; // 新增:删除确认消息元素
let idsToDeleteGlobally = []; // 新增存储待删除的ID
// 页面加载完成后执行
document.addEventListener('DOMContentLoaded', function() {
@@ -57,11 +75,25 @@ document.addEventListener('DOMContentLoaded', function() {
modalCloseBtns = document.querySelectorAll('#closeLogDetailModalBtn, #closeModalFooterBtn');
keySearchInput = document.getElementById('keySearch');
errorSearchInput = document.getElementById('errorSearch');
errorCodeSearchInput = document.getElementById('errorCodeSearch'); // Get error code input
startDateInput = document.getElementById('startDate');
endDateInput = document.getElementById('endDate');
searchBtn = document.getElementById('searchBtn');
pageInput = document.getElementById('pageInput'); // 新增
goToPageBtn = document.getElementById('goToPageBtn'); // 新增
pageInput = document.getElementById('pageInput');
goToPageBtn = document.getElementById('goToPageBtn');
selectAllCheckbox = document.getElementById('selectAllCheckbox'); // 新增
copySelectedKeysBtn = document.getElementById('copySelectedKeysBtn'); // 新增
deleteSelectedBtn = document.getElementById('deleteSelectedBtn'); // 新增
sortByIdHeader = document.getElementById('sortById'); // 新增
if (sortByIdHeader) {
sortIcon = sortByIdHeader.querySelector('i'); // 新增
}
selectedCountSpan = document.getElementById('selectedCount'); // 新增
deleteConfirmModal = document.getElementById('deleteConfirmModal'); // 新增
closeDeleteConfirmModalBtn = document.getElementById('closeDeleteConfirmModalBtn'); // 新增
cancelDeleteBtn = document.getElementById('cancelDeleteBtn'); // 新增
confirmDeleteBtn = document.getElementById('confirmDeleteBtn'); // 新增
deleteConfirmMessage = document.getElementById('deleteConfirmMessage'); // 新增
// Initialize page size selector
if (pageSizeSelector) {
@@ -81,6 +113,7 @@ document.addEventListener('DOMContentLoaded', function() {
// Update search parameters from input fields
currentSearch.key = keySearchInput ? keySearchInput.value.trim() : '';
currentSearch.error = errorSearchInput ? errorSearchInput.value.trim() : '';
currentSearch.errorCode = errorCodeSearchInput ? errorCodeSearchInput.value.trim() : ''; // Get error code value
currentSearch.startDate = startDateInput ? startDateInput.value : '';
currentSearch.endDate = endDateInput ? endDateInput.value : '';
currentPage = 1; // Reset to first page on new search
@@ -104,8 +137,11 @@ document.addEventListener('DOMContentLoaded', function() {
// Initial load of error logs
loadErrorLogs();
// Add event listeners for copy buttons inside the modal
setupCopyButtons();
// Add event listeners for copy buttons inside the modal and table
setupCopyButtons(); // This will now also handle table copy buttons if called after render
// Add event listeners for bulk selection
setupBulkSelectionListeners(); // 新增:设置批量选择监听器
// 新增:为页码跳转按钮添加事件监听器
if (goToPageBtn && pageInput) {
@@ -132,8 +168,63 @@ document.addEventListener('DOMContentLoaded', function() {
}
});
}
// 新增:为批量删除按钮添加事件监听器
if (deleteSelectedBtn) {
deleteSelectedBtn.addEventListener('click', handleDeleteSelected);
}
// 新增:为 ID 排序表头添加事件监听器
if (sortByIdHeader) {
sortByIdHeader.addEventListener('click', handleSortById);
}
// 新增:为删除确认模态框按钮添加事件监听器
if (closeDeleteConfirmModalBtn) {
closeDeleteConfirmModalBtn.addEventListener('click', hideDeleteConfirmModal);
}
if (cancelDeleteBtn) {
cancelDeleteBtn.addEventListener('click', hideDeleteConfirmModal);
}
if (confirmDeleteBtn) {
confirmDeleteBtn.addEventListener('click', handleConfirmDelete);
}
// Optional: Close modal if clicking outside the content
if (deleteConfirmModal) {
deleteConfirmModal.addEventListener('click', function(event) {
if (event.target === deleteConfirmModal) {
hideDeleteConfirmModal();
}
});
}
});
// 新增:显示删除确认模态框
function showDeleteConfirmModal(message) {
if (deleteConfirmModal && deleteConfirmMessage) {
deleteConfirmMessage.textContent = message;
deleteConfirmModal.classList.add('show');
document.body.style.overflow = 'hidden'; // Prevent body scrolling
}
}
// 新增:隐藏删除确认模态框
function hideDeleteConfirmModal() {
if (deleteConfirmModal) {
deleteConfirmModal.classList.remove('show');
document.body.style.overflow = ''; // Restore body scrolling
idsToDeleteGlobally = []; // 清空待删除ID
}
}
// 新增:处理确认删除按钮点击
function handleConfirmDelete() {
if (idsToDeleteGlobally.length > 0) {
performActualDelete(idsToDeleteGlobally);
}
hideDeleteConfirmModal(); // 关闭模态框
}
// Fallback copy function using document.execCommand
function fallbackCopyTextToClipboard(text) {
const textArea = document.createElement("textarea");
@@ -174,44 +265,315 @@ function handleCopyResult(buttonElement, success) {
setTimeout(() => { iconElement.className = originalIcon; }, success ? 2000 : 3000); // Restore original icon class
}
// Function to set up copy button listeners (using modern API with fallback)
function setupCopyButtons() {
const copyButtons = document.querySelectorAll('.copy-btn');
// Function to set up copy button listeners (using modern API with fallback) - Updated to handle table copy buttons
function setupCopyButtons(containerSelector = 'body') {
// Find buttons within the specified container (defaults to body)
const container = document.querySelector(containerSelector);
if (!container) return;
const copyButtons = container.querySelectorAll('.copy-btn');
copyButtons.forEach(button => {
button.addEventListener('click', function() {
const targetId = this.getAttribute('data-target');
const targetElement = document.getElementById(targetId);
if (targetElement) {
const textToCopy = targetElement.textContent;
let copySuccess = false;
// Try modern clipboard API first (requires HTTPS or localhost)
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(textToCopy).then(() => {
handleCopyResult(this, true); // Use helper for feedback
}).catch(err => {
console.error('Clipboard API failed, attempting fallback:', err);
// Attempt fallback if modern API fails
copySuccess = fallbackCopyTextToClipboard(textToCopy);
handleCopyResult(this, copySuccess); // Use helper for feedback
});
} else {
// Use fallback if modern API is not available or context is insecure
console.warn("Clipboard API not available or context insecure. Using fallback copy method.");
copySuccess = fallbackCopyTextToClipboard(textToCopy);
handleCopyResult(this, copySuccess); // Use helper for feedback
}
} else {
console.error('Target element not found:', targetId);
showNotification('复制出错:找不到目标元素', 'error');
}
});
// Remove existing listener to prevent duplicates if called multiple times
button.removeEventListener('click', handleCopyButtonClick);
// Add the listener
button.addEventListener('click', handleCopyButtonClick);
});
}
// Extracted click handler logic for reusability and removing listeners
function handleCopyButtonClick() {
const button = this; // 'this' refers to the button clicked
const targetId = button.getAttribute('data-target');
const textToCopyDirect = button.getAttribute('data-copy-text'); // For direct text copy (e.g., table key)
let textToCopy = '';
if (textToCopyDirect) {
textToCopy = textToCopyDirect;
} else if (targetId) {
const targetElement = document.getElementById(targetId);
if (targetElement) {
textToCopy = targetElement.textContent;
} else {
console.error('Target element not found:', targetId);
showNotification('复制出错:找不到目标元素', 'error');
return; // Exit if target element not found
}
} else {
console.error('No data-target or data-copy-text attribute found on button:', button);
showNotification('复制出错:未指定复制内容', 'error');
return; // Exit if no source specified
}
if (textToCopy) {
let copySuccess = false;
// Try modern clipboard API first (requires HTTPS or localhost)
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(textToCopy).then(() => {
handleCopyResult(button, true); // Use helper for feedback
}).catch(err => {
console.error('Clipboard API failed, attempting fallback:', err);
// Attempt fallback if modern API fails
copySuccess = fallbackCopyTextToClipboard(textToCopy);
handleCopyResult(button, copySuccess); // Use helper for feedback
});
} else {
// Use fallback if modern API is not available or context is insecure
console.warn("Clipboard API not available or context insecure. Using fallback copy method.");
copySuccess = fallbackCopyTextToClipboard(textToCopy);
handleCopyResult(button, copySuccess); // Use helper for feedback
}
} else {
console.warn('No text found to copy for target:', targetId || 'direct text');
showNotification('没有内容可复制', 'warning');
}
} // End of handleCopyButtonClick function
// Function to set up copy button listeners (using modern API with fallback) - Updated to handle table copy buttons
function setupCopyButtons(containerSelector = 'body') {
// Find buttons within the specified container (defaults to body)
const container = document.querySelector(containerSelector);
if (!container) return;
const copyButtons = container.querySelectorAll('.copy-btn');
copyButtons.forEach(button => {
// Remove existing listener to prevent duplicates if called multiple times
button.removeEventListener('click', handleCopyButtonClick);
// Add the listener
button.addEventListener('click', handleCopyButtonClick);
});
}
// 新增:设置批量选择相关的事件监听器
function setupBulkSelectionListeners() {
if (selectAllCheckbox) {
selectAllCheckbox.addEventListener('change', handleSelectAllChange);
}
if (tableBody) {
// 使用事件委托处理行复选框的点击
tableBody.addEventListener('change', handleRowCheckboxChange);
}
if (copySelectedKeysBtn) {
copySelectedKeysBtn.addEventListener('click', handleCopySelectedKeys);
}
// 新增:为批量删除按钮添加事件监听器 (如果尚未添加)
// 通常在 DOMContentLoaded 中添加一次即可
// if (deleteSelectedBtn && !deleteSelectedBtn.hasListener) {
// deleteSelectedBtn.addEventListener('click', handleDeleteSelected);
// deleteSelectedBtn.hasListener = true; // 标记已添加
// }
}
// 新增:处理“全选”复选框变化的函数
function handleSelectAllChange() {
const isChecked = selectAllCheckbox.checked;
const rowCheckboxes = tableBody.querySelectorAll('.row-checkbox');
rowCheckboxes.forEach(checkbox => {
checkbox.checked = isChecked;
});
updateSelectedState();
}
// 新增:处理行复选框变化的函数 (事件委托)
function handleRowCheckboxChange(event) {
if (event.target.classList.contains('row-checkbox')) {
updateSelectedState();
}
}
// 新增:更新选中状态(计数、按钮状态、全选框状态)
function updateSelectedState() {
const rowCheckboxes = tableBody.querySelectorAll('.row-checkbox');
const selectedCheckboxes = tableBody.querySelectorAll('.row-checkbox:checked');
const selectedCount = selectedCheckboxes.length;
// 移除了数字显示不再更新selectedCountSpan
// 仍然更新复制按钮的禁用状态
if (copySelectedKeysBtn) {
copySelectedKeysBtn.disabled = selectedCount === 0;
// 可选:根据选中项数量更新按钮标题属性
copySelectedKeysBtn.setAttribute('title', `复制${selectedCount}项选中密钥`);
}
// 新增:更新批量删除按钮的禁用状态
if (deleteSelectedBtn) {
deleteSelectedBtn.disabled = selectedCount === 0;
deleteSelectedBtn.setAttribute('title', `删除${selectedCount}项选中日志`);
}
// 更新“全选”复选框的状态
if (selectAllCheckbox) {
if (rowCheckboxes.length > 0 && selectedCount === rowCheckboxes.length) {
selectAllCheckbox.checked = true;
selectAllCheckbox.indeterminate = false;
} else if (selectedCount > 0) {
selectAllCheckbox.checked = false;
selectAllCheckbox.indeterminate = true; // 部分选中状态
} else {
selectAllCheckbox.checked = false;
selectAllCheckbox.indeterminate = false;
}
}
}
// 新增:处理“复制选中密钥”按钮点击的函数
function handleCopySelectedKeys() {
const selectedCheckboxes = tableBody.querySelectorAll('.row-checkbox:checked');
const keysToCopy = [];
selectedCheckboxes.forEach(checkbox => {
const key = checkbox.getAttribute('data-key');
if (key) {
keysToCopy.push(key);
}
});
if (keysToCopy.length > 0) {
const textToCopy = keysToCopy.join('\n'); // 每行一个密钥
copyTextToClipboard(textToCopy, copySelectedKeysBtn); // 使用通用复制函数
} else {
showNotification('没有选中的密钥可复制', 'warning');
}
}
// 新增:通用的文本复制函数(结合现有逻辑)
function copyTextToClipboard(text, buttonElement = null) {
let copySuccess = false;
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text).then(() => {
if (buttonElement) handleCopyResult(buttonElement, true);
else showNotification('已复制到剪贴板', 'success');
}).catch(err => {
console.error('Clipboard API failed, attempting fallback:', err);
copySuccess = fallbackCopyTextToClipboard(text);
if (buttonElement) handleCopyResult(buttonElement, copySuccess);
else showNotification(copySuccess ? '已复制到剪贴板' : '复制失败', copySuccess ? 'success' : 'error');
});
} else {
console.warn("Clipboard API not available or context insecure. Using fallback copy method.");
copySuccess = fallbackCopyTextToClipboard(text);
if (buttonElement) handleCopyResult(buttonElement, copySuccess);
else showNotification(copySuccess ? '已复制到剪贴板' : '复制失败', copySuccess ? 'success' : 'error');
}
}
// 修改:处理批量删除按钮点击的函数 - 改为显示模态框
function handleDeleteSelected() {
const selectedCheckboxes = tableBody.querySelectorAll('.row-checkbox:checked');
const logIdsToDelete = [];
selectedCheckboxes.forEach(checkbox => {
const logId = checkbox.getAttribute('data-log-id'); // 需要在渲染时添加 data-log-id
if (logId) {
logIdsToDelete.push(parseInt(logId));
}
});
if (logIdsToDelete.length === 0) {
showNotification('没有选中的日志可删除', 'warning');
return;
}
if (logIdsToDelete.length === 0) {
showNotification('没有选中的日志可删除', 'warning');
return;
}
// 存储待删除ID并显示模态框
idsToDeleteGlobally = logIdsToDelete;
const message = `确定要删除选中的 ${logIdsToDelete.length} 条日志吗?此操作不可恢复!`;
showDeleteConfirmModal(message);
}
// 新增:执行实际的删除操作(提取自原 handleDeleteSelected 和 handleDeleteLogRow
async function performActualDelete(logIds) {
if (!logIds || logIds.length === 0) return;
const isSingleDelete = logIds.length === 1;
const url = isSingleDelete ? `/api/logs/errors/${logIds[0]}` : '/api/logs/errors';
const method = 'DELETE';
const body = isSingleDelete ? null : JSON.stringify({ ids: logIds });
const headers = isSingleDelete ? {} : { 'Content-Type': 'application/json' };
try {
// Rename 'response' to 'deleteResponse' and remove duplicate fetch
const deleteResponse = await fetch(url, {
method: method,
headers: headers,
body: body,
});
// Removed duplicate fetch call below
if (!deleteResponse.ok) {
let errorData;
try { errorData = await deleteResponse.json(); } catch (e) { /* ignore */ }
const actionText = isSingleDelete ? `删除该条日志` : `批量删除 ${logIds.length} 条日志`;
throw new Error(errorData?.detail || `${actionText}失败: ${deleteResponse.statusText}`);
}
const successMessage = isSingleDelete ? `成功删除该日志` : `成功删除 ${logIds.length} 条日志`;
showNotification(successMessage, 'success');
// 取消全选
if (selectAllCheckbox) selectAllCheckbox.checked = false;
// 重新加载当前页数据
loadErrorLogs();
} catch (error) {
console.error('批量删除错误日志失败:', error);
showNotification(`批量删除失败: ${error.message}`, 'error', 5000);
}
}
// 修改:处理单行删除按钮点击的函数 - 改为显示模态框
function handleDeleteLogRow(logId) {
if (!logId) return;
// 存储待删除ID并显示模态框
idsToDeleteGlobally = [parseInt(logId)]; // 存储为数组
// 使用通用确认消息不显示具体ID
const message = `确定要删除这条日志吗?此操作不可恢复!`;
showDeleteConfirmModal(message);
}
// 新增:处理 ID 排序点击的函数
function handleSortById() {
if (currentSort.field === 'id') {
// 如果当前是按 ID 排序,切换顺序
currentSort.order = currentSort.order === 'asc' ? 'desc' : 'asc';
} else {
// 如果当前不是按 ID 排序,切换到按 ID 排序,默认为降序
currentSort.field = 'id';
currentSort.order = 'desc';
}
// 更新图标
updateSortIcon();
// 重新加载第一页数据
currentPage = 1;
loadErrorLogs();
}
// 新增:更新排序图标的函数
function updateSortIcon() {
if (!sortIcon) return;
// 移除所有可能的排序类
sortIcon.classList.remove('fa-sort', 'fa-sort-up', 'fa-sort-down', 'text-gray-400', 'text-primary-600');
if (currentSort.field === 'id') {
sortIcon.classList.add(currentSort.order === 'asc' ? 'fa-sort-up' : 'fa-sort-down');
sortIcon.classList.add('text-primary-600'); // 高亮显示
} else {
// 如果不是按 ID 排序,显示默认图标
sortIcon.classList.add('fa-sort', 'text-gray-400');
}
}
// 加载错误日志数据
async function loadErrorLogs() {
// 重置选择状态
if (selectAllCheckbox) selectAllCheckbox.checked = false;
if (selectAllCheckbox) selectAllCheckbox.indeterminate = false;
updateSelectedState(); // 更新按钮状态和计数
showLoading(true);
showError(false);
showNoData(false);
@@ -219,14 +581,21 @@ async function loadErrorLogs() {
const offset = (currentPage - 1) * pageSize;
try {
// Construct the API URL with search parameters
// Construct the API URL with search and sort parameters
let apiUrl = `/api/logs/errors?limit=${pageSize}&offset=${offset}`;
// 添加排序参数
apiUrl += `&sort_by=${currentSort.field}&sort_order=${currentSort.order}`;
// 添加搜索参数
if (currentSearch.key) {
apiUrl += `&key_search=${encodeURIComponent(currentSearch.key)}`;
}
if (currentSearch.error) {
apiUrl += `&error_search=${encodeURIComponent(currentSearch.error)}`;
}
if (currentSearch.errorCode) { // Add error code to API request
apiUrl += `&error_code_search=${encodeURIComponent(currentSearch.errorCode)}`;
}
if (currentSearch.startDate) {
apiUrl += `&start_date=${encodeURIComponent(currentSearch.startDate)}`;
}
@@ -274,6 +643,12 @@ function renderErrorLogs(logs) {
if (!tableBody) return;
tableBody.innerHTML = ''; // Clear previous entries
// 重置全选复选框状态(在清空表格后)
if (selectAllCheckbox) {
selectAllCheckbox.checked = false;
selectAllCheckbox.indeterminate = false;
}
if (!logs || logs.length === 0) {
// Handled by showNoData
return;
@@ -306,17 +681,30 @@ function renderErrorLogs(logs) {
return `${key.substring(0, 4)}...${key.substring(key.length - 4)}`;
};
const maskedKey = maskKey(log.gemini_key);
const fullKey = log.gemini_key || ''; // Store the full key
row.innerHTML = `
<td>${sequentialId}</td> <!-- Use sequential ID -->
<td title="${log.gemini_key || ''}">${maskedKey}</td>
<td class="text-center px-3 py-3"> <!-- Checkbox column -->
<input type="checkbox" class="row-checkbox form-checkbox h-4 w-4 text-primary-600 border-gray-300 rounded focus:ring-primary-500" data-key="${fullKey}" data-log-id="${log.id}"> <!-- 添加 data-log-id -->
</td>
<td>${sequentialId}</td> <!-- 显示从1开始的序号 -->
<td class="relative group" title="${fullKey}"> <!-- Added relative/group for button positioning -->
${maskedKey}
<!-- Added copy button for the key in the table row -->
<button class="copy-btn absolute top-1/2 right-2 transform -translate-y-1/2 bg-gray-200 hover:bg-gray-300 text-gray-600 p-1 rounded opacity-0 group-hover:opacity-100 transition-opacity text-xs" data-copy-text="${log.gemini_key || ''}" title="复制完整密钥">
<i class="far fa-copy"></i>
</button>
</td>
<td>${log.error_type || '未知'}</td>
<td class="error-code-content" title="${log.error_code || ''}">${errorCodeContent}</td>
<td>${log.model_name || '未知'}</td>
<td>${formattedTime}</td>
<td>
<button class="btn-view-details" data-log-id="${log.id}">
查看详情
<button class="btn-view-details mr-2" data-log-id="${log.id}"> <!-- 添加 mr-2 -->
<i class="fas fa-eye mr-1"></i>详情
</button>
<button class="btn-delete-row text-danger-600 hover:text-danger-800" data-log-id="${log.id}" title="删除此日志">
<i class="fas fa-trash-alt"></i>
</button>
</td>
`;
@@ -331,6 +719,19 @@ function renderErrorLogs(logs) {
showLogDetails(logId);
});
});
// 新增:为新渲染的删除按钮添加事件监听器
document.querySelectorAll('.btn-delete-row').forEach(button => {
button.addEventListener('click', function() {
const logId = this.getAttribute('data-log-id');
handleDeleteLogRow(logId);
});
});
// Re-initialize copy buttons specifically for the newly rendered table rows
setupCopyButtons('#errorLogsTable');
// Update selected state after rendering
updateSelectedState();
}
// 显示错误日志详情 (从 API 获取)
@@ -403,6 +804,9 @@ async function showLogDetails(logId) {
document.getElementById('modalModelName').textContent = logDetails.model_name || '未知';
document.getElementById('modalRequestTime').textContent = formattedTime;
// Re-initialize copy buttons specifically for the modal after content is loaded
setupCopyButtons('#logDetailModal');
} catch (error) {
console.error('获取日志详情失败:', error);
// Show error in modal
@@ -547,10 +951,17 @@ function showError(show, message = '加载错误日志失败,请稍后重试
// Function to show temporary status notifications (like copy success)
function showNotification(message, type = 'success', duration = 3000) {
const notificationElement = document.getElementById('copyStatus'); // Or a more generic ID if needed
if (!notificationElement) return;
const notificationElement = document.getElementById('notification'); // Use the correct ID from base.html
if (!notificationElement) {
console.error("Notification element with ID 'notification' not found.");
return;
}
// Set message and type class
notificationElement.textContent = message;
// Remove previous type classes before adding the new one
notificationElement.classList.remove('success', 'error', 'warning', 'info');
notificationElement.classList.add(type); // Add the type class for styling
notificationElement.className = `notification ${type} show`; // Add 'show' class
// Hide after duration

File diff suppressed because it is too large Load Diff

View File

@@ -141,7 +141,7 @@
background-color: rgba(0, 0, 0, 0.8);
color: white;
font-weight: 500; /* font-medium */
z-index: 50;
z-index: 1000; /* Increased z-index */
opacity: 0;
transition: opacity 0.3s ease-in-out, transform 0.3s ease-in-out;
}
@@ -184,7 +184,6 @@
{% block head_extra_scripts %}{% endblock %}
</head>
<body class="bg-gradient min-h-screen text-gray-800 pt-6 pb-16">
{% block content %}{% endblock %}
<!-- 底部版权 -->
@@ -195,7 +194,17 @@
</a> |
<a href="https://github.com/snailyp/gemini-balance" target="_blank" class="text-primary-600 hover:text-primary-800 transition duration-300">
<i class="fab fa-github"></i> GitHub
</a> |
<a href="https://afdian.com/a/snaily" target="_blank" class="text-primary-600 hover:text-primary-800 transition duration-300">
<i class="fas fa-drumstick-bite text-yellow-600"></i> 给作者加鸡腿
</a>
<span class="mx-1">|</span>
<span class="text-xs text-yellow-600 font-semibold">
<i class="fas fa-exclamation-triangle mr-1"></i>免费项目,谨防诈骗
</span>
<span id="version-info-container" class="inline-block">
<!-- Version info will be loaded here by JavaScript -->
</span>
</div>
<!-- 通用JS -->
@@ -259,6 +268,48 @@
}, 300); // Short delay to show spinner
}
// --- Version Check ---
const versionInfoContainer = document.getElementById('version-info-container');
async function fetchVersionInfo() {
if (!versionInfoContainer) return;
versionInfoContainer.innerHTML = '<span class="mx-1">|</span><span class="text-xs text-gray-400">检查更新中...</span>'; // Initial loading state
try {
const response = await fetch('/api/version/check');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
let versionHtml = `<span class="mx-1">|</span><span class="text-xs text-gray-500">v${data.current_version}</span>`;
if (data.update_available) {
versionHtml += `
<span class="mx-1">|</span>
<a href="https://github.com/snailyp/gemini-balance/releases/latest" target="_blank" class="text-yellow-600 hover:text-yellow-800 transition duration-300 animate-pulse">
<i class="fas fa-arrow-up"></i> 新版本: v${data.latest_version}
</a>`;
} else if (data.error_message) {
versionHtml += `
<span class="mx-1">|</span>
<span class="text-xs text-red-500" title="${data.error_message}">更新检查失败</span>`;
} else {
versionHtml += `<span class="mx-1">|</span><span class="text-xs text-green-500">已是最新</span>`; // Indicate up-to-date
}
versionInfoContainer.innerHTML = versionHtml;
} catch (error) {
console.error('Error fetching version info:', error);
versionInfoContainer.innerHTML = `<span class="mx-1">|</span><span class="text-xs text-red-500" title="无法连接到服务器或解析响应">更新检查失败</span>`;
}
}
// Fetch immediately on load
fetchVersionInfo();
// Fetch periodically (e.g., every hour)
setInterval(fetchVersionInfo, 3600000); // 3600000 ms = 1 hour
</script>
{% block body_scripts %}{% endblock %}
</body>

View File

@@ -42,6 +42,11 @@
@apply: bg-primary-600;
background-color: #4F46E5;
}
/* 统一通知样式为黑色半透明,确保与 keys_status 一致 */
.notification {
background: rgba(0,0,0,0.8) !important;
color: #fff !important;
}
</style>
{% endblock %}
@@ -87,6 +92,9 @@
<button class="tab-btn bg-white bg-opacity-50 text-gray-700 px-5 py-2 rounded-full font-medium text-sm hover:bg-opacity-70 transition-all duration-200" data-tab="scheduler">
定时任务
</button>
<button class="tab-btn bg-white bg-opacity-50 text-gray-700 px-5 py-2 rounded-full font-medium text-sm hover:bg-opacity-70 transition-all duration-200" data-tab="logging">
日志配置
</button>
</div>
<!-- Save Status Banner (Removed - using notification component now) -->
@@ -257,8 +265,38 @@
<label for="SHOW_THINKING_PROCESS" class="toggle-label block overflow-hidden h-6 rounded-full bg-gray-300 cursor-pointer"></label>
</div>
</div>
<!-- 思考模型列表 -->
<div class="mb-6">
<label for="THINKING_MODELS" class="block font-semibold mb-2 text-gray-700">思考模型列表</label>
<div class="array-container bg-white rounded-lg border border-gray-200 p-4 mb-2" id="THINKING_MODELS_container">
<!-- 数组项将在这里动态添加 -->
</div>
<div class="flex justify-end">
<button type="button" class="bg-primary-600 hover:bg-primary-700 text-white px-4 py-2 rounded-lg font-medium transition-all duration-200 flex items-center gap-2" onclick="addArrayItem('THINKING_MODELS')">
<i class="fas fa-plus"></i> 添加模型
</button>
</div>
<small class="text-gray-500 mt-1 block">用于“思考过程”的模型列表</small>
</div>
<!-- 思考模型预算映射 -->
<div class="mb-6">
<label for="THINKING_BUDGET_MAP" class="block font-semibold mb-2 text-gray-700">思考模型预算映射</label>
<div class="bg-white rounded-lg border border-gray-200 p-4 mb-2 space-y-3" id="THINKING_BUDGET_MAP_container">
<!-- 键值对将在这里动态添加 -->
<div class="text-gray-500 text-sm italic">请先在上方添加思考模型,然后在此处配置预算。</div>
</div>
<!-- 移除添加预算映射按钮 -->
<!-- <div class="flex justify-end">
<button type="button" class="bg-primary-600 hover:bg-primary-700 text-white px-4 py-2 rounded-lg font-medium transition-all duration-200 flex items-center gap-2" id="addBudgetMapItemBtn">
<i class="fas fa-plus"></i> 添加预算映射
</button>
</div> -->
<small class="text-gray-500 mt-1 block">为每个思考模型设置预算(整数,最大值 24576此项与上方模型列表自动关联。</small>
</div>
</div>
<!-- 图像生成相关配置 -->
<div class="config-section bg-white bg-opacity-70 rounded-xl p-6 mb-6 shadow-lg" id="image-section">
<h2 class="text-xl font-bold mb-6 pb-3 border-b border-gray-200 flex items-center gap-2">
@@ -285,7 +323,7 @@
<select id="UPLOAD_PROVIDER" name="UPLOAD_PROVIDER" class="w-full px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50 bg-white">
<option value="smms" selected>SM.MS</option>
<option value="picgo">PicGo</option>
<option value="cloudflare">Cloudflare</option>
<option value="cloudflare_imgbed">Cloudflare</option>
</select>
<small class="text-gray-500 mt-1 block">图片上传服务提供商</small>
</div>
@@ -305,14 +343,14 @@
</div>
<!-- Cloudflare图床URL -->
<div class="mb-6 provider-config" data-provider="cloudflare">
<div class="mb-6 provider-config" data-provider="cloudflare_imgbed">
<label for="CLOUDFLARE_IMGBED_URL" class="block font-semibold mb-2 text-gray-700">Cloudflare图床URL</label>
<input type="text" id="CLOUDFLARE_IMGBED_URL" name="CLOUDFLARE_IMGBED_URL" placeholder="https://xxxxxxx.pages.dev/upload" class="w-full px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50">
<small class="text-gray-500 mt-1 block">Cloudflare图床的URL</small>
</div>
<!-- Cloudflare认证码 -->
<div class="mb-6 provider-config" data-provider="cloudflare">
<div class="mb-6 provider-config" data-provider="cloudflare_imgbed">
<label for="CLOUDFLARE_IMGBED_AUTH_CODE" class="block font-semibold mb-2 text-gray-700">Cloudflare认证码</label>
<input type="text" id="CLOUDFLARE_IMGBED_AUTH_CODE" name="CLOUDFLARE_IMGBED_AUTH_CODE" placeholder="xxxxxxxxx" class="w-full px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50">
<small class="text-gray-500 mt-1 block">Cloudflare图床的认证码</small>
@@ -390,6 +428,26 @@
<small class="text-gray-500 mt-1 block">定时任务使用的时区,格式如 "Asia/Shanghai" 或 "UTC"</small>
</div>
</div>
<!-- 日志配置 -->
<div class="config-section bg-white bg-opacity-70 rounded-xl p-6 mb-6 shadow-lg" id="logging-section">
<h2 class="text-xl font-bold mb-6 pb-3 border-b border-gray-200 flex items-center gap-2">
<i class="fas fa-file-alt text-primary-600"></i> 日志配置
</h2>
<!-- 日志级别 -->
<div class="mb-6">
<label for="LOG_LEVEL" class="block font-semibold mb-2 text-gray-700">日志级别</label>
<select id="LOG_LEVEL" name="LOG_LEVEL" class="w-full px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50 bg-white">
<option value="DEBUG">DEBUG</option>
<option value="INFO">INFO</option>
<option value="WARNING">WARNING</option>
<option value="ERROR">ERROR</option>
<option value="CRITICAL">CRITICAL</option>
</select>
<small class="text-gray-500 mt-1 block">设置应用程序的日志记录详细程度</small>
</div>
</div>
<!-- Action Buttons -->
<div class="flex flex-col md:flex-row justify-center gap-4 mt-8">

View File

@@ -48,6 +48,26 @@
}
}
/* Modal styles are in base.html */
/* 确保输入框和按钮高度一致 */
input[type="text"], input[type="datetime-local"], select, button {
height: 36px !important;
}
/* 日期选择器样式优化 */
.date-range-container {
display: flex;
align-items: center;
gap: 0.5rem;
}
/* 确保所有输入框在小屏幕上正确显示 */
@media (max-width: 640px) {
input[type="datetime-local"] {
min-width: 0;
width: 100%;
}
}
</style>
{% endblock %}
@@ -83,32 +103,56 @@
<!-- 控制区域 (Refresh button removed, page size moved below) -->
<!-- Removed the original controls div -->
<!-- 搜索控件 -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-3 mb-6">
<input type="text" id="keySearch" placeholder="搜索密钥 (部分)" class="px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50 col-span-1 lg:col-span-1">
<input type="text" id="errorSearch" placeholder="搜索错误类型/日志" class="px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50 col-span-1 lg:col-span-1">
<div class="flex items-center gap-2 col-span-1 lg:col-span-2">
<input type="datetime-local" id="startDate" class="px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50 flex-1 text-sm">
<span class="text-gray-700"></span>
<input type="datetime-local" id="endDate" class="px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50 flex-1 text-sm">
<!-- 搜索与操作控件 -->
<div class="grid grid-cols-1 lg:grid-cols-[1fr_auto] items-center gap-4 mb-6"> <!-- 修改为items-center -->
<!-- Left side: Search inputs and date range -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 w-full"> <!-- 修改为3列布局 -->
<input type="text" id="keySearch" placeholder="搜索密钥 (部分)" style="height: 36px;" class="px-3 py-1 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50">
<input type="text" id="errorSearch" placeholder="搜索错误类型/日志" style="height: 36px;" class="px-3 py-1 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50">
<input type="text" id="errorCodeSearch" placeholder="搜索错误码" style="height: 36px;" class="px-3 py-1 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50">
<!-- 日期选择器单独一行 -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2 col-span-1 sm:col-span-2 lg:col-span-3 mt-2">
<div class="flex items-center gap-2">
<label class="text-sm text-gray-700 whitespace-nowrap">开始时间:</label>
<input type="datetime-local" id="startDate" style="height: 36px;" class="px-3 py-1 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50 text-sm w-full">
</div>
<div class="flex items-center gap-2">
<label class="text-sm text-gray-700 whitespace-nowrap">结束时间:</label>
<input type="datetime-local" id="endDate" style="height: 36px;" class="px-3 py-1 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50 text-sm w-full">
</div>
</div>
</div>
<!-- Right side: Action buttons -->
<div class="flex items-center gap-3 flex-shrink-0"> <!-- 移除上边距 -->
<button id="searchBtn" class="flex items-center justify-center px-4 py-1.5 bg-primary-600 hover:bg-primary-700 text-white rounded-lg font-medium transition-all duration-200 shadow-sm hover:shadow-md whitespace-nowrap" style="height: 36px;">
<i class="fas fa-search mr-1.5"></i>搜索
</button>
<button id="copySelectedKeysBtn" class="flex items-center justify-center px-4 py-1.5 bg-success-600 hover:bg-success-700 text-white rounded-lg font-medium transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed shadow-sm hover:shadow-md whitespace-nowrap" style="height: 36px;" disabled>
<i class="far fa-copy mr-1.5"></i>复制
</button>
<button id="deleteSelectedBtn" class="flex items-center justify-center px-4 py-1.5 bg-danger-600 hover:bg-danger-700 text-white rounded-lg font-medium transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed shadow-sm hover:shadow-md whitespace-nowrap" style="height: 36px;" disabled>
<i class="fas fa-trash-alt mr-1.5"></i>删除
</button>
</div>
<button id="searchBtn" class="flex items-center justify-center gap-2 bg-primary-600 hover:bg-primary-700 text-white px-4 py-3 rounded-lg font-medium transition-all duration-200 col-span-1">
<i class="fas fa-search"></i> 搜索
</button>
</div>
<!-- 表格容器 - Enhanced Styling -->
<div class="overflow-x-auto rounded-lg border border-gray-200 mb-6 bg-white"> <!-- Removed shadow, added border -->
<table class="styled-table w-full min-w-full text-sm"> <!-- Added text-sm -->
<thead>
<tr class="bg-primary-50 text-left text-primary-800"> <!-- Changed header background and text color -->
<th class="px-5 py-3 font-semibold rounded-tl-lg">ID</th> <!-- Increased padding, adjusted rounding -->
<th class="px-3 py-3 font-semibold rounded-tl-lg w-12 text-center"> <!-- Adjusted padding and width -->
<input type="checkbox" id="selectAllCheckbox" class="form-checkbox h-4 w-4 text-primary-600 border-gray-300 rounded focus:ring-primary-500">
</th>
<th class="px-5 py-3 font-semibold cursor-pointer" id="sortById">
ID <i class="fas fa-sort ml-1 text-gray-400"></i>
</th>
<th class="px-5 py-3 font-semibold">Gemini密钥</th>
<th class="px-5 py-3 font-semibold">错误类型</th>
<th class="px-5 py-3 font-semibold">错误码</th>
<th class="px-5 py-3 font-semibold">模型名称</th>
<th class="px-5 py-3 font-semibold">请求时间</th>
<th class="px-5 py-3 font-semibold rounded-tr-lg">操作</th> <!-- Adjusted rounding -->
<th class="px-5 py-3 font-semibold rounded-tr-lg text-center">操作</th> <!-- Adjusted rounding and centered -->
</tr>
</thead>
<tbody id="errorLogsTable" class="divide-y divide-gray-200">
@@ -186,17 +230,23 @@
</div>
<div class="space-y-4 max-h-[60vh] overflow-y-auto p-1">
<div class="bg-gray-50 p-4 rounded-lg">
<div class="bg-gray-50 p-4 rounded-lg relative group"> <!-- Added relative and group -->
<h6 class="text-sm font-semibold text-gray-600 mb-1">Gemini密钥:</h6>
<pre id="modalGeminiKey" class="font-mono text-sm bg-gray-100 p-3 rounded overflow-x-auto"></pre>
</div>
<div class="bg-gray-50 p-4 rounded-lg">
<h6 class="text-sm font-semibold text-gray-600 mb-1">错误类型:</h6>
<p id="modalErrorType" class="text-danger-600 font-medium"></p>
<button class="copy-btn absolute top-2 right-2 bg-gray-200 hover:bg-gray-300 text-gray-600 p-1.5 rounded opacity-0 group-hover:opacity-100 transition-opacity" data-target="modalGeminiKey" title="复制密钥">
<i class="far fa-copy"></i>
</button>
</div>
<div class="bg-gray-50 p-4 rounded-lg relative group"> <!-- Added relative and group -->
<h6 class="text-sm font-semibold text-gray-600 mb-1">错误类型:</h6>
<p id="modalErrorType" class="text-danger-600 font-medium pr-8"></p> <!-- Added padding right for button -->
<button class="copy-btn absolute top-2 right-2 bg-gray-200 hover:bg-gray-300 text-gray-600 p-1.5 rounded opacity-0 group-hover:opacity-100 transition-opacity" data-target="modalErrorType" title="复制错误类型">
<i class="far fa-copy"></i>
</button>
</div>
<div class="bg-gray-50 p-4 rounded-lg relative group">
<h6 class="text-sm font-semibold text-gray-600 mb-1">错误日志:</h6>
<pre id="modalErrorLog" class="font-mono text-sm bg-gray-100 p-3 rounded overflow-x-auto whitespace-pre-wrap"></pre>
<button class="copy-btn absolute top-2 right-2 bg-gray-200 hover:bg-gray-300 text-gray-600 p-1.5 rounded opacity-0 group-hover:opacity-100 transition-opacity" data-target="modalErrorLog" title="复制错误日志">
@@ -204,7 +254,7 @@
</button>
</div>
<div class="bg-gray-50 p-4 rounded-lg relative group"> <!-- Added relative and group -->
<div class="bg-gray-50 p-4 rounded-lg relative group">
<h6 class="text-sm font-semibold text-gray-600 mb-1">请求消息:</h6>
<pre id="modalRequestMsg" class="font-mono text-sm bg-gray-100 p-3 rounded overflow-x-auto whitespace-pre-wrap"></pre>
<button class="copy-btn absolute top-2 right-2 bg-gray-200 hover:bg-gray-300 text-gray-600 p-1.5 rounded opacity-0 group-hover:opacity-100 transition-opacity" data-target="modalRequestMsg" title="复制请求消息">
@@ -212,14 +262,20 @@
</button>
</div>
<div class="bg-gray-50 p-4 rounded-lg">
<div class="bg-gray-50 p-4 rounded-lg relative group"> <!-- Added relative and group -->
<h6 class="text-sm font-semibold text-gray-600 mb-1">模型名称:</h6>
<p id="modalModelName" class="font-medium"></p>
<p id="modalModelName" class="font-medium pr-8"></p> <!-- Added padding right for button -->
<button class="copy-btn absolute top-2 right-2 bg-gray-200 hover:bg-gray-300 text-gray-600 p-1.5 rounded opacity-0 group-hover:opacity-100 transition-opacity" data-target="modalModelName" title="复制模型名称">
<i class="far fa-copy"></i>
</button>
</div>
<div class="bg-gray-50 p-4 rounded-lg">
<div class="bg-gray-50 p-4 rounded-lg relative group"> <!-- Added relative and group -->
<h6 class="text-sm font-semibold text-gray-600 mb-1">请求时间:</h6>
<p id="modalRequestTime" class="font-medium"></p>
<p id="modalRequestTime" class="font-medium pr-8"></p> <!-- Added padding right for button -->
<button class="copy-btn absolute top-2 right-2 bg-gray-200 hover:bg-gray-300 text-gray-600 p-1.5 rounded opacity-0 group-hover:opacity-100 transition-opacity" data-target="modalRequestTime" title="复制请求时间">
<i class="far fa-copy"></i>
</button>
</div>
</div>
@@ -230,6 +286,22 @@
</div>
</div>
<!-- 删除确认模态框 -->
<div id="deleteConfirmModal" class="modal">
<div class="w-full max-w-md mx-auto bg-white rounded-xl shadow-xl overflow-hidden animate-fade-in">
<div class="p-6">
<div class="flex justify-between items-center border-b border-gray-200 pb-3 mb-4">
<h2 class="text-lg font-semibold text-gray-800">确认删除</h2>
<button id="closeDeleteConfirmModalBtn" class="text-gray-400 hover:text-gray-600 text-xl">&times;</button>
</div>
<p id="deleteConfirmMessage" class="text-gray-700 mb-6">你确定要删除选中的项目吗?此操作不可恢复!</p>
<div class="flex justify-end gap-3">
<button id="cancelDeleteBtn" type="button" class="bg-gray-200 hover:bg-gray-300 text-gray-800 px-5 py-2 rounded-lg font-medium transition">取消</button>
<button id="confirmDeleteBtn" type="button" class="bg-danger-600 hover:bg-danger-700 text-white px-5 py-2 rounded-lg font-medium transition">确认删除</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block body_scripts %}

View File

@@ -6,12 +6,15 @@
<style>
/* keys_status.html specific styles */
.key-content {
transition: max-height 0.3s ease-in-out, opacity 0.3s ease-in-out;
transition: max-height 0.3s ease-in-out, opacity 0.3s ease-in-out, padding 0.3s ease-in-out; /* Added padding transition */
overflow: hidden; /* Keep hidden initially and during collapse */
}
.key-content.collapsed {
max-height: 0;
overflow: hidden;
max-height: 0 !important; /* Use important to override inline style during transition */
opacity: 0;
padding-top: 0 !important; /* Collapse padding */
padding-bottom: 0 !important; /* Collapse padding */
/* overflow: hidden; */ /* Already set above */
}
.toggle-icon {
transition: transform 0.3s ease;
@@ -30,13 +33,13 @@
position: relative;
z-index: 10;
}
@media (min-width: 768px) {
.stats-dashboard {
grid-template-columns: 1fr 1fr;
}
}
/* 统计卡片样式 */
.stats-card {
background-color: rgba(255, 255, 255, 0.8);
@@ -48,11 +51,11 @@
overflow: hidden;
transition: all 0.3s ease-in-out;
}
.stats-card:hover {
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
}
.stats-card-header {
background-color: rgba(255, 255, 255, 0.3);
padding: 0.75rem 1rem;
@@ -60,8 +63,10 @@
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap; /* Allow wrapping for smaller screens */
gap: 0.5rem; /* Add gap between items */
}
.stats-card-title {
display: flex;
align-items: center;
@@ -69,19 +74,19 @@
font-weight: 500;
color: #374151;
}
.stats-card-title i {
margin-right: 0.5rem;
color: #4F46E5;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.5rem;
padding: 0.75rem;
}
/* 统计项样式 */
.stat-item {
padding: 0.75rem;
@@ -95,7 +100,7 @@
position: relative;
overflow: hidden;
}
.stat-item::before {
content: '';
position: absolute;
@@ -105,15 +110,15 @@
z-index: 0;
transition: opacity 0.3s ease-in-out;
}
.stat-item:hover::before {
opacity: 0.1;
}
.stat-item:hover {
transform: scale(1.05);
}
.stat-value {
font-size: 1.5rem;
font-weight: 700;
@@ -121,7 +126,7 @@
position: relative;
color: #1F2937;
}
.stat-label {
font-size: 0.75rem;
font-weight: 500;
@@ -132,7 +137,7 @@
position: relative;
color: #6B7280;
}
.stat-icon {
position: absolute;
right: 0.5rem;
@@ -142,63 +147,30 @@
transform: rotate(12deg);
transition: all 0.3s ease-in-out;
}
.stat-item:hover .stat-icon {
opacity: 0.2;
transform: scale(1.1) rotate(0deg);
}
/* 统计类型样式 */
.stat-primary {
color: #4F46E5;
background-color: rgba(238, 242, 255, 0.5);
}
.stat-success {
color: #10B981;
background-color: rgba(236, 253, 245, 0.5);
}
.stat-danger {
color: #EF4444;
background-color: rgba(254, 242, 242, 0.5);
}
.stat-warning {
color: #F59E0B;
background-color: rgba(255, 251, 235, 0.5);
}
.stat-info {
color: #3B82F6;
background-color: rgba(239, 246, 255, 0.5);
}
.stat-primary { color: #4F46E5; background-color: rgba(238, 242, 255, 0.5); }
.stat-success { color: #10B981; background-color: rgba(236, 253, 245, 0.5); }
.stat-danger { color: #EF4444; background-color: rgba(254, 242, 242, 0.5); }
.stat-warning { color: #F59E0B; background-color: rgba(255, 251, 235, 0.5); }
.stat-info { color: #3B82F6; background-color: rgba(239, 246, 255, 0.5); }
/* 响应式调整 */
@media (max-width: 640px) {
.stats-dashboard {
gap: 1rem;
}
.stats-grid {
grid-template-columns: repeat(2, 1fr);
gap: 0.5rem;
padding: 0.5rem;
}
.stat-item {
padding: 0.5rem;
}
.stat-value {
font-size: 1.25rem;
}
.stat-label {
font-size: 0.625rem;
}
.stats-dashboard { gap: 1rem; }
.stats-grid { grid-template-columns: repeat(2, 1fr); gap: 0.5rem; padding: 0.5rem; }
.stat-item { padding: 0.5rem; }
.stat-value { font-size: 1.25rem; }
.stat-label { font-size: 0.625rem; }
.stats-card-header { padding: 0.5rem 0.75rem; } /* Adjust header padding */
.key-content ul { grid-template-columns: 1fr; } /* Stack keys vertically on small screens */
}
/* Tailwind Toggle Switch Helper CSS from config_editor.html */
/* Tailwind Toggle Switch Helper CSS */
.toggle-checkbox:checked {
@apply: right-0 border-primary-600;
right: 0;
@@ -209,6 +181,34 @@
background-color: #4F46E5;
}
/* Pagination Controls */
#validPaginationControls, #invalidPaginationControls {
display: flex;
justify-content: center;
align-items: center;
margin-top: 1rem; /* mt-4 */
gap: 0.5rem; /* space-x-2 */
}
/* Ensure list items are flex for alignment */
#validKeys li, #invalidKeys li {
display: flex;
align-items: flex-start; /* Align checkbox with top of content */
gap: 0.75rem; /* gap-3 */
}
/* Ensure grid layout for key lists */
#validKeys, #invalidKeys {
display: grid;
grid-template-columns: 1fr; /* Default single column */
gap: 0.75rem; /* gap-3 */
}
@media (min-width: 768px) { /* md breakpoint */
#validKeys, #invalidKeys {
grid-template-columns: repeat(2, 1fr); /* Two columns on medium screens and up */
}
}
</style>
{% endblock %}
@@ -282,7 +282,7 @@
</div>
</div>
</div>
<!-- API调用统计卡片 -->
<div class="stats-card">
<div class="stats-card-header">
@@ -311,153 +311,217 @@
</div>
</div>
</div>
<!-- 有效密钥区域 -->
<div class="stats-card mb-6 animate-fade-in" style="animation-delay: 0.2s">
<div class="stats-card-header cursor-pointer" onclick="toggleSection(this, 'validKeys')">
<div class="flex items-center gap-3">
<!-- Left side: Title and Toggle Icon -->
<div class="flex items-center gap-3 flex-shrink-0"> <!-- Prevent shrinking -->
<i class="fas fa-chevron-down toggle-icon text-primary-600"></i>
<i class="fas fa-check-circle text-success-500"></i>
<h2 class="text-lg font-semibold">有效密钥列表 ({{ valid_key_count }})</h2>
<div class="flex items-center gap-2 ml-4">
<label for="failCountThreshold" class="text-sm text-gray-600 select-none">失败次数≥</label>
<h2 class="text-lg font-semibold whitespace-nowrap">有效密钥列表 ({{ valid_key_count }})</h2>
</div>
<!-- Middle: Filters and Search (Allow wrapping) -->
<div class="flex items-center gap-x-4 gap-y-2 flex-grow flex-wrap justify-start md:justify-center"> <!-- Allow wrapping, center on medium+ -->
<!-- 失败次数筛选 -->
<div class="flex items-center gap-1">
<label for="failCountThreshold" class="text-sm text-gray-600 select-none whitespace-nowrap">失败次数≥</label>
<input type="number" id="failCountThreshold" value="0" min="0" class="form-input h-7 w-16 px-2 py-1 text-sm border border-gray-300 rounded focus:ring-primary-500 focus:border-primary-500" onclick="event.stopPropagation();">
</div>
<!-- 密钥搜索 -->
<div class="flex items-center gap-1">
<label for="keySearchInput" class="text-sm text-gray-600 select-none whitespace-nowrap"><i class="fas fa-search mr-1"></i>搜索</label>
<input type="search" id="keySearchInput" placeholder="输入密钥..." class="form-input h-7 w-32 px-2 py-1 text-sm border border-gray-300 rounded focus:ring-primary-500 focus:border-primary-500" onclick="event.stopPropagation();">
</div>
<!-- 每页显示数量 -->
<div class="flex items-center gap-1">
<label for="itemsPerPageSelect" class="text-sm text-gray-600 select-none whitespace-nowrap">每页</label>
<select id="itemsPerPageSelect" class="form-select h-7 px-2 py-1 text-sm border border-gray-300 rounded focus:ring-primary-500 focus:border-primary-500 bg-white" onclick="event.stopPropagation();">
<option value="10">10</option>
<option value="20">20</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
<span class="text-sm text-gray-600 select-none"></span>
</div>
</div>
<div class="flex gap-2">
<button class="flex items-center gap-2 bg-teal-500 hover:bg-teal-600 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200" onclick="event.stopPropagation(); showVerifyModal('valid', event)"> <!-- 新增批量验证按钮 -->
<i class="fas fa-check-double"></i>
批量验证
</button>
<button class="flex items-center gap-2 bg-blue-500 hover:bg-blue-600 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200" onclick="event.stopPropagation(); resetAllKeysFailCount('valid', event)" data-reset-type="valid">
<i class="fas fa-redo-alt"></i>
批量重置
</button>
<button class="flex items-center gap-2 bg-primary-600 hover:bg-primary-700 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200" onclick="event.stopPropagation(); copyKeys('valid')">
<i class="fas fa-copy"></i>
批量复制
</button>
<!-- Right side: Select All -->
<div class="flex items-center gap-1 flex-shrink-0" onclick="event.stopPropagation();"> <!-- Prevent shrinking -->
<input type="checkbox" id="selectAllValid" class="form-checkbox h-4 w-4 text-primary-600 border-gray-300 rounded focus:ring-primary-500" onchange="toggleSelectAll('valid', this.checked)">
<label for="selectAllValid" class="text-sm text-gray-600 select-none whitespace-nowrap">全选</label>
</div>
</div>
<!-- 批量操作按钮组 (仅在选中时显示) -->
<div id="validBatchActions" class="p-3 bg-gray-50 border-t border-gray-200 hidden flex items-center flex-wrap gap-3"> <!-- Added flex-wrap -->
<span class="text-sm font-medium text-gray-700 whitespace-nowrap">已选择 <span id="validSelectedCount">0</span></span>
<button class="flex items-center gap-2 bg-teal-500 hover:bg-teal-600 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed" onclick="event.stopPropagation(); showVerifyModal('valid', event)" disabled>
<i class="fas fa-check-double"></i> 批量验证
</button>
<button class="flex items-center gap-2 bg-blue-500 hover:bg-blue-600 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed" onclick="event.stopPropagation(); resetAllKeysFailCount('valid', event)" data-reset-type="valid" disabled>
<i class="fas fa-redo-alt"></i> 批量重置
</button>
<button class="flex items-center gap-2 bg-primary-600 hover:bg-primary-700 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed" onclick="event.stopPropagation(); copySelectedKeys('valid')" disabled>
<i class="fas fa-copy"></i> 批量复制
</button>
</div>
<div class="key-content p-4 bg-white bg-opacity-40">
<!-- Key list will be populated by JS -->
<ul id="validKeys" class="grid grid-cols-1 md:grid-cols-2 gap-3">
{# Initial keys rendered by server-side for non-JS users or initial load #}
{# JS will replace this content with paginated/filtered results #}
{% if valid_keys %}
{% for key, fail_count in valid_keys.items() %}
<li class="bg-white rounded-lg p-3 shadow-sm hover:shadow-md transition-all duration-300 border border-gray-100 hover:border-success-300 transform hover:-translate-y-1" data-fail-count="{{ fail_count }}">
<div class="flex flex-col justify-between h-full gap-3">
<div class="flex flex-wrap items-center gap-2">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-success-50 text-success-600">
<i class="fas fa-check mr-1"></i> 有效
</span>
<div class="flex items-center gap-1">
<span class="key-text font-mono text-gray-700" data-full-key="{{ key }}">{{ key[:4] + '...' + key[-4:] }}</span>
<button class="text-gray-500 hover:text-primary-600 transition-colors" onclick="toggleKeyVisibility(this)">
<i class="fas fa-eye"></i>
<li class="bg-white rounded-lg p-3 shadow-sm hover:shadow-md transition-all duration-300 border border-gray-100 hover:border-success-300 transform hover:-translate-y-1" data-fail-count="{{ fail_count }}" data-key="{{ key }}">
<!-- Checkbox -->
<input type="checkbox" class="form-checkbox h-5 w-5 text-primary-600 border-gray-300 rounded focus:ring-primary-500 mt-1 key-checkbox" data-key-type="valid" value="{{ key }}">
<!-- Key Info -->
<div class="flex-grow">
<div class="flex flex-col justify-between h-full gap-3">
<div class="flex flex-wrap items-center gap-2">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-success-50 text-success-600">
<i class="fas fa-check mr-1"></i> 有效
</span>
<div class="flex items-center gap-1">
<span class="key-text font-mono text-gray-700" data-full-key="{{ key }}">{{ key[:4] + '...' + key[-4:] }}</span>
<button class="text-gray-500 hover:text-primary-600 transition-colors" onclick="toggleKeyVisibility(this)" title="显示/隐藏密钥">
<i class="fas fa-eye"></i>
</button>
</div>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-amber-50 text-amber-600">
<i class="fas fa-exclamation-triangle mr-1"></i>
失败: {{ fail_count }}
</span>
</div>
<div class="flex flex-wrap items-center gap-2">
<button class="flex items-center gap-1 bg-success-500 hover:bg-success-600 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200" onclick="verifyKey('{{ key }}', this)">
<i class="fas fa-check-circle"></i>
验证
</button>
<button class="flex items-center gap-1 bg-blue-500 hover:bg-blue-600 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200" onclick="resetKeyFailCount('{{ key }}', this)">
<i class="fas fa-redo-alt"></i>
重置
</button>
<button class="flex items-center gap-1 bg-gray-500 hover:bg-gray-600 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200" onclick="copyKey('{{ key }}')">
<i class="fas fa-copy"></i>
复制
</button>
<button class="flex items-center gap-1 bg-purple-500 hover:bg-purple-600 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200" onclick="showKeyUsageDetails('{{ key }}')">
<i class="fas fa-chart-pie"></i>
详情
</button>
</div>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-amber-50 text-amber-600">
<i class="fas fa-exclamation-triangle mr-1"></i>
失败: {{ fail_count }}
</span>
</div>
<div class="flex flex-wrap items-center gap-2">
<button class="flex items-center gap-1 bg-success-500 hover:bg-success-600 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200" onclick="verifyKey('{{ key }}', this)">
<i class="fas fa-check-circle"></i>
验证
</button>
<button class="flex items-center gap-1 bg-blue-500 hover:bg-blue-600 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200" onclick="resetKeyFailCount('{{ key }}', this)">
<i class="fas fa-redo-alt"></i>
重置
</button>
<button class="flex items-center gap-1 bg-gray-500 hover:bg-gray-600 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200" onclick="copyKey('{{ key }}')">
<i class="fas fa-copy"></i>
复制
</button>
</div>
</div>
</li>
{% endfor %}
{% endfor %}
{% else %}
<li class="text-center text-gray-500 py-4">暂无有效密钥</li>
<li class="text-center text-gray-500 py-4 col-span-full">暂无有效密钥</li>
{% endif %}
</ul>
<!-- 有效密钥分页控件容器 -->
<div id="validPaginationControls" class="flex justify-center items-center mt-4 space-x-2">
<!-- Pagination controls will be generated by JS -->
</div>
</div>
</div>
<!-- 无效密钥区域 -->
<div class="stats-card mb-6 animate-fade-in" style="animation-delay: 0.4s">
<div class="stats-card-header cursor-pointer" onclick="toggleSection(this, 'invalidKeys')">
<div class="flex items-center gap-3">
<!-- Left side: Title and Toggle Icon -->
<div class="flex items-center gap-3 flex-shrink-0"> <!-- Prevent shrinking -->
<i class="fas fa-chevron-down toggle-icon text-primary-600"></i>
<i class="fas fa-times-circle text-danger-500"></i>
<h2 class="text-lg font-semibold">无效密钥列表 ({{ invalid_key_count }})</h2>
<h2 class="text-lg font-semibold whitespace-nowrap">无效密钥列表 ({{ invalid_key_count }})</h2>
</div>
<div class="flex gap-2">
<button class="flex items-center gap-2 bg-teal-500 hover:bg-teal-600 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200" onclick="event.stopPropagation(); showVerifyModal('invalid', event)"> <!-- 新增批量验证按钮 -->
<i class="fas fa-check-double"></i>
批量验证
</button>
<button class="flex items-center gap-2 bg-blue-500 hover:bg-blue-600 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200" onclick="event.stopPropagation(); resetAllKeysFailCount('invalid', event)" data-reset-type="invalid">
<i class="fas fa-redo-alt"></i>
批量重置
</button>
<button class="flex items-center gap-2 bg-primary-600 hover:bg-primary-700 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200" onclick="event.stopPropagation(); copyKeys('invalid')">
<i class="fas fa-copy"></i>
批量复制
</button>
<!-- Right side: Select All -->
<div class="flex items-center gap-1 ml-auto flex-shrink-0" onclick="event.stopPropagation();"> <!-- Use ml-auto, Prevent shrinking -->
<input type="checkbox" id="selectAllInvalid" class="form-checkbox h-4 w-4 text-primary-600 border-gray-300 rounded focus:ring-primary-500" onchange="toggleSelectAll('invalid', this.checked)">
<label for="selectAllInvalid" class="text-sm text-gray-600 select-none whitespace-nowrap">全选</label>
</div>
</div>
<!-- 批量操作按钮组 (仅在选中时显示) -->
<div id="invalidBatchActions" class="p-3 bg-gray-50 border-t border-gray-200 hidden flex items-center flex-wrap gap-3"> <!-- Added flex-wrap -->
<span class="text-sm font-medium text-gray-700 whitespace-nowrap">已选择 <span id="invalidSelectedCount">0</span></span>
<button class="flex items-center gap-2 bg-teal-500 hover:bg-teal-600 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed" onclick="event.stopPropagation(); showVerifyModal('invalid', event)" disabled>
<i class="fas fa-check-double"></i> 批量验证
</button>
<button class="flex items-center gap-2 bg-blue-500 hover:bg-blue-600 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed" onclick="event.stopPropagation(); resetAllKeysFailCount('invalid', event)" data-reset-type="invalid" disabled>
<i class="fas fa-redo-alt"></i> 批量重置
</button>
<button class="flex items-center gap-2 bg-primary-600 hover:bg-primary-700 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed" onclick="event.stopPropagation(); copySelectedKeys('invalid')" disabled>
<i class="fas fa-copy"></i> 批量复制
</button>
</div>
<div class="key-content p-4 bg-white bg-opacity-40">
<!-- Key list will be populated by JS -->
<ul id="invalidKeys" class="grid grid-cols-1 md:grid-cols-2 gap-3">
{# Initial keys rendered by server-side #}
{# JS will replace this content with paginated results #}
{% if invalid_keys %}
{% for key, fail_count in invalid_keys.items() %}
<li class="bg-white rounded-lg p-3 shadow-sm hover:shadow-md transition-all duration-300 border border-gray-100 hover:border-danger-300 transform hover:-translate-y-1">
<div class="flex flex-col justify-between h-full gap-3">
<div class="flex flex-wrap items-center gap-2">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-danger-50 text-danger-600">
<i class="fas fa-times mr-1"></i> 无效
</span>
<div class="flex items-center gap-1">
<span class="key-text font-mono text-gray-700" data-full-key="{{ key }}">{{ key[:4] + '...' + key[-4:] }}</span>
<button class="text-gray-500 hover:text-primary-600 transition-colors" onclick="toggleKeyVisibility(this)">
<i class="fas fa-eye"></i>
<li class="bg-white rounded-lg p-3 shadow-sm hover:shadow-md transition-all duration-300 border border-gray-100 hover:border-danger-300 transform hover:-translate-y-1" data-key="{{ key }}">
<!-- Checkbox -->
<input type="checkbox" class="form-checkbox h-5 w-5 text-primary-600 border-gray-300 rounded focus:ring-primary-500 mt-1 key-checkbox" data-key-type="invalid" value="{{ key }}">
<!-- Key Info -->
<div class="flex-grow">
<div class="flex flex-col justify-between h-full gap-3">
<div class="flex flex-wrap items-center gap-2">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-danger-50 text-danger-600">
<i class="fas fa-times mr-1"></i> 无效
</span>
<div class="flex items-center gap-1">
<span class="key-text font-mono text-gray-700" data-full-key="{{ key }}">{{ key[:4] + '...' + key[-4:] }}</span>
<button class="text-gray-500 hover:text-primary-600 transition-colors" onclick="toggleKeyVisibility(this)" title="显示/隐藏密钥">
<i class="fas fa-eye"></i>
</button>
</div>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-amber-50 text-amber-600">
<i class="fas fa-exclamation-triangle mr-1"></i>
失败: {{ fail_count }}
</span>
</div>
<div class="flex flex-wrap items-center gap-2">
<button class="flex items-center gap-1 bg-success-500 hover:bg-success-600 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200" onclick="verifyKey('{{ key }}', this)">
<i class="fas fa-check-circle"></i>
验证
</button>
<button class="flex items-center gap-1 bg-blue-500 hover:bg-blue-600 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200" onclick="resetKeyFailCount('{{ key }}', this)">
<i class="fas fa-redo-alt"></i>
重置
</button>
<button class="flex items-center gap-1 bg-gray-500 hover:bg-gray-600 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200" onclick="copyKey('{{ key }}')">
<i class="fas fa-copy"></i>
复制
</button>
<button class="flex items-center gap-1 bg-purple-500 hover:bg-purple-600 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200" onclick="showKeyUsageDetails('{{ key }}')">
<i class="fas fa-chart-pie"></i>
详情
</button>
</div>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-amber-50 text-amber-600">
<i class="fas fa-exclamation-triangle mr-1"></i>
失败: {{ fail_count }}
</span>
</div>
<div class="flex flex-wrap items-center gap-2">
<button class="flex items-center gap-1 bg-success-500 hover:bg-success-600 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200" onclick="verifyKey('{{ key }}', this)">
<i class="fas fa-check-circle"></i>
验证
</button>
<button class="flex items-center gap-1 bg-blue-500 hover:bg-blue-600 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200" onclick="resetKeyFailCount('{{ key }}', this)">
<i class="fas fa-redo-alt"></i>
重置
</button>
<button class="flex items-center gap-1 bg-gray-500 hover:bg-gray-600 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200" onclick="copyKey('{{ key }}')">
<i class="fas fa-copy"></i>
复制
</button>
</div>
</div>
</li>
{% endfor %}
{% endfor %}
{% else %}
<li class="text-center text-gray-500 py-4">暂无无效密钥</li>
<li class="text-center text-gray-500 py-4 col-span-full">暂无无效密钥</li>
{% endif %}
</ul>
<!-- 无效密钥分页控件容器 -->
<div id="invalidPaginationControls" class="flex justify-center items-center mt-4 space-x-2">
<!-- Pagination controls will be generated by JS -->
</div>
</div>
</div>
<!-- Removed old total keys display -->
</div>
</div>
<!-- Scroll buttons are now in base.html -->
<div class="scroll-buttons">
<button class="scroll-button" onclick="scrollToTop()" title="回到顶部">
@@ -467,7 +531,7 @@
<i class="fas fa-chevron-down"></i>
</button>
</div>
<!-- Notification component is now in base.html (use id="notification") -->
<div id="notification" class="notification"></div>
<!-- 重置确认模态框 -->
@@ -492,7 +556,7 @@
</div>
</div>
</div>
<!-- 验证确认模态框移到 resetModal 外部,避免嵌套导致显示异常 -->
<!-- 验证确认模态框 -->
<div id="verifyModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden">
<div class="bg-white rounded-lg p-6 shadow-xl max-w-md w-full animate-fade-in">
<div class="flex items-center justify-between mb-4">
@@ -514,7 +578,7 @@
</div>
</div>
</div>
<!-- 操作结果模态框 -->
<div id="resultModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden">
<div class="bg-white rounded-2xl p-0 shadow-2xl max-w-lg w-full animate-fade-in border border-gray-200">
@@ -529,8 +593,9 @@
</div>
<div class="px-8 pb-2 w-full">
<div id="resultModalMessage"
class="text-gray-700 text-base leading-relaxed break-all whitespace-pre-line max-h-60 overflow-y-auto border border-gray-100 rounded-lg bg-gray-50 p-4 shadow-inner"
class="text-gray-700 text-base leading-relaxed break-words whitespace-pre-line max-h-80 overflow-y-auto border border-gray-100 rounded-lg bg-gray-50 p-4 shadow-inner"
style="font-family: 'JetBrains Mono', 'Fira Mono', 'Consolas', 'monospace';">
<!-- Content is dynamically generated by JS -->
</div>
</div>
<div class="flex justify-center px-8 pb-6 pt-2">
@@ -564,71 +629,39 @@
</div>
</div>
</div>
<!-- 密钥使用详情模态框 -->
<div id="keyUsageDetailsModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden">
<div class="bg-white rounded-lg p-6 shadow-xl max-w-lg w-full animate-fade-in"> <!-- Adjusted max-width -->
<div class="flex items-center justify-between mb-4 border-b pb-3">
<h3 class="text-xl font-semibold text-gray-800" id="keyUsageDetailsModalTitle">密钥请求详情</h3>
<button onclick="closeKeyUsageDetailsModal()" class="text-gray-500 hover:text-gray-700 focus:outline-none text-xl">
<i class="fas fa-times"></i>
</button>
</div>
<div id="keyUsageDetailsContent" class="mb-6 max-h-[50vh] overflow-y-auto pr-2"> <!-- Adjusted max-height -->
<!-- 详细数据将加载到这里 -->
<div class="text-center py-10">
<i class="fas fa-spinner fa-spin text-primary-600 text-3xl"></i>
<p class="text-gray-500 mt-2">加载中...</p>
</div>
</div>
<div class="flex justify-end pt-4 border-t">
<button onclick="closeKeyUsageDetailsModal()" class="px-5 py-2 bg-gray-200 hover:bg-gray-300 text-gray-800 rounded-lg transition-colors text-sm font-medium">
关闭
</button>
</div>
</div>
</div>
<!-- Footer is now in base.html -->
{% endblock %}
{% block body_scripts %}
<script>
// keys_status.html specific JavaScript initialization
document.addEventListener('DOMContentLoaded', () => {
// Filter functionality based on fail count threshold
const thresholdInput = document.getElementById('failCountThreshold');
const validKeysList = document.getElementById('validKeys');
function filterValidKeys() {
const threshold = parseInt(thresholdInput.value, 10);
if (isNaN(threshold)) return; // Do nothing if input is not a number
const keys = validKeysList.querySelectorAll('li');
let visibleCount = 0;
keys.forEach(keyItem => {
// Check if it's a key item (has data-fail-count) before processing
if (keyItem.hasAttribute('data-fail-count')) {
const failCount = parseInt(keyItem.getAttribute('data-fail-count'), 10);
if (failCount >= threshold) {
keyItem.style.display = ''; // Show item
visibleCount++;
} else {
keyItem.style.display = 'none'; // Hide item
}
}
});
// Optional: Show a message if no keys match the filter
const noMatchMsgId = 'no-valid-keys-msg';
let noMatchMsg = validKeysList.querySelector(`#${noMatchMsgId}`);
if (visibleCount === 0 && keys.length > 0) { // Only show if there were keys initially
if (!noMatchMsg) {
noMatchMsg = document.createElement('li');
noMatchMsg.id = noMatchMsgId;
noMatchMsg.className = 'text-center text-gray-500 py-4';
noMatchMsg.textContent = '没有符合条件的有效密钥';
validKeysList.appendChild(noMatchMsg);
}
noMatchMsg.style.display = '';
} else if (noMatchMsg) {
noMatchMsg.style.display = 'none';
}
}
if (thresholdInput && validKeysList) {
thresholdInput.addEventListener('input', filterValidKeys);
// Initial filter on load
filterValidKeys();
}
// Initialize other elements or event listeners if needed
// The main logic (verifyKey, resetKeyFailCount, copyKey, etc.) is in keys_status.js
// The toggleSection logic is now specific to this page
window.toggleSection = function(header, sectionId) {
const toggleIcon = header.querySelector('.toggle-icon');
const content = header.nextElementSibling; // Assumes content is immediately after header
if (toggleIcon && content) {
toggleIcon.classList.toggle('collapsed');
content.classList.toggle('collapsed');
}
}
});
// keys_status.html specific JavaScript initialization is now handled by keys_status.js
// The DOMContentLoaded listener in keys_status.js will execute after the DOM is ready.
// No inline script needed here anymore.
</script>
{% endblock %}

View File

@@ -6,9 +6,19 @@ import re
import base64
import requests
from typing import Dict, Any, List, Optional, Tuple
from pathlib import Path
import logging # Import logging
from app.core.constants import DATA_URL_PATTERN, IMAGE_URL_PATTERN, VALID_IMAGE_RATIOS
# Define logger for helper functions if needed, or use specific loggers
helper_logger = logging.getLogger("app.utils") # Or use a more specific logger if available
# Define project root and version file path here for get_current_version
# Assuming this file is at app/utils/helpers.py
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
VERSION_FILE_PATH = PROJECT_ROOT / "VERSION"
def extract_mime_type_and_data(base64_string: str) -> Tuple[Optional[str], str]:
"""
@@ -146,3 +156,21 @@ def is_valid_api_key(key: str) -> bool:
return False
def get_current_version(default_version: str = "0.0.0") -> str:
"""Reads the current version from the VERSION file."""
version_file = VERSION_FILE_PATH # Use Path object defined above
try:
# Use Path object's open method
with version_file.open('r', encoding='utf-8') as f:
version = f.read().strip()
if not version:
helper_logger.warning(f"VERSION file ('{version_file}') is empty. Using default version '{default_version}'.")
return default_version
return version
except FileNotFoundError:
helper_logger.warning(f"VERSION file not found at '{version_file}'. Using default version '{default_version}'.")
return default_version
except IOError as e:
helper_logger.error(f"Error reading VERSION file ('{version_file}'): {e}. Using default version '{default_version}'.")
return default_version

View File

@@ -1,9 +1,39 @@
version: '3'
volumes:
mysql_data:
services:
gemini-balance:
build: .
image: ghcr.io/snailyp/gemini-balance:latest
container_name: gemini-balance
restart: unless-stopped
ports:
- "8000:8000"
env_file:
- .env
depends_on:
mysql:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "python -c \"import requests; exit(0) if requests.get('http://localhost:8000/health').status_code == 200 else exit(1)\""]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
mysql:
image: mysql:8
container_name: gemini-balance-mysql
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: your_root_password
MYSQL_DATABASE: ${MYSQL_DATABASE}
MYSQL_USER: ${MYSQL_USER}
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
# ports:
# - "3306:3306"
volumes:
- mysql_data:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1"]
interval: 10s # 每隔10秒检查一次
timeout: 5s # 每次检查的超时时间为5秒
retries: 3 # 重试3次失败后标记为 unhealthy
start_period: 30s # 容器启动后等待30秒再开始第一次健康检查

View File

@@ -17,3 +17,5 @@ aiomysql
databases
python-dotenv
apscheduler # 添加定时任务库
packaging