Upload 7 files
Browse files- README_hf.md +92 -0
- app.py +15 -0
- business_gemini_session.json.example +24 -0
- gemini.py +2498 -0
- hf_manual_deploy.md +97 -0
- index.html +2025 -0
- requirements-hf.txt +5 -0
README_hf.md
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Business Gemini Pool
|
| 3 |
+
emoji: 🚀
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: green
|
| 6 |
+
sdk: gradio
|
| 7 |
+
sdk_version: 4.44.0
|
| 8 |
+
app_file: app.py
|
| 9 |
+
pinned: false
|
| 10 |
+
license: mit
|
| 11 |
+
---
|
| 12 |
+
|
| 13 |
+
# Business Gemini Pool 管理系统
|
| 14 |
+
|
| 15 |
+
一个基于 Flask 的 Google Gemini Enterprise API 代理服务,现已部署到 Hugging Face Spaces!
|
| 16 |
+
|
| 17 |
+
## 🌟 特性
|
| 18 |
+
|
| 19 |
+
- **多账号轮询**: 支持配置多个 Gemini 账号,自动轮询使用
|
| 20 |
+
- **OpenAI 兼容接口**: 提供与 OpenAI API 兼容的接口格式
|
| 21 |
+
- **流式响应**: 支持 SSE 流式输出
|
| 22 |
+
- **Web 管理控制台**: 美观的 Web 管理界面
|
| 23 |
+
- **图片处理**: 支持图片输入输出
|
| 24 |
+
- **代理支持**: 支持 HTTP/HTTPS 代理配置
|
| 25 |
+
|
| 26 |
+
## 🚀 快速开始
|
| 27 |
+
|
| 28 |
+
1. 访问此 Space:[Business Gemini Pool](https://huggingface.co/spaces/your-username/business-gemini-pool)
|
| 29 |
+
|
| 30 |
+
2. 在 Web 界面中配置你的 Gemini 账号信息
|
| 31 |
+
|
| 32 |
+
3. 开始使用 API 服务!
|
| 33 |
+
|
| 34 |
+
## 📝 配置说明
|
| 35 |
+
|
| 36 |
+
### 环境变量(可选)
|
| 37 |
+
在 Space 设置中可以配置以下环境变量:
|
| 38 |
+
- `PROXY_URL`: 代理服务器地址
|
| 39 |
+
- `PROXY_ENABLED`: 是否启用代理 (true/false)
|
| 40 |
+
|
| 41 |
+
### 账号配置
|
| 42 |
+
在 Web 界面的"账号管理"页面��加你的 Gemini 账号:
|
| 43 |
+
- Team ID
|
| 44 |
+
- Secure Cookie
|
| 45 |
+
- Host Cookie
|
| 46 |
+
- Session Index
|
| 47 |
+
- User Agent
|
| 48 |
+
|
| 49 |
+
## 🔧 API 使用
|
| 50 |
+
|
| 51 |
+
### 获取模型列表
|
| 52 |
+
```bash
|
| 53 |
+
curl https://your-space.hf.space/v1/models
|
| 54 |
+
```
|
| 55 |
+
|
| 56 |
+
### 聊天对话
|
| 57 |
+
```bash
|
| 58 |
+
curl -X POST https://your-space.hf.space/v1/chat/completions \
|
| 59 |
+
-H "Content-Type: application/json" \
|
| 60 |
+
-d '{
|
| 61 |
+
"model": "gemini-enterprise",
|
| 62 |
+
"messages": [
|
| 63 |
+
{"role": "user", "content": "Hello!"}
|
| 64 |
+
]
|
| 65 |
+
}'
|
| 66 |
+
```
|
| 67 |
+
|
| 68 |
+
## 🛠️ 本地部署
|
| 69 |
+
|
| 70 |
+
```bash
|
| 71 |
+
# 克隆仓库
|
| 72 |
+
git clone https://huggingface.co/spaces/your-username/business-gemini-pool
|
| 73 |
+
cd business-gemini-pool
|
| 74 |
+
|
| 75 |
+
# 安装依赖
|
| 76 |
+
pip install -r requirements-hf.txt
|
| 77 |
+
|
| 78 |
+
# 运行
|
| 79 |
+
python app.py
|
| 80 |
+
```
|
| 81 |
+
|
| 82 |
+
## 📄 许可证
|
| 83 |
+
|
| 84 |
+
MIT License
|
| 85 |
+
|
| 86 |
+
## 🤝 贡献
|
| 87 |
+
|
| 88 |
+
欢迎提交 Issue 和 Pull Request!
|
| 89 |
+
|
| 90 |
+
## ⚠️ 免责声明
|
| 91 |
+
|
| 92 |
+
本工具仅供学习和研究使用,请遵守 Google 的使用条款。
|
app.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Hugging Face Spaces兼容的应用入口文件"""
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
import sys
|
| 5 |
+
|
| 6 |
+
# 将当前目录添加到Python路径
|
| 7 |
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
| 8 |
+
|
| 9 |
+
# 导入主应用
|
| 10 |
+
from gemini import app
|
| 11 |
+
|
| 12 |
+
if __name__ == "__main__":
|
| 13 |
+
# Hugging Face Spaces通常需要运行在特定端口
|
| 14 |
+
port = int(os.environ.get("PORT", 7860))
|
| 15 |
+
app.run(host="0.0.0.0", port=port)
|
business_gemini_session.json.example
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"proxy": "http://127.0.0.1:7890",
|
| 3 |
+
"proxy_enabled": false,
|
| 4 |
+
"image_base_url": "http://127.0.0.1:8000/",
|
| 5 |
+
"image_output_mode": "url",
|
| 6 |
+
"log_level": "INFO",
|
| 7 |
+
"admin_password_hash": "",
|
| 8 |
+
"admin_secret_key": "",
|
| 9 |
+
"api_tokens": [
|
| 10 |
+
"please_set_api_token_here"
|
| 11 |
+
],
|
| 12 |
+
"accounts": [
|
| 13 |
+
],
|
| 14 |
+
"models": [
|
| 15 |
+
{
|
| 16 |
+
"id": "gemini-3-pro-preview",
|
| 17 |
+
"name": "gemini-3-pro-preview",
|
| 18 |
+
"description": "gemini-3-pro-preview 模型",
|
| 19 |
+
"context_length": 32768,
|
| 20 |
+
"max_tokens": 8192,
|
| 21 |
+
"price_per_1k_tokens": 0.0015
|
| 22 |
+
}
|
| 23 |
+
]
|
| 24 |
+
}
|
gemini.py
ADDED
|
@@ -0,0 +1,2498 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Business Gemini OpenAPI 兼容服务
|
| 2 |
+
整合JWT获取和聊天功能,提供OpenAPI接口
|
| 3 |
+
支持多账号轮训
|
| 4 |
+
支持图片输出(OpenAI格式)
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import json
|
| 8 |
+
import time
|
| 9 |
+
import hmac
|
| 10 |
+
import hashlib
|
| 11 |
+
import base64
|
| 12 |
+
import uuid
|
| 13 |
+
import threading
|
| 14 |
+
import os
|
| 15 |
+
import re
|
| 16 |
+
import shutil
|
| 17 |
+
import mimetypes
|
| 18 |
+
import requests
|
| 19 |
+
from pathlib import Path
|
| 20 |
+
from datetime import datetime, timedelta, timezone
|
| 21 |
+
from dataclasses import dataclass, field
|
| 22 |
+
from typing import List, Optional, Dict, Any, Tuple
|
| 23 |
+
import builtins
|
| 24 |
+
import secrets
|
| 25 |
+
from flask import Flask, request, Response, jsonify, send_from_directory, abort
|
| 26 |
+
from flask_cors import CORS
|
| 27 |
+
from functools import wraps
|
| 28 |
+
from werkzeug.security import generate_password_hash, check_password_hash
|
| 29 |
+
|
| 30 |
+
# 禁用SSL警告
|
| 31 |
+
import urllib3
|
| 32 |
+
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
| 33 |
+
|
| 34 |
+
# 配置
|
| 35 |
+
CONFIG_FILE = Path(__file__).parent / "business_gemini_session.json"
|
| 36 |
+
|
| 37 |
+
# 图片缓存配置
|
| 38 |
+
IMAGE_CACHE_DIR = Path(__file__).parent / "image"
|
| 39 |
+
IMAGE_CACHE_HOURS = 1 # 图片缓存时间(小时)
|
| 40 |
+
IMAGE_CACHE_DIR.mkdir(exist_ok=True)
|
| 41 |
+
|
| 42 |
+
# API endpoints
|
| 43 |
+
BASE_URL = "https://biz-discoveryengine.googleapis.com/v1alpha/locations/global"
|
| 44 |
+
CREATE_SESSION_URL = f"{BASE_URL}/widgetCreateSession"
|
| 45 |
+
STREAM_ASSIST_URL = f"{BASE_URL}/widgetStreamAssist"
|
| 46 |
+
LIST_FILE_METADATA_URL = f"{BASE_URL}/widgetListSessionFileMetadata"
|
| 47 |
+
ADD_CONTEXT_FILE_URL = f"{BASE_URL}/widgetAddContextFile"
|
| 48 |
+
GETOXSRF_URL = "https://business.gemini.google/auth/getoxsrf"
|
| 49 |
+
|
| 50 |
+
# 账号错误冷却时间(秒)
|
| 51 |
+
AUTH_ERROR_COOLDOWN_SECONDS = 900 # 凭证错误,15分钟
|
| 52 |
+
RATE_LIMIT_COOLDOWN_SECONDS = 300 # 触发限额,5分钟
|
| 53 |
+
GENERIC_ERROR_COOLDOWN_SECONDS = 120 # 其他错误的短暂冷却
|
| 54 |
+
LOG_LEVELS = {"DEBUG": 10, "INFO": 20, "ERROR": 40}
|
| 55 |
+
DEFAULT_LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper()
|
| 56 |
+
CURRENT_LOG_LEVEL_NAME = DEFAULT_LOG_LEVEL if DEFAULT_LOG_LEVEL in LOG_LEVELS else "INFO"
|
| 57 |
+
CURRENT_LOG_LEVEL = LOG_LEVELS[CURRENT_LOG_LEVEL_NAME]
|
| 58 |
+
ADMIN_SECRET_KEY = None
|
| 59 |
+
API_TOKENS = set()
|
| 60 |
+
|
| 61 |
+
try:
|
| 62 |
+
from zoneinfo import ZoneInfo
|
| 63 |
+
except ImportError:
|
| 64 |
+
ZoneInfo = None
|
| 65 |
+
|
| 66 |
+
# Flask应用
|
| 67 |
+
app = Flask(__name__, static_folder='.')
|
| 68 |
+
CORS(app)
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def _infer_log_level(text: str) -> str:
|
| 72 |
+
t = text.strip()
|
| 73 |
+
if t.startswith("[DEBUG]"):
|
| 74 |
+
return "DEBUG"
|
| 75 |
+
if t.startswith("[ERROR]") or t.startswith("[!]"):
|
| 76 |
+
return "ERROR"
|
| 77 |
+
return "INFO"
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
_original_print = builtins.print
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def filtered_print(*args, **kwargs):
|
| 84 |
+
"""简单的日志过滤,根据全局日志级别屏蔽低级别输出"""
|
| 85 |
+
level = kwargs.pop("_level", None)
|
| 86 |
+
sep = kwargs.get("sep", " ")
|
| 87 |
+
text = sep.join(str(a) for a in args)
|
| 88 |
+
level_name = (level or _infer_log_level(text)).upper()
|
| 89 |
+
if LOG_LEVELS.get(level_name, LOG_LEVELS["INFO"]) >= CURRENT_LOG_LEVEL:
|
| 90 |
+
_original_print(*args, **kwargs)
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
builtins.print = filtered_print
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def set_log_level(level: str, persist: bool = False):
|
| 97 |
+
"""设置全局日志级别"""
|
| 98 |
+
global CURRENT_LOG_LEVEL_NAME, CURRENT_LOG_LEVEL
|
| 99 |
+
lvl = (level or "").upper()
|
| 100 |
+
if lvl not in LOG_LEVELS:
|
| 101 |
+
raise ValueError(f"无效日志级别: {level}")
|
| 102 |
+
CURRENT_LOG_LEVEL_NAME = lvl
|
| 103 |
+
CURRENT_LOG_LEVEL = LOG_LEVELS[lvl]
|
| 104 |
+
if persist and globals().get("account_manager") and account_manager.config is not None:
|
| 105 |
+
account_manager.config["log_level"] = lvl
|
| 106 |
+
account_manager.save_config()
|
| 107 |
+
_original_print(f"[LOG] 当前日志级别: {CURRENT_LOG_LEVEL_NAME}")
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
class AccountError(Exception):
|
| 111 |
+
"""基础账号异常"""
|
| 112 |
+
|
| 113 |
+
def __init__(self, message: str, status_code: Optional[int] = None):
|
| 114 |
+
super().__init__(message)
|
| 115 |
+
self.status_code = status_code
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
class AccountAuthError(AccountError):
|
| 119 |
+
"""凭证/权限相关异常"""
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
class AccountRateLimitError(AccountError):
|
| 123 |
+
"""配额或限流异常"""
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
class AccountRequestError(AccountError):
|
| 127 |
+
"""其他请求异常"""
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
class NoAvailableAccount(AccountError):
|
| 131 |
+
"""无可用账号异常"""
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def get_admin_secret_key() -> str:
|
| 135 |
+
"""获取/初始化后台密钥"""
|
| 136 |
+
global ADMIN_SECRET_KEY
|
| 137 |
+
if ADMIN_SECRET_KEY:
|
| 138 |
+
return ADMIN_SECRET_KEY
|
| 139 |
+
if account_manager.config is None:
|
| 140 |
+
ADMIN_SECRET_KEY = os.getenv("ADMIN_SECRET_KEY", "change_me_secret")
|
| 141 |
+
return ADMIN_SECRET_KEY
|
| 142 |
+
secret = account_manager.config.get("admin_secret_key") or os.getenv("ADMIN_SECRET_KEY")
|
| 143 |
+
if not secret:
|
| 144 |
+
secret = secrets.token_urlsafe(32)
|
| 145 |
+
account_manager.config["admin_secret_key"] = secret
|
| 146 |
+
account_manager.save_config()
|
| 147 |
+
ADMIN_SECRET_KEY = secret
|
| 148 |
+
return ADMIN_SECRET_KEY
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
def load_api_tokens():
|
| 152 |
+
"""从配置加载用户访问token"""
|
| 153 |
+
global API_TOKENS
|
| 154 |
+
API_TOKENS = set()
|
| 155 |
+
if not account_manager.config:
|
| 156 |
+
return
|
| 157 |
+
tokens = account_manager.config.get("api_tokens") or account_manager.config.get("api_token")
|
| 158 |
+
if isinstance(tokens, str):
|
| 159 |
+
API_TOKENS.add(tokens)
|
| 160 |
+
elif isinstance(tokens, list):
|
| 161 |
+
for t in tokens:
|
| 162 |
+
if isinstance(t, str):
|
| 163 |
+
API_TOKENS.add(t)
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
def persist_api_tokens():
|
| 167 |
+
if account_manager.config is None:
|
| 168 |
+
account_manager.config = {}
|
| 169 |
+
account_manager.config["api_tokens"] = list(API_TOKENS)
|
| 170 |
+
account_manager.save_config()
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def get_admin_password_hash() -> Optional[str]:
|
| 174 |
+
if account_manager.config:
|
| 175 |
+
return account_manager.config.get("admin_password_hash")
|
| 176 |
+
return None
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def set_admin_password(password: str):
|
| 180 |
+
if not password:
|
| 181 |
+
raise ValueError("密码不能为空")
|
| 182 |
+
if account_manager.config is None:
|
| 183 |
+
account_manager.config = {}
|
| 184 |
+
account_manager.config["admin_password_hash"] = generate_password_hash(password)
|
| 185 |
+
account_manager.save_config()
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def is_valid_api_token(token: str) -> bool:
|
| 189 |
+
if not token:
|
| 190 |
+
return False
|
| 191 |
+
if verify_admin_token(token):
|
| 192 |
+
return True
|
| 193 |
+
return token in API_TOKENS
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
def require_api_auth(func):
|
| 197 |
+
"""开放接口需要 api_token 或 admin token"""
|
| 198 |
+
@wraps(func)
|
| 199 |
+
def wrapper(*args, **kwargs):
|
| 200 |
+
token = (
|
| 201 |
+
request.headers.get("X-API-Token")
|
| 202 |
+
or request.headers.get("Authorization", "").replace("Bearer ", "")
|
| 203 |
+
or request.cookies.get("admin_token")
|
| 204 |
+
)
|
| 205 |
+
if not is_valid_api_token(token):
|
| 206 |
+
return jsonify({"error": "未授权"}), 401
|
| 207 |
+
return func(*args, **kwargs)
|
| 208 |
+
return wrapper
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
def create_admin_token(exp_seconds: int = 86400) -> str:
|
| 212 |
+
payload = {
|
| 213 |
+
"exp": time.time() + exp_seconds,
|
| 214 |
+
"ts": int(time.time())
|
| 215 |
+
}
|
| 216 |
+
payload_b = json.dumps(payload, separators=(",", ":")).encode()
|
| 217 |
+
b64 = base64.urlsafe_b64encode(payload_b).decode().rstrip("=")
|
| 218 |
+
secret = get_admin_secret_key().encode()
|
| 219 |
+
signature = hmac.new(secret, b64.encode(), hashlib.sha256).hexdigest()
|
| 220 |
+
return f"{b64}.{signature}"
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
def verify_admin_token(token: str) -> bool:
|
| 224 |
+
if not token:
|
| 225 |
+
return False
|
| 226 |
+
try:
|
| 227 |
+
b64, sig = token.split(".", 1)
|
| 228 |
+
except ValueError:
|
| 229 |
+
return False
|
| 230 |
+
expected_sig = hmac.new(get_admin_secret_key().encode(), b64.encode(), hashlib.sha256).hexdigest()
|
| 231 |
+
if not hmac.compare_digest(expected_sig, sig):
|
| 232 |
+
return False
|
| 233 |
+
padding = '=' * (-len(b64) % 4)
|
| 234 |
+
try:
|
| 235 |
+
payload = json.loads(base64.urlsafe_b64decode(b64 + padding).decode())
|
| 236 |
+
except Exception:
|
| 237 |
+
return False
|
| 238 |
+
if payload.get("exp", 0) < time.time():
|
| 239 |
+
return False
|
| 240 |
+
return True
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
def require_admin(func):
|
| 244 |
+
@wraps(func)
|
| 245 |
+
def wrapper(*args, **kwargs):
|
| 246 |
+
token = (
|
| 247 |
+
request.headers.get("X-Admin-Token")
|
| 248 |
+
or request.headers.get("Authorization", "").replace("Bearer ", "")
|
| 249 |
+
or request.cookies.get("admin_token")
|
| 250 |
+
)
|
| 251 |
+
if not verify_admin_token(token):
|
| 252 |
+
return jsonify({"error": "未授权"}), 401
|
| 253 |
+
return func(*args, **kwargs)
|
| 254 |
+
return wrapper
|
| 255 |
+
|
| 256 |
+
|
| 257 |
+
def seconds_until_next_pt_midnight(now_ts: Optional[float] = None) -> int:
|
| 258 |
+
"""计算距离下一个 PT 午夜的秒数,用于配额冷却"""
|
| 259 |
+
now_utc = datetime.now(timezone.utc) if now_ts is None else datetime.fromtimestamp(now_ts, tz=timezone.utc)
|
| 260 |
+
if ZoneInfo:
|
| 261 |
+
pt_tz = ZoneInfo("America/Los_Angeles")
|
| 262 |
+
now_pt = now_utc.astimezone(pt_tz)
|
| 263 |
+
else:
|
| 264 |
+
# 兼容旧版本 Python 的简易回退(不考虑夏令时)
|
| 265 |
+
now_pt = now_utc - timedelta(hours=8)
|
| 266 |
+
|
| 267 |
+
tomorrow = (now_pt + timedelta(days=1)).date()
|
| 268 |
+
midnight_pt = datetime.combine(tomorrow, datetime.min.time(), tzinfo=now_pt.tzinfo)
|
| 269 |
+
delta = (midnight_pt - now_pt).total_seconds()
|
| 270 |
+
return max(0, int(delta))
|
| 271 |
+
|
| 272 |
+
|
| 273 |
+
class AccountManager:
|
| 274 |
+
"""多账号管理器,支持轮训策略"""
|
| 275 |
+
|
| 276 |
+
def __init__(self):
|
| 277 |
+
self.config = None
|
| 278 |
+
self.accounts = [] # 账号列表
|
| 279 |
+
self.current_index = 0 # 当前轮训索引
|
| 280 |
+
self.account_states = {} # 账号状态: {index: {jwt, jwt_time, session, available, cooldown_until, cooldown_reason}}
|
| 281 |
+
self.lock = threading.Lock()
|
| 282 |
+
self.auth_error_cooldown = AUTH_ERROR_COOLDOWN_SECONDS
|
| 283 |
+
self.rate_limit_cooldown = RATE_LIMIT_COOLDOWN_SECONDS
|
| 284 |
+
self.generic_error_cooldown = GENERIC_ERROR_COOLDOWN_SECONDS
|
| 285 |
+
|
| 286 |
+
def load_config(self):
|
| 287 |
+
"""加载配置"""
|
| 288 |
+
if CONFIG_FILE.exists():
|
| 289 |
+
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
|
| 290 |
+
self.config = json.load(f)
|
| 291 |
+
if "log_level" in self.config:
|
| 292 |
+
try:
|
| 293 |
+
set_log_level(self.config.get("log_level"), persist=False)
|
| 294 |
+
except Exception:
|
| 295 |
+
pass
|
| 296 |
+
if "admin_secret_key" in self.config:
|
| 297 |
+
global ADMIN_SECRET_KEY
|
| 298 |
+
ADMIN_SECRET_KEY = self.config.get("admin_secret_key")
|
| 299 |
+
load_api_tokens()
|
| 300 |
+
self.accounts = self.config.get("accounts", [])
|
| 301 |
+
# 初始化账号状态
|
| 302 |
+
for i, acc in enumerate(self.accounts):
|
| 303 |
+
available = acc.get("available", True) # 默认可用
|
| 304 |
+
self.account_states[i] = {
|
| 305 |
+
"jwt": None,
|
| 306 |
+
"jwt_time": 0,
|
| 307 |
+
"session": None,
|
| 308 |
+
"available": available,
|
| 309 |
+
"cooldown_until": acc.get("cooldown_until"),
|
| 310 |
+
"cooldown_reason": acc.get("unavailable_reason") or acc.get("cooldown_reason") or ""
|
| 311 |
+
}
|
| 312 |
+
return self.config
|
| 313 |
+
|
| 314 |
+
def save_config(self):
|
| 315 |
+
"""保存配置到文件"""
|
| 316 |
+
if self.config and CONFIG_FILE.exists():
|
| 317 |
+
with open(CONFIG_FILE, "w", encoding="utf-8") as f:
|
| 318 |
+
json.dump(self.config, f, indent=4, ensure_ascii=False)
|
| 319 |
+
|
| 320 |
+
def mark_account_unavailable(self, index: int, reason: str = ""):
|
| 321 |
+
"""标记账号不可用"""
|
| 322 |
+
with self.lock:
|
| 323 |
+
if 0 <= index < len(self.accounts):
|
| 324 |
+
self.accounts[index]["available"] = False
|
| 325 |
+
self.accounts[index]["unavailable_reason"] = reason
|
| 326 |
+
self.accounts[index]["unavailable_time"] = datetime.now().isoformat()
|
| 327 |
+
self.account_states[index]["available"] = False
|
| 328 |
+
self.save_config()
|
| 329 |
+
print(f"[!] 账号 {index} 已标记为不可用: {reason}")
|
| 330 |
+
|
| 331 |
+
def mark_account_cooldown(self, index: int, reason: str = "", cooldown_seconds: Optional[int] = None):
|
| 332 |
+
"""临时拉黑账号(冷却),在冷却时间内不会被选择"""
|
| 333 |
+
if cooldown_seconds is None:
|
| 334 |
+
cooldown_seconds = self.generic_error_cooldown
|
| 335 |
+
|
| 336 |
+
with self.lock:
|
| 337 |
+
if 0 <= index < len(self.accounts):
|
| 338 |
+
now_ts = time.time()
|
| 339 |
+
new_until = now_ts + cooldown_seconds
|
| 340 |
+
state = self.account_states.setdefault(index, {})
|
| 341 |
+
current_until = state.get("cooldown_until") or 0
|
| 342 |
+
# 如果已有更长的冷却,则不重复更新
|
| 343 |
+
if current_until > now_ts and current_until >= new_until:
|
| 344 |
+
return
|
| 345 |
+
|
| 346 |
+
until = max(new_until, current_until)
|
| 347 |
+
state["cooldown_until"] = until
|
| 348 |
+
state["cooldown_reason"] = reason
|
| 349 |
+
state["jwt"] = None
|
| 350 |
+
state["jwt_time"] = 0
|
| 351 |
+
state["session"] = None
|
| 352 |
+
|
| 353 |
+
# 在配置中记录冷却信息,便于前端展示
|
| 354 |
+
self.accounts[index]["cooldown_until"] = until
|
| 355 |
+
self.accounts[index]["unavailable_reason"] = reason
|
| 356 |
+
self.accounts[index]["unavailable_time"] = datetime.now().isoformat()
|
| 357 |
+
|
| 358 |
+
self.save_config()
|
| 359 |
+
print(f"[!] 账号 {index} 进入冷却 {cooldown_seconds} 秒: {reason}")
|
| 360 |
+
|
| 361 |
+
def _is_in_cooldown(self, index: int, now_ts: Optional[float] = None) -> bool:
|
| 362 |
+
"""检查账号是否处于冷却期"""
|
| 363 |
+
now_ts = now_ts or time.time()
|
| 364 |
+
state = self.account_states.get(index, {})
|
| 365 |
+
cooldown_until = state.get("cooldown_until")
|
| 366 |
+
if not cooldown_until:
|
| 367 |
+
return False
|
| 368 |
+
return now_ts < cooldown_until
|
| 369 |
+
|
| 370 |
+
def get_next_cooldown_info(self) -> Optional[dict]:
|
| 371 |
+
"""获取最近即将结束冷却的账号信息"""
|
| 372 |
+
now_ts = time.time()
|
| 373 |
+
candidates = []
|
| 374 |
+
for idx, state in self.account_states.items():
|
| 375 |
+
cooldown_until = state.get("cooldown_until")
|
| 376 |
+
if cooldown_until and cooldown_until > now_ts and state.get("available", True):
|
| 377 |
+
candidates.append((cooldown_until, idx))
|
| 378 |
+
if not candidates:
|
| 379 |
+
return None
|
| 380 |
+
cooldown_until, idx = min(candidates, key=lambda x: x[0])
|
| 381 |
+
return {"index": idx, "cooldown_until": cooldown_until}
|
| 382 |
+
|
| 383 |
+
def is_account_available(self, index: int) -> bool:
|
| 384 |
+
"""计算账号当前是否可用(考虑冷却和手动禁用)"""
|
| 385 |
+
state = self.account_states.get(index, {})
|
| 386 |
+
if not state.get("available", True):
|
| 387 |
+
return False
|
| 388 |
+
return not self._is_in_cooldown(index)
|
| 389 |
+
|
| 390 |
+
def get_available_accounts(self):
|
| 391 |
+
"""获取可用账号列表"""
|
| 392 |
+
now_ts = time.time()
|
| 393 |
+
available_accounts = []
|
| 394 |
+
for i, acc in enumerate(self.accounts):
|
| 395 |
+
state = self.account_states.get(i, {})
|
| 396 |
+
if not state.get("available", True):
|
| 397 |
+
continue
|
| 398 |
+
if self._is_in_cooldown(i, now_ts):
|
| 399 |
+
continue
|
| 400 |
+
available_accounts.append((i, acc))
|
| 401 |
+
return available_accounts
|
| 402 |
+
|
| 403 |
+
def get_next_account(self):
|
| 404 |
+
"""轮训获取下一个可用账号"""
|
| 405 |
+
with self.lock:
|
| 406 |
+
available = self.get_available_accounts()
|
| 407 |
+
if not available:
|
| 408 |
+
cooldown_info = self.get_next_cooldown_info()
|
| 409 |
+
if cooldown_info:
|
| 410 |
+
remaining = int(max(0, cooldown_info["cooldown_until"] - time.time()))
|
| 411 |
+
raise NoAvailableAccount(f"没有可用的账号(最近冷却账号 {cooldown_info['index']}��约 {remaining} 秒后可重试)")
|
| 412 |
+
raise NoAvailableAccount("没有可用的账号")
|
| 413 |
+
|
| 414 |
+
# 轮训选择
|
| 415 |
+
self.current_index = self.current_index % len(available)
|
| 416 |
+
idx, account = available[self.current_index]
|
| 417 |
+
self.current_index = (self.current_index + 1) % len(available)
|
| 418 |
+
return idx, account
|
| 419 |
+
|
| 420 |
+
def get_account_count(self):
|
| 421 |
+
"""获取账号数量统计"""
|
| 422 |
+
total = len(self.accounts)
|
| 423 |
+
available = len(self.get_available_accounts())
|
| 424 |
+
return total, available
|
| 425 |
+
|
| 426 |
+
|
| 427 |
+
# 全局账号管理器
|
| 428 |
+
account_manager = AccountManager()
|
| 429 |
+
|
| 430 |
+
|
| 431 |
+
class FileManager:
|
| 432 |
+
"""文件管理器 - 管理上传文件的映射关系(OpenAI file_id <-> Gemini fileId)"""
|
| 433 |
+
|
| 434 |
+
def __init__(self):
|
| 435 |
+
self.files: Dict[str, Dict] = {} # openai_file_id -> {gemini_file_id, session_name, filename, mime_type, size, created_at}
|
| 436 |
+
|
| 437 |
+
def add_file(self, openai_file_id: str, gemini_file_id: str, session_name: str,
|
| 438 |
+
filename: str, mime_type: str, size: int) -> Dict:
|
| 439 |
+
"""添加文件映射"""
|
| 440 |
+
file_info = {
|
| 441 |
+
"id": openai_file_id,
|
| 442 |
+
"gemini_file_id": gemini_file_id,
|
| 443 |
+
"session_name": session_name,
|
| 444 |
+
"filename": filename,
|
| 445 |
+
"mime_type": mime_type,
|
| 446 |
+
"bytes": size,
|
| 447 |
+
"created_at": int(time.time()),
|
| 448 |
+
"purpose": "assistants",
|
| 449 |
+
"object": "file"
|
| 450 |
+
}
|
| 451 |
+
self.files[openai_file_id] = file_info
|
| 452 |
+
return file_info
|
| 453 |
+
|
| 454 |
+
def get_file(self, openai_file_id: str) -> Optional[Dict]:
|
| 455 |
+
"""获取文件信息"""
|
| 456 |
+
return self.files.get(openai_file_id)
|
| 457 |
+
|
| 458 |
+
def get_gemini_file_id(self, openai_file_id: str) -> Optional[str]:
|
| 459 |
+
"""获取 Gemini 文件ID"""
|
| 460 |
+
file_info = self.files.get(openai_file_id)
|
| 461 |
+
return file_info.get("gemini_file_id") if file_info else None
|
| 462 |
+
|
| 463 |
+
def delete_file(self, openai_file_id: str) -> bool:
|
| 464 |
+
"""删除文件映射"""
|
| 465 |
+
if openai_file_id in self.files:
|
| 466 |
+
del self.files[openai_file_id]
|
| 467 |
+
return True
|
| 468 |
+
return False
|
| 469 |
+
|
| 470 |
+
def list_files(self) -> List[Dict]:
|
| 471 |
+
"""列出所有文件"""
|
| 472 |
+
return list(self.files.values())
|
| 473 |
+
|
| 474 |
+
def get_session_for_file(self, openai_file_id: str) -> Optional[str]:
|
| 475 |
+
"""获取文件关联的会话名称"""
|
| 476 |
+
file_info = self.files.get(openai_file_id)
|
| 477 |
+
return file_info.get("session_name") if file_info else None
|
| 478 |
+
|
| 479 |
+
|
| 480 |
+
# 全局文件管理器
|
| 481 |
+
file_manager = FileManager()
|
| 482 |
+
|
| 483 |
+
|
| 484 |
+
def check_proxy(proxy: str) -> bool:
|
| 485 |
+
"""检测代理是否可用"""
|
| 486 |
+
if not proxy:
|
| 487 |
+
return False
|
| 488 |
+
try:
|
| 489 |
+
proxies = {"http": proxy, "https": proxy}
|
| 490 |
+
resp = requests.get("https://www.google.com", proxies=proxies,
|
| 491 |
+
verify=False, timeout=10)
|
| 492 |
+
return resp.status_code == 200
|
| 493 |
+
except:
|
| 494 |
+
return False
|
| 495 |
+
|
| 496 |
+
|
| 497 |
+
def get_proxy() -> Optional[str]:
|
| 498 |
+
"""获取代理配置,根据proxy_enabled开关决定是否返回代理地址
|
| 499 |
+
|
| 500 |
+
Returns:
|
| 501 |
+
代理地址字符串,如果禁用代理则返回None
|
| 502 |
+
"""
|
| 503 |
+
if account_manager.config is None:
|
| 504 |
+
return None
|
| 505 |
+
if not account_manager.config.get("proxy_enabled", False):
|
| 506 |
+
return None
|
| 507 |
+
return account_manager.config.get("proxy")
|
| 508 |
+
|
| 509 |
+
|
| 510 |
+
def url_safe_b64encode(data: bytes) -> str:
|
| 511 |
+
"""URL安全的Base64编码,不带padding"""
|
| 512 |
+
return base64.urlsafe_b64encode(data).decode('utf-8').rstrip('=')
|
| 513 |
+
|
| 514 |
+
|
| 515 |
+
def kq_encode(s: str) -> str:
|
| 516 |
+
"""模拟JS的kQ函数"""
|
| 517 |
+
byte_arr = bytearray()
|
| 518 |
+
for char in s:
|
| 519 |
+
val = ord(char)
|
| 520 |
+
if val > 255:
|
| 521 |
+
byte_arr.append(val & 255)
|
| 522 |
+
byte_arr.append(val >> 8)
|
| 523 |
+
else:
|
| 524 |
+
byte_arr.append(val)
|
| 525 |
+
return url_safe_b64encode(bytes(byte_arr))
|
| 526 |
+
|
| 527 |
+
|
| 528 |
+
def decode_xsrf_token(xsrf_token: str) -> bytes:
|
| 529 |
+
"""将 xsrfToken 解码为字节数组(用于HMAC签名)"""
|
| 530 |
+
padding = 4 - len(xsrf_token) % 4
|
| 531 |
+
if padding != 4:
|
| 532 |
+
xsrf_token += '=' * padding
|
| 533 |
+
return base64.urlsafe_b64decode(xsrf_token)
|
| 534 |
+
|
| 535 |
+
|
| 536 |
+
def create_jwt(key_bytes: bytes, key_id: str, csesidx: str) -> str:
|
| 537 |
+
"""创建JWT token"""
|
| 538 |
+
now = int(time.time())
|
| 539 |
+
|
| 540 |
+
header = {
|
| 541 |
+
"alg": "HS256",
|
| 542 |
+
"typ": "JWT",
|
| 543 |
+
"kid": key_id
|
| 544 |
+
}
|
| 545 |
+
|
| 546 |
+
payload = {
|
| 547 |
+
"iss": "https://business.gemini.google",
|
| 548 |
+
"aud": "https://biz-discoveryengine.googleapis.com",
|
| 549 |
+
"sub": f"csesidx/{csesidx}",
|
| 550 |
+
"iat": now,
|
| 551 |
+
"exp": now + 300,
|
| 552 |
+
"nbf": now
|
| 553 |
+
}
|
| 554 |
+
|
| 555 |
+
header_b64 = kq_encode(json.dumps(header, separators=(',', ':')))
|
| 556 |
+
payload_b64 = kq_encode(json.dumps(payload, separators=(',', ':')))
|
| 557 |
+
message = f"{header_b64}.{payload_b64}"
|
| 558 |
+
|
| 559 |
+
signature = hmac.new(key_bytes, message.encode('utf-8'), hashlib.sha256).digest()
|
| 560 |
+
signature_b64 = url_safe_b64encode(signature)
|
| 561 |
+
|
| 562 |
+
return f"{message}.{signature_b64}"
|
| 563 |
+
|
| 564 |
+
|
| 565 |
+
def get_jwt_for_account(account: dict, proxy: str) -> str:
|
| 566 |
+
"""为指定账号获取JWT"""
|
| 567 |
+
secure_c_ses = account.get("secure_c_ses")
|
| 568 |
+
host_c_oses = account.get("host_c_oses")
|
| 569 |
+
csesidx = account.get("csesidx")
|
| 570 |
+
|
| 571 |
+
if not secure_c_ses or not csesidx:
|
| 572 |
+
raise ValueError("缺少 secure_c_ses 或 csesidx")
|
| 573 |
+
|
| 574 |
+
url = f"{GETOXSRF_URL}?csesidx={csesidx}"
|
| 575 |
+
proxies = {"http": proxy, "https": proxy} if proxy else None
|
| 576 |
+
|
| 577 |
+
headers = {
|
| 578 |
+
"accept": "*/*",
|
| 579 |
+
"user-agent": account.get('user_agent', 'Mozilla/5.0'),
|
| 580 |
+
"cookie": f'__Secure-C_SES={secure_c_ses}; __Host-C_OSES={host_c_oses}',
|
| 581 |
+
}
|
| 582 |
+
|
| 583 |
+
try:
|
| 584 |
+
resp = requests.get(url, headers=headers, proxies=proxies, verify=False, timeout=30)
|
| 585 |
+
except requests.RequestException as e:
|
| 586 |
+
raise AccountRequestError(f"获取JWT 请求失败: {e}") from e
|
| 587 |
+
|
| 588 |
+
if resp.status_code != 200:
|
| 589 |
+
raise_for_account_response(resp, "获取JWT")
|
| 590 |
+
|
| 591 |
+
# 处理Google安全前缀
|
| 592 |
+
text = resp.text
|
| 593 |
+
if text.startswith(")]}'\n") or text.startswith(")]}'"):
|
| 594 |
+
text = text[4:].strip()
|
| 595 |
+
|
| 596 |
+
try:
|
| 597 |
+
data = json.loads(text)
|
| 598 |
+
except json.JSONDecodeError as e:
|
| 599 |
+
raise AccountAuthError(f"解析JWT响应失败: {e}") from e
|
| 600 |
+
|
| 601 |
+
key_id = data.get("keyId")
|
| 602 |
+
xsrf_token = data.get("xsrfToken")
|
| 603 |
+
if not key_id or not xsrf_token:
|
| 604 |
+
raise AccountAuthError(f"JWT 响应缺少 keyId/xsrfToken: {data}")
|
| 605 |
+
|
| 606 |
+
print(f"账号: {account.get('csesidx')} 账号可用! key_id: {key_id}")
|
| 607 |
+
|
| 608 |
+
key_bytes = decode_xsrf_token(xsrf_token)
|
| 609 |
+
|
| 610 |
+
return create_jwt(key_bytes, key_id, csesidx)
|
| 611 |
+
|
| 612 |
+
|
| 613 |
+
def get_headers(jwt: str) -> dict:
|
| 614 |
+
"""获取请求头"""
|
| 615 |
+
return {
|
| 616 |
+
"accept": "*/*",
|
| 617 |
+
"accept-encoding": "gzip, deflate, br, zstd",
|
| 618 |
+
"accept-language": "zh-CN,zh;q=0.9,en;q=0.8",
|
| 619 |
+
"authorization": f"Bearer {jwt}",
|
| 620 |
+
"content-type": "application/json",
|
| 621 |
+
"origin": "https://business.gemini.google",
|
| 622 |
+
"referer": "https://business.gemini.google/",
|
| 623 |
+
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36",
|
| 624 |
+
"x-server-timeout": "1800",
|
| 625 |
+
}
|
| 626 |
+
|
| 627 |
+
|
| 628 |
+
def raise_for_account_response(resp: requests.Response, action: str):
|
| 629 |
+
"""根据响应状态抛出对应的账号异常"""
|
| 630 |
+
status = resp.status_code
|
| 631 |
+
body_preview = resp.text[:500] if resp.text else ""
|
| 632 |
+
lower_body = body_preview.lower()
|
| 633 |
+
|
| 634 |
+
if status in (401, 403):
|
| 635 |
+
raise AccountAuthError(f"{action} 认证失败: {status} - {body_preview}", status)
|
| 636 |
+
if status == 429 or "quota" in lower_body or "exceed" in lower_body or "limit" in lower_body:
|
| 637 |
+
raise AccountRateLimitError(f"{action} 触发限额: {status} - {body_preview}", status)
|
| 638 |
+
|
| 639 |
+
raise AccountRequestError(f"{action} 请求失败: {status} - {body_preview}", status)
|
| 640 |
+
|
| 641 |
+
|
| 642 |
+
def ensure_jwt_for_account(account_idx: int, account: dict):
|
| 643 |
+
"""确保指定账号的JWT有效,必要时刷新"""
|
| 644 |
+
print(f"[DEBUG][ensure_jwt_for_account] 开始 - 账号索引: {account_idx}, CSESIDX: {account.get('csesidx')}")
|
| 645 |
+
start_time = time.time()
|
| 646 |
+
with account_manager.lock:
|
| 647 |
+
state = account_manager.account_states[account_idx]
|
| 648 |
+
jwt_age = time.time() - state["jwt_time"] if state["jwt"] else float('inf')
|
| 649 |
+
print(f"[DEBUG][ensure_jwt_for_account] JWT状态 - 存在: {state['jwt'] is not None}, 年龄: {jwt_age:.2f}秒")
|
| 650 |
+
if state["jwt"] is None or jwt_age > 240:
|
| 651 |
+
print(f"[DEBUG][ensure_jwt_for_account] 需要刷新JWT...")
|
| 652 |
+
proxy = get_proxy()
|
| 653 |
+
try:
|
| 654 |
+
refresh_start = time.time()
|
| 655 |
+
state["jwt"] = get_jwt_for_account(account, proxy)
|
| 656 |
+
state["jwt_time"] = time.time()
|
| 657 |
+
print(f"[DEBUG][ensure_jwt_for_account] JWT刷新成功 - 耗时: {time.time() - refresh_start:.2f}秒")
|
| 658 |
+
except Exception as e:
|
| 659 |
+
print(f"[DEBUG][ensure_jwt_for_account] JWT刷新失败: {e}")
|
| 660 |
+
raise
|
| 661 |
+
else:
|
| 662 |
+
print(f"[DEBUG][ensure_jwt_for_account] 使用缓存JWT")
|
| 663 |
+
print(f"[DEBUG][ensure_jwt_for_account] 完成 - 总耗时: {time.time() - start_time:.2f}秒")
|
| 664 |
+
return state["jwt"]
|
| 665 |
+
|
| 666 |
+
|
| 667 |
+
def create_chat_session(jwt: str, team_id: str, proxy: str) -> str:
|
| 668 |
+
"""创建会话,返回session ID"""
|
| 669 |
+
print(f"[DEBUG][create_chat_session] 开始 - team_id: {team_id}")
|
| 670 |
+
start_time = time.time()
|
| 671 |
+
session_id = uuid.uuid4().hex[:12]
|
| 672 |
+
print(f"[DEBUG][create_chat_session] 生成session_id: {session_id}")
|
| 673 |
+
body = {
|
| 674 |
+
"configId": team_id,
|
| 675 |
+
"additionalParams": {"token": "-"},
|
| 676 |
+
"createSessionRequest": {
|
| 677 |
+
"session": {"name": session_id, "displayName": session_id}
|
| 678 |
+
}
|
| 679 |
+
}
|
| 680 |
+
|
| 681 |
+
proxies = {"http": proxy, "https": proxy} if proxy else None
|
| 682 |
+
print(f"[DEBUG][create_chat_session] 发送请求到: {CREATE_SESSION_URL}")
|
| 683 |
+
print(f"[DEBUG][create_chat_session] 使用代理: {proxy}")
|
| 684 |
+
|
| 685 |
+
request_start = time.time()
|
| 686 |
+
try:
|
| 687 |
+
resp = requests.post(
|
| 688 |
+
CREATE_SESSION_URL,
|
| 689 |
+
headers=get_headers(jwt),
|
| 690 |
+
json=body,
|
| 691 |
+
proxies=proxies,
|
| 692 |
+
verify=False,
|
| 693 |
+
timeout=30
|
| 694 |
+
)
|
| 695 |
+
except requests.RequestException as e:
|
| 696 |
+
raise AccountRequestError(f"创建会话请求失败: {e}") from e
|
| 697 |
+
print(f"[DEBUG][create_chat_session] 请求完成 - 状态码: {resp.status_code}, 耗时: {time.time() - request_start:.2f}秒")
|
| 698 |
+
|
| 699 |
+
if resp.status_code != 200:
|
| 700 |
+
print(f"[DEBUG][create_chat_session] 请求失败 - 响应: {resp.text[:500]}")
|
| 701 |
+
if resp.status_code == 401:
|
| 702 |
+
print(f"[DEBUG][create_chat_session] 401错误 - 可能是team_id填错了")
|
| 703 |
+
raise_for_account_response(resp, "创建会话")
|
| 704 |
+
|
| 705 |
+
data = resp.json()
|
| 706 |
+
session_name = data.get("session", {}).get("name")
|
| 707 |
+
print(f"[DEBUG][create_chat_session] 完成 - session_name: {session_name}, 总耗时: {time.time() - start_time:.2f}秒")
|
| 708 |
+
return session_name
|
| 709 |
+
|
| 710 |
+
|
| 711 |
+
def ensure_session_for_account(account_idx: int, account: dict):
|
| 712 |
+
"""确保指定账号的会话有效"""
|
| 713 |
+
print(f"[DEBUG][ensure_session_for_account] 开始 - 账号索引: {account_idx}")
|
| 714 |
+
start_time = time.time()
|
| 715 |
+
|
| 716 |
+
jwt_start = time.time()
|
| 717 |
+
jwt = ensure_jwt_for_account(account_idx, account)
|
| 718 |
+
print(f"[DEBUG][ensure_session_for_account] JWT获取完成 - 耗时: {time.time() - jwt_start:.2f}秒")
|
| 719 |
+
|
| 720 |
+
with account_manager.lock:
|
| 721 |
+
state = account_manager.account_states[account_idx]
|
| 722 |
+
print(f"[DEBUG][ensure_session_for_account] 当前session状态: {state['session'] is not None}")
|
| 723 |
+
if state["session"] is None:
|
| 724 |
+
print(f"[DEBUG][ensure_session_for_account] 需要创建新session...")
|
| 725 |
+
proxy = get_proxy()
|
| 726 |
+
team_id = account.get("team_id")
|
| 727 |
+
session_start = time.time()
|
| 728 |
+
state["session"] = create_chat_session(jwt, team_id, proxy)
|
| 729 |
+
print(f"[DEBUG][ensure_session_for_account] Session创建完成 - 耗时: {time.time() - session_start:.2f}秒")
|
| 730 |
+
else:
|
| 731 |
+
print(f"[DEBUG][ensure_session_for_account] 使用缓存session: {state['session']}")
|
| 732 |
+
|
| 733 |
+
print(f"[DEBUG][ensure_session_for_account] 完成 - 总耗时: {time.time() - start_time:.2f}秒")
|
| 734 |
+
return state["session"], jwt, account.get("team_id")
|
| 735 |
+
|
| 736 |
+
|
| 737 |
+
# ==================== 文件上传功能 ====================
|
| 738 |
+
|
| 739 |
+
def upload_file_to_gemini(jwt: str, session_name: str, team_id: str,
|
| 740 |
+
file_content: bytes, filename: str, mime_type: str,
|
| 741 |
+
proxy: str = None) -> str:
|
| 742 |
+
"""
|
| 743 |
+
上传文件到 Gemini,返回 Gemini 的 fileId
|
| 744 |
+
|
| 745 |
+
Args:
|
| 746 |
+
jwt: JWT 认证令牌
|
| 747 |
+
session_name: 会话名称
|
| 748 |
+
team_id: 团队ID
|
| 749 |
+
file_content: 文件内容(字节)
|
| 750 |
+
filename: 文件名
|
| 751 |
+
mime_type: MIME 类型
|
| 752 |
+
proxy: 代理地址
|
| 753 |
+
|
| 754 |
+
Returns:
|
| 755 |
+
str: Gemini 返回的 fileId
|
| 756 |
+
"""
|
| 757 |
+
start_time = time.time()
|
| 758 |
+
print(f"[DEBUG][upload_file_to_gemini] 开始上传文件: {filename}, MIME类型: {mime_type}, 文件大小: {len(file_content)} bytes")
|
| 759 |
+
|
| 760 |
+
encode_start = time.time()
|
| 761 |
+
file_contents_b64 = base64.b64encode(file_content).decode('utf-8')
|
| 762 |
+
print(f"[DEBUG][upload_file_to_gemini] Base64编码完成 - 耗时: {time.time() - encode_start:.2f}秒, 编码后大小: {len(file_contents_b64)} chars")
|
| 763 |
+
|
| 764 |
+
body = {
|
| 765 |
+
"addContextFileRequest": {
|
| 766 |
+
"fileContents": file_contents_b64,
|
| 767 |
+
"fileName": filename,
|
| 768 |
+
"mimeType": mime_type,
|
| 769 |
+
"name": session_name
|
| 770 |
+
},
|
| 771 |
+
"additionalParams": {"token": "-"},
|
| 772 |
+
"configId": team_id
|
| 773 |
+
}
|
| 774 |
+
|
| 775 |
+
proxies = {"http": proxy, "https": proxy} if proxy else None
|
| 776 |
+
print(f"[DEBUG][upload_file_to_gemini] 准备发送请求到: {ADD_CONTEXT_FILE_URL}")
|
| 777 |
+
print(f"[DEBUG][upload_file_to_gemini] 使用代理: {proxy if proxy else '无'}")
|
| 778 |
+
|
| 779 |
+
request_start = time.time()
|
| 780 |
+
try:
|
| 781 |
+
resp = requests.post(
|
| 782 |
+
ADD_CONTEXT_FILE_URL,
|
| 783 |
+
headers=get_headers(jwt),
|
| 784 |
+
json=body,
|
| 785 |
+
proxies=proxies,
|
| 786 |
+
verify=False,
|
| 787 |
+
timeout=60
|
| 788 |
+
)
|
| 789 |
+
except requests.RequestException as e:
|
| 790 |
+
raise AccountRequestError(f"文件上传请求失败: {e}") from e
|
| 791 |
+
print(f"[DEBUG][upload_file_to_gemini] 请求完成 - 耗时: {time.time() - request_start:.2f}秒, 状态码: {resp.status_code}")
|
| 792 |
+
|
| 793 |
+
if resp.status_code != 200:
|
| 794 |
+
print(f"[DEBUG][upload_file_to_gemini] 上传失败 - 响应内容: {resp.text[:500]}")
|
| 795 |
+
raise_for_account_response(resp, "文件上传")
|
| 796 |
+
|
| 797 |
+
parse_start = time.time()
|
| 798 |
+
data = resp.json()
|
| 799 |
+
file_id = data.get("addContextFileResponse", {}).get("fileId")
|
| 800 |
+
print(f"[DEBUG][upload_file_to_gemini] 解析响应完成 - 耗时: {time.time() - parse_start:.2f}秒")
|
| 801 |
+
|
| 802 |
+
if not file_id:
|
| 803 |
+
print(f"[DEBUG][upload_file_to_gemini] 响应中未找到fileId - 响应数据: {data}")
|
| 804 |
+
raise ValueError(f"响应中未找到 fileId: {data}")
|
| 805 |
+
|
| 806 |
+
print(f"[DEBUG][upload_file_to_gemini] 上传成功 - fileId: {file_id}, 总耗时: {time.time() - start_time:.2f}秒")
|
| 807 |
+
return file_id
|
| 808 |
+
|
| 809 |
+
|
| 810 |
+
# ==================== 图片处理功能 ====================
|
| 811 |
+
|
| 812 |
+
@dataclass
|
| 813 |
+
class ChatImage:
|
| 814 |
+
"""表示生成的图片"""
|
| 815 |
+
url: Optional[str] = None
|
| 816 |
+
base64_data: Optional[str] = None
|
| 817 |
+
mime_type: str = "image/png"
|
| 818 |
+
local_path: Optional[str] = None
|
| 819 |
+
file_id: Optional[str] = None
|
| 820 |
+
file_name: Optional[str] = None
|
| 821 |
+
|
| 822 |
+
|
| 823 |
+
@dataclass
|
| 824 |
+
class ChatResponse:
|
| 825 |
+
"""聊天响应,包含文本和图片"""
|
| 826 |
+
text: str = ""
|
| 827 |
+
images: List[ChatImage] = field(default_factory=list)
|
| 828 |
+
thoughts: List[str] = field(default_factory=list)
|
| 829 |
+
|
| 830 |
+
|
| 831 |
+
def cleanup_expired_images():
|
| 832 |
+
"""清理过期的缓存图片"""
|
| 833 |
+
if not IMAGE_CACHE_DIR.exists():
|
| 834 |
+
return
|
| 835 |
+
|
| 836 |
+
now = time.time()
|
| 837 |
+
max_age_seconds = IMAGE_CACHE_HOURS * 3600
|
| 838 |
+
|
| 839 |
+
for filepath in IMAGE_CACHE_DIR.iterdir():
|
| 840 |
+
if filepath.is_file():
|
| 841 |
+
try:
|
| 842 |
+
file_age = now - filepath.stat().st_mtime
|
| 843 |
+
if file_age > max_age_seconds:
|
| 844 |
+
filepath.unlink()
|
| 845 |
+
print(f"[图片缓存] 已删除过期图片: {filepath.name}")
|
| 846 |
+
except Exception as e:
|
| 847 |
+
print(f"[图片缓存] 删除失败: {filepath.name}, 错误: {e}")
|
| 848 |
+
|
| 849 |
+
|
| 850 |
+
def save_image_to_cache(image_data: bytes, mime_type: str = "image/png", filename: Optional[str] = None) -> str:
|
| 851 |
+
"""保存图片到缓存目录,返回文件名"""
|
| 852 |
+
IMAGE_CACHE_DIR.mkdir(exist_ok=True)
|
| 853 |
+
|
| 854 |
+
# 确定文件扩展名
|
| 855 |
+
ext_map = {
|
| 856 |
+
"image/png": ".png",
|
| 857 |
+
"image/jpeg": ".jpg",
|
| 858 |
+
"image/gif": ".gif",
|
| 859 |
+
"image/webp": ".webp",
|
| 860 |
+
}
|
| 861 |
+
ext = ext_map.get(mime_type, ".png")
|
| 862 |
+
|
| 863 |
+
if filename:
|
| 864 |
+
# 确保有正确的扩展名
|
| 865 |
+
if not any(filename.endswith(e) for e in ext_map.values()):
|
| 866 |
+
filename = f"{filename}{ext}"
|
| 867 |
+
else:
|
| 868 |
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 869 |
+
filename = f"gemini_{timestamp}_{uuid.uuid4().hex[:8]}{ext}"
|
| 870 |
+
|
| 871 |
+
filepath = IMAGE_CACHE_DIR / filename
|
| 872 |
+
with open(filepath, "wb") as f:
|
| 873 |
+
f.write(image_data)
|
| 874 |
+
|
| 875 |
+
return filename
|
| 876 |
+
|
| 877 |
+
|
| 878 |
+
def parse_base64_data_url(data_url: str) -> Optional[Dict]:
|
| 879 |
+
"""解析 base64 data URL,返回 {type, mime_type, data} 或 None"""
|
| 880 |
+
if not data_url or not data_url.startswith("data:"):
|
| 881 |
+
return None
|
| 882 |
+
|
| 883 |
+
# base64格式: data:image/png;base64,xxxxx
|
| 884 |
+
match = re.match(r"data:([^;]+);base64,(.+)", data_url)
|
| 885 |
+
if match:
|
| 886 |
+
return {
|
| 887 |
+
"type": "base64",
|
| 888 |
+
"mime_type": match.group(1),
|
| 889 |
+
"data": match.group(2)
|
| 890 |
+
}
|
| 891 |
+
return None
|
| 892 |
+
|
| 893 |
+
|
| 894 |
+
def extract_images_from_files_array(files: List[Dict]) -> List[Dict]:
|
| 895 |
+
"""从 files 数组中提取图片(支持内联 base64 格式)
|
| 896 |
+
|
| 897 |
+
支持格式:
|
| 898 |
+
{
|
| 899 |
+
"data": "data:image/png;base64,xxxxx",
|
| 900 |
+
"type": "image",
|
| 901 |
+
"detail": "high" # 可选
|
| 902 |
+
}
|
| 903 |
+
|
| 904 |
+
返回: 图片列表 [{type: 'base64', mime_type: ..., data: ...}]
|
| 905 |
+
"""
|
| 906 |
+
images = []
|
| 907 |
+
for file_item in files:
|
| 908 |
+
if not isinstance(file_item, dict):
|
| 909 |
+
continue
|
| 910 |
+
|
| 911 |
+
file_type = file_item.get("type", "")
|
| 912 |
+
|
| 913 |
+
# 只处理图片类型
|
| 914 |
+
if file_type != "image":
|
| 915 |
+
continue
|
| 916 |
+
|
| 917 |
+
data = file_item.get("data", "")
|
| 918 |
+
if data:
|
| 919 |
+
parsed = parse_base64_data_url(data)
|
| 920 |
+
if parsed:
|
| 921 |
+
images.append(parsed)
|
| 922 |
+
|
| 923 |
+
return images
|
| 924 |
+
|
| 925 |
+
|
| 926 |
+
def extract_images_from_openai_content(content: Any) -> Tuple[str, List[Dict]]:
|
| 927 |
+
"""从OpenAI格式的content中提取文本和图片
|
| 928 |
+
|
| 929 |
+
返回: (文本内容, 图片列表[{type: 'base64'|'url', data: ...}])
|
| 930 |
+
"""
|
| 931 |
+
if isinstance(content, str):
|
| 932 |
+
return content, []
|
| 933 |
+
|
| 934 |
+
if not isinstance(content, list):
|
| 935 |
+
return str(content), []
|
| 936 |
+
|
| 937 |
+
text_parts = []
|
| 938 |
+
images = []
|
| 939 |
+
|
| 940 |
+
for item in content:
|
| 941 |
+
if not isinstance(item, dict):
|
| 942 |
+
continue
|
| 943 |
+
|
| 944 |
+
item_type = item.get("type", "")
|
| 945 |
+
|
| 946 |
+
if item_type == "text":
|
| 947 |
+
text_parts.append(item.get("text", ""))
|
| 948 |
+
|
| 949 |
+
elif item_type == "image_url":
|
| 950 |
+
image_url_obj = item.get("image_url", {})
|
| 951 |
+
if isinstance(image_url_obj, str):
|
| 952 |
+
url = image_url_obj
|
| 953 |
+
else:
|
| 954 |
+
url = image_url_obj.get("url", "")
|
| 955 |
+
|
| 956 |
+
parsed = parse_base64_data_url(url)
|
| 957 |
+
if parsed:
|
| 958 |
+
images.append(parsed)
|
| 959 |
+
elif url:
|
| 960 |
+
# 普通URL
|
| 961 |
+
images.append({
|
| 962 |
+
"type": "url",
|
| 963 |
+
"url": url
|
| 964 |
+
})
|
| 965 |
+
|
| 966 |
+
# 支持直接的 image 类型(带 data 字段)
|
| 967 |
+
elif item_type == "image" and item.get("data"):
|
| 968 |
+
parsed = parse_base64_data_url(item.get("data"))
|
| 969 |
+
if parsed:
|
| 970 |
+
images.append(parsed)
|
| 971 |
+
|
| 972 |
+
return "\n".join(text_parts), images
|
| 973 |
+
|
| 974 |
+
|
| 975 |
+
def download_image_from_url(url: str, proxy: Optional[str] = None) -> Tuple[bytes, str]:
|
| 976 |
+
"""从URL下载图片,返回(图片数据, mime_type)"""
|
| 977 |
+
proxies = {"http": proxy, "https": proxy} if proxy else None
|
| 978 |
+
resp = requests.get(url, proxies=proxies, verify=False, timeout=60)
|
| 979 |
+
resp.raise_for_status()
|
| 980 |
+
|
| 981 |
+
content_type = resp.headers.get("Content-Type", "image/png")
|
| 982 |
+
# 提取主mime类型
|
| 983 |
+
mime_type = content_type.split(";")[0].strip()
|
| 984 |
+
|
| 985 |
+
return resp.content, mime_type
|
| 986 |
+
|
| 987 |
+
|
| 988 |
+
def get_session_file_metadata(jwt: str, session_name: str, team_id: str, proxy: Optional[str] = None) -> Dict:
|
| 989 |
+
"""获取会话中的文件元数据(AI生成的图片)"""
|
| 990 |
+
body = {
|
| 991 |
+
"configId": team_id,
|
| 992 |
+
"additionalParams": {"token": "-"},
|
| 993 |
+
"listSessionFileMetadataRequest": {
|
| 994 |
+
"name": session_name,
|
| 995 |
+
"filter": "file_origin_type = AI_GENERATED"
|
| 996 |
+
}
|
| 997 |
+
}
|
| 998 |
+
|
| 999 |
+
proxies = {"http": proxy, "https": proxy} if proxy else None
|
| 1000 |
+
resp = requests.post(
|
| 1001 |
+
LIST_FILE_METADATA_URL,
|
| 1002 |
+
headers=get_headers(jwt),
|
| 1003 |
+
json=body,
|
| 1004 |
+
proxies=proxies,
|
| 1005 |
+
verify=False,
|
| 1006 |
+
timeout=30
|
| 1007 |
+
)
|
| 1008 |
+
|
| 1009 |
+
if resp.status_code != 200:
|
| 1010 |
+
print(f"[图片] 获取文件元数据失败: {resp.status_code}")
|
| 1011 |
+
return {}
|
| 1012 |
+
|
| 1013 |
+
data = resp.json()
|
| 1014 |
+
# 返回 fileId -> metadata 的映射
|
| 1015 |
+
result = {}
|
| 1016 |
+
file_metadata_list = data.get("listSessionFileMetadataResponse", {}).get("fileMetadata", [])
|
| 1017 |
+
for meta in file_metadata_list:
|
| 1018 |
+
file_id = meta.get("fileId")
|
| 1019 |
+
if file_id:
|
| 1020 |
+
result[file_id] = meta
|
| 1021 |
+
return result
|
| 1022 |
+
|
| 1023 |
+
|
| 1024 |
+
def build_download_url(session_name: str, file_id: str) -> str:
|
| 1025 |
+
"""构造正确的下载URL"""
|
| 1026 |
+
return f"https://biz-discoveryengine.googleapis.com/v1alpha/{session_name}:downloadFile?fileId={file_id}&alt=media"
|
| 1027 |
+
|
| 1028 |
+
|
| 1029 |
+
def download_file_with_jwt(jwt: str, session_name: str, file_id: str, proxy: Optional[str] = None) -> bytes:
|
| 1030 |
+
"""使用JWT认证下载文件"""
|
| 1031 |
+
url = build_download_url(session_name, file_id)
|
| 1032 |
+
proxies = {"http": proxy, "https": proxy} if proxy else None
|
| 1033 |
+
|
| 1034 |
+
resp = requests.get(
|
| 1035 |
+
url,
|
| 1036 |
+
headers=get_headers(jwt),
|
| 1037 |
+
proxies=proxies,
|
| 1038 |
+
verify=False,
|
| 1039 |
+
timeout=120,
|
| 1040 |
+
allow_redirects=True
|
| 1041 |
+
)
|
| 1042 |
+
|
| 1043 |
+
resp.raise_for_status()
|
| 1044 |
+
content = resp.content
|
| 1045 |
+
|
| 1046 |
+
# 检测是否为base64编码的内容
|
| 1047 |
+
try:
|
| 1048 |
+
text_content = content.decode("utf-8", errors="ignore").strip()
|
| 1049 |
+
if text_content.startswith("iVBORw0KGgo") or text_content.startswith("/9j/"):
|
| 1050 |
+
# 是base64编码,需要解码
|
| 1051 |
+
return base64.b64decode(text_content)
|
| 1052 |
+
except Exception:
|
| 1053 |
+
pass
|
| 1054 |
+
|
| 1055 |
+
return content
|
| 1056 |
+
|
| 1057 |
+
|
| 1058 |
+
def upload_inline_image_to_gemini(jwt: str, session_name: str, team_id: str,
|
| 1059 |
+
image_data: Dict, proxy: str = None) -> Optional[str]:
|
| 1060 |
+
"""上传内联图片到 Gemini,返回 fileId"""
|
| 1061 |
+
try:
|
| 1062 |
+
ext_map = {"image/png": ".png", "image/jpeg": ".jpg", "image/gif": ".gif", "image/webp": ".webp"}
|
| 1063 |
+
|
| 1064 |
+
if image_data.get("type") == "base64":
|
| 1065 |
+
mime_type = image_data.get("mime_type", "image/png")
|
| 1066 |
+
file_content = base64.b64decode(image_data.get("data", ""))
|
| 1067 |
+
ext = ext_map.get(mime_type, ".png")
|
| 1068 |
+
filename = f"inline_{uuid.uuid4().hex[:8]}{ext}"
|
| 1069 |
+
elif image_data.get("type") == "url":
|
| 1070 |
+
file_content, mime_type = download_image_from_url(image_data.get("url"), proxy)
|
| 1071 |
+
ext = ext_map.get(mime_type, ".png")
|
| 1072 |
+
filename = f"url_{uuid.uuid4().hex[:8]}{ext}"
|
| 1073 |
+
else:
|
| 1074 |
+
return None
|
| 1075 |
+
|
| 1076 |
+
return upload_file_to_gemini(jwt, session_name, team_id, file_content, filename, mime_type, proxy)
|
| 1077 |
+
except AccountError:
|
| 1078 |
+
# 让账号相关错误向上抛出,以便触发冷却
|
| 1079 |
+
raise
|
| 1080 |
+
except Exception:
|
| 1081 |
+
return None
|
| 1082 |
+
|
| 1083 |
+
|
| 1084 |
+
def stream_chat_with_images(jwt: str, sess_name: str, message: str,
|
| 1085 |
+
proxy: str, team_id: str, file_ids: List[str] = None) -> ChatResponse:
|
| 1086 |
+
"""发送消息并流式接收响应"""
|
| 1087 |
+
query_parts = [{"text": message}]
|
| 1088 |
+
request_file_ids = file_ids if file_ids else []
|
| 1089 |
+
|
| 1090 |
+
body = {
|
| 1091 |
+
"configId": team_id,
|
| 1092 |
+
"additionalParams": {"token": "-"},
|
| 1093 |
+
"streamAssistRequest": {
|
| 1094 |
+
"session": sess_name,
|
| 1095 |
+
"query": {"parts": query_parts},
|
| 1096 |
+
"filter": "",
|
| 1097 |
+
"fileIds": request_file_ids,
|
| 1098 |
+
"answerGenerationMode": "NORMAL",
|
| 1099 |
+
"assistGenerationConfig":{
|
| 1100 |
+
"modelId":"gemini-3-pro-preview"
|
| 1101 |
+
},
|
| 1102 |
+
"toolsSpec": {
|
| 1103 |
+
"webGroundingSpec": {},
|
| 1104 |
+
"toolRegistry": "default_tool_registry",
|
| 1105 |
+
"imageGenerationSpec": {},
|
| 1106 |
+
"videoGenerationSpec": {}
|
| 1107 |
+
},
|
| 1108 |
+
"languageCode": "zh-CN",
|
| 1109 |
+
"userMetadata": {"timeZone": "Etc/GMT-8"},
|
| 1110 |
+
"assistSkippingMode": "REQUEST_ASSIST"
|
| 1111 |
+
}
|
| 1112 |
+
}
|
| 1113 |
+
|
| 1114 |
+
proxies = {"http": proxy, "https": proxy} if proxy else None
|
| 1115 |
+
try:
|
| 1116 |
+
resp = requests.post(
|
| 1117 |
+
STREAM_ASSIST_URL,
|
| 1118 |
+
headers=get_headers(jwt),
|
| 1119 |
+
json=body,
|
| 1120 |
+
proxies=proxies,
|
| 1121 |
+
verify=False,
|
| 1122 |
+
timeout=120,
|
| 1123 |
+
stream=True
|
| 1124 |
+
)
|
| 1125 |
+
except requests.RequestException as e:
|
| 1126 |
+
raise AccountRequestError(f"聊天请求失败: {e}") from e
|
| 1127 |
+
|
| 1128 |
+
if resp.status_code != 200:
|
| 1129 |
+
raise_for_account_response(resp, "聊天请求")
|
| 1130 |
+
|
| 1131 |
+
# 收集完整响应
|
| 1132 |
+
full_response = ""
|
| 1133 |
+
for line in resp.iter_lines():
|
| 1134 |
+
if line:
|
| 1135 |
+
full_response += line.decode('utf-8') + "\n"
|
| 1136 |
+
|
| 1137 |
+
# 解析响应
|
| 1138 |
+
result = ChatResponse()
|
| 1139 |
+
texts = []
|
| 1140 |
+
file_ids = [] # 收集需要下载的文件 {fileId, mimeType}
|
| 1141 |
+
current_session = None
|
| 1142 |
+
|
| 1143 |
+
try:
|
| 1144 |
+
data_list = json.loads(full_response)
|
| 1145 |
+
for data in data_list:
|
| 1146 |
+
sar = data.get("streamAssistResponse")
|
| 1147 |
+
if not sar:
|
| 1148 |
+
continue
|
| 1149 |
+
|
| 1150 |
+
# 获取session信息
|
| 1151 |
+
session_info = sar.get("sessionInfo", {})
|
| 1152 |
+
if session_info.get("session"):
|
| 1153 |
+
current_session = session_info["session"]
|
| 1154 |
+
|
| 1155 |
+
# 检查顶层的generatedImages
|
| 1156 |
+
for gen_img in sar.get("generatedImages", []):
|
| 1157 |
+
parse_generated_image(gen_img, result, proxy)
|
| 1158 |
+
|
| 1159 |
+
answer = sar.get("answer") or {}
|
| 1160 |
+
|
| 1161 |
+
# 检查answer级别的generatedImages
|
| 1162 |
+
for gen_img in answer.get("generatedImages", []):
|
| 1163 |
+
parse_generated_image(gen_img, result, proxy)
|
| 1164 |
+
|
| 1165 |
+
for reply in answer.get("replies", []):
|
| 1166 |
+
# 检查reply级别的generatedImages
|
| 1167 |
+
for gen_img in reply.get("generatedImages", []):
|
| 1168 |
+
parse_generated_image(gen_img, result, proxy)
|
| 1169 |
+
|
| 1170 |
+
gc = reply.get("groundedContent", {})
|
| 1171 |
+
content = gc.get("content", {})
|
| 1172 |
+
text = content.get("text", "")
|
| 1173 |
+
thought = content.get("thought", False)
|
| 1174 |
+
|
| 1175 |
+
# 检查file字段(图片生成的关键)
|
| 1176 |
+
file_info = content.get("file")
|
| 1177 |
+
if file_info and file_info.get("fileId"):
|
| 1178 |
+
file_ids.append({
|
| 1179 |
+
"fileId": file_info["fileId"],
|
| 1180 |
+
"mimeType": file_info.get("mimeType", "image/png"),
|
| 1181 |
+
"fileName": file_info.get("name")
|
| 1182 |
+
})
|
| 1183 |
+
|
| 1184 |
+
# 解析图片数据
|
| 1185 |
+
parse_image_from_content(content, result, proxy)
|
| 1186 |
+
parse_image_from_content(gc, result, proxy)
|
| 1187 |
+
|
| 1188 |
+
# 检查attachments
|
| 1189 |
+
for att in reply.get("attachments", []) + gc.get("attachments", []) + content.get("attachments", []):
|
| 1190 |
+
parse_attachment(att, result, proxy)
|
| 1191 |
+
|
| 1192 |
+
if text and not thought:
|
| 1193 |
+
texts.append(text)
|
| 1194 |
+
|
| 1195 |
+
# 处理通过fileId引用的图片
|
| 1196 |
+
if file_ids and current_session:
|
| 1197 |
+
try:
|
| 1198 |
+
file_metadata = get_session_file_metadata(jwt, current_session, team_id, proxy)
|
| 1199 |
+
for finfo in file_ids:
|
| 1200 |
+
fid = finfo["fileId"]
|
| 1201 |
+
mime = finfo["mimeType"]
|
| 1202 |
+
fname = finfo.get("fileName")
|
| 1203 |
+
meta = file_metadata.get(fid)
|
| 1204 |
+
|
| 1205 |
+
if meta:
|
| 1206 |
+
fname = fname or meta.get("name")
|
| 1207 |
+
session_path = meta.get("session") or current_session
|
| 1208 |
+
else:
|
| 1209 |
+
session_path = current_session
|
| 1210 |
+
|
| 1211 |
+
try:
|
| 1212 |
+
image_data = download_file_with_jwt(jwt, session_path, fid, proxy)
|
| 1213 |
+
filename = None
|
| 1214 |
+
local_path = None
|
| 1215 |
+
b64_data = base64.b64encode(image_data).decode("utf-8")
|
| 1216 |
+
|
| 1217 |
+
# 仅在 URL 模式下缓存到本地以便通过 /image/ 访问
|
| 1218 |
+
if not is_base64_output_mode():
|
| 1219 |
+
filename = save_image_to_cache(image_data, mime, fname)
|
| 1220 |
+
local_path = str(IMAGE_CACHE_DIR / filename)
|
| 1221 |
+
|
| 1222 |
+
img = ChatImage(
|
| 1223 |
+
file_id=fid,
|
| 1224 |
+
file_name=filename,
|
| 1225 |
+
mime_type=mime,
|
| 1226 |
+
local_path=local_path,
|
| 1227 |
+
base64_data=b64_data,
|
| 1228 |
+
)
|
| 1229 |
+
result.images.append(img)
|
| 1230 |
+
if filename:
|
| 1231 |
+
print(f"[图片] 已保存: {filename}")
|
| 1232 |
+
except Exception as e:
|
| 1233 |
+
print(f"[图片] 下载失败 (fileId={fid}): {e}")
|
| 1234 |
+
except Exception as e:
|
| 1235 |
+
print(f"[图片] 获取文件元数据失败: {e}")
|
| 1236 |
+
|
| 1237 |
+
except json.JSONDecodeError:
|
| 1238 |
+
pass
|
| 1239 |
+
|
| 1240 |
+
result.text = "".join(texts)
|
| 1241 |
+
return result
|
| 1242 |
+
|
| 1243 |
+
|
| 1244 |
+
def parse_generated_image(gen_img: Dict, result: ChatResponse, proxy: Optional[str] = None):
|
| 1245 |
+
"""解析generatedImages中的图片"""
|
| 1246 |
+
image_data = gen_img.get("image")
|
| 1247 |
+
if not image_data:
|
| 1248 |
+
return
|
| 1249 |
+
|
| 1250 |
+
# 检查base64数据
|
| 1251 |
+
b64_data = image_data.get("bytesBase64Encoded")
|
| 1252 |
+
if b64_data:
|
| 1253 |
+
try:
|
| 1254 |
+
mime_type = image_data.get("mimeType", "image/png")
|
| 1255 |
+
filename = None
|
| 1256 |
+
local_path = None
|
| 1257 |
+
|
| 1258 |
+
# 仅在 URL 模式下落盘缓存
|
| 1259 |
+
if not is_base64_output_mode():
|
| 1260 |
+
decoded = base64.b64decode(b64_data)
|
| 1261 |
+
filename = save_image_to_cache(decoded, mime_type)
|
| 1262 |
+
local_path = str(IMAGE_CACHE_DIR / filename)
|
| 1263 |
+
|
| 1264 |
+
img = ChatImage(
|
| 1265 |
+
base64_data=b64_data,
|
| 1266 |
+
mime_type=mime_type,
|
| 1267 |
+
file_name=filename,
|
| 1268 |
+
local_path=local_path,
|
| 1269 |
+
)
|
| 1270 |
+
result.images.append(img)
|
| 1271 |
+
if filename:
|
| 1272 |
+
print(f"[图片] 已保存: {filename}")
|
| 1273 |
+
except Exception as e:
|
| 1274 |
+
print(f"[图片] 解析base64失败: {e}")
|
| 1275 |
+
|
| 1276 |
+
|
| 1277 |
+
def parse_image_from_content(content: Dict, result: ChatResponse, proxy: Optional[str] = None):
|
| 1278 |
+
"""从content中解析图片"""
|
| 1279 |
+
# 检查inlineData
|
| 1280 |
+
inline_data = content.get("inlineData")
|
| 1281 |
+
if inline_data:
|
| 1282 |
+
b64_data = inline_data.get("data")
|
| 1283 |
+
if b64_data:
|
| 1284 |
+
try:
|
| 1285 |
+
mime_type = inline_data.get("mimeType", "image/png")
|
| 1286 |
+
filename = None
|
| 1287 |
+
local_path = None
|
| 1288 |
+
|
| 1289 |
+
if not is_base64_output_mode():
|
| 1290 |
+
decoded = base64.b64decode(b64_data)
|
| 1291 |
+
filename = save_image_to_cache(decoded, mime_type)
|
| 1292 |
+
local_path = str(IMAGE_CACHE_DIR / filename)
|
| 1293 |
+
|
| 1294 |
+
img = ChatImage(
|
| 1295 |
+
base64_data=b64_data,
|
| 1296 |
+
mime_type=mime_type,
|
| 1297 |
+
file_name=filename,
|
| 1298 |
+
local_path=local_path,
|
| 1299 |
+
)
|
| 1300 |
+
result.images.append(img)
|
| 1301 |
+
if filename:
|
| 1302 |
+
print(f"[图片] 已保存: {filename}")
|
| 1303 |
+
except Exception as e:
|
| 1304 |
+
print(f"[图片] 解析inlineData失败: {e}")
|
| 1305 |
+
|
| 1306 |
+
|
| 1307 |
+
def parse_attachment(att: Dict, result: ChatResponse, proxy: Optional[str] = None):
|
| 1308 |
+
"""解析attachment中的图片"""
|
| 1309 |
+
# 检查是否是图片类型
|
| 1310 |
+
mime_type = att.get("mimeType", "")
|
| 1311 |
+
if not mime_type.startswith("image/"):
|
| 1312 |
+
return
|
| 1313 |
+
|
| 1314 |
+
# 检查base64数据
|
| 1315 |
+
b64_data = att.get("data") or att.get("bytesBase64Encoded")
|
| 1316 |
+
if b64_data:
|
| 1317 |
+
try:
|
| 1318 |
+
filename = None
|
| 1319 |
+
local_path = None
|
| 1320 |
+
|
| 1321 |
+
if not is_base64_output_mode():
|
| 1322 |
+
decoded = base64.b64decode(b64_data)
|
| 1323 |
+
filename = att.get("name") or None
|
| 1324 |
+
filename = save_image_to_cache(decoded, mime_type, filename)
|
| 1325 |
+
local_path = str(IMAGE_CACHE_DIR / filename)
|
| 1326 |
+
|
| 1327 |
+
img = ChatImage(
|
| 1328 |
+
base64_data=b64_data,
|
| 1329 |
+
mime_type=mime_type,
|
| 1330 |
+
file_name=filename,
|
| 1331 |
+
local_path=local_path,
|
| 1332 |
+
)
|
| 1333 |
+
result.images.append(img)
|
| 1334 |
+
if filename:
|
| 1335 |
+
print(f"[图片] 已保存: {filename}")
|
| 1336 |
+
except Exception as e:
|
| 1337 |
+
print(f"[图片] 解析attachment失败: {e}")
|
| 1338 |
+
|
| 1339 |
+
|
| 1340 |
+
# ==================== OpenAPI 接口 ====================
|
| 1341 |
+
|
| 1342 |
+
@app.route('/v1/models', methods=['GET'])
|
| 1343 |
+
@require_api_auth
|
| 1344 |
+
def list_models():
|
| 1345 |
+
"""获取模型列表"""
|
| 1346 |
+
models_config = account_manager.config.get("models", [])
|
| 1347 |
+
models_data = []
|
| 1348 |
+
|
| 1349 |
+
for model in models_config:
|
| 1350 |
+
models_data.append({
|
| 1351 |
+
"id": model.get("id", "gemini-enterprise"),
|
| 1352 |
+
"object": "model",
|
| 1353 |
+
"created": int(time.time()),
|
| 1354 |
+
"owned_by": "google",
|
| 1355 |
+
"permission": [],
|
| 1356 |
+
"root": model.get("id", "gemini-enterprise"),
|
| 1357 |
+
"parent": None
|
| 1358 |
+
})
|
| 1359 |
+
|
| 1360 |
+
# 如果没有配置模型,返回默认模型
|
| 1361 |
+
if not models_data:
|
| 1362 |
+
models_data.append({
|
| 1363 |
+
"id": "gemini-enterprise",
|
| 1364 |
+
"object": "model",
|
| 1365 |
+
"created": int(time.time()),
|
| 1366 |
+
"owned_by": "google",
|
| 1367 |
+
"permission": [],
|
| 1368 |
+
"root": "gemini-enterprise",
|
| 1369 |
+
"parent": None
|
| 1370 |
+
})
|
| 1371 |
+
|
| 1372 |
+
return jsonify({"object": "list", "data": models_data})
|
| 1373 |
+
|
| 1374 |
+
|
| 1375 |
+
@app.route('/v1/files', methods=['POST'])
|
| 1376 |
+
@require_api_auth
|
| 1377 |
+
def upload_file():
|
| 1378 |
+
"""OpenAI 兼容的文件上传接口"""
|
| 1379 |
+
import traceback
|
| 1380 |
+
request_start_time = time.time()
|
| 1381 |
+
print(f"\n{'='*60}")
|
| 1382 |
+
print(f"[文件上传] ===== 接口调用开始 =====")
|
| 1383 |
+
print(f"[文件上传] 请求时间: {time.strftime('%Y-%m-%d %H:%M:%S')}")
|
| 1384 |
+
|
| 1385 |
+
try:
|
| 1386 |
+
# 检查是否有文件
|
| 1387 |
+
step_start = time.time()
|
| 1388 |
+
print(f"[文件上传] 步骤1: 检查请求中的文件...")
|
| 1389 |
+
if 'file' not in request.files:
|
| 1390 |
+
print(f"[文件上传] 错误: 请求中没有文件")
|
| 1391 |
+
return jsonify({"error": {"message": "No file provided", "type": "invalid_request_error"}}), 400
|
| 1392 |
+
|
| 1393 |
+
file = request.files['file']
|
| 1394 |
+
if file.filename == '':
|
| 1395 |
+
print(f"[文件上传] 错误: 文件名为空")
|
| 1396 |
+
return jsonify({"error": {"message": "No file selected", "type": "invalid_request_error"}}), 400
|
| 1397 |
+
print(f"[文件上传] 步骤1完成: 文件名={file.filename}, 耗时={time.time()-step_start:.3f}秒")
|
| 1398 |
+
|
| 1399 |
+
# 获取文件内容和MIME类型
|
| 1400 |
+
step_start = time.time()
|
| 1401 |
+
print(f"[文件上传] 步骤2: 读取文件内容...")
|
| 1402 |
+
file_content = file.read()
|
| 1403 |
+
mime_type = file.content_type or mimetypes.guess_type(file.filename)[0] or 'application/octet-stream'
|
| 1404 |
+
print(f"[文件上传] 步骤2完成: 文件大小={len(file_content)}字节, MIME类型={mime_type}, 耗时={time.time()-step_start:.3f}秒")
|
| 1405 |
+
|
| 1406 |
+
# 获取账号信息
|
| 1407 |
+
available_accounts = account_manager.get_available_accounts()
|
| 1408 |
+
if not available_accounts:
|
| 1409 |
+
next_cd = account_manager.get_next_cooldown_info()
|
| 1410 |
+
wait_msg = ""
|
| 1411 |
+
if next_cd:
|
| 1412 |
+
wait_msg = f"(最近冷却账号 {next_cd['index']},约 {int(next_cd['cooldown_until']-time.time())} 秒后可重试)"
|
| 1413 |
+
return jsonify({"error": {"message": f"没有可用的账号{wait_msg}", "type": "rate_limit"}}), 429
|
| 1414 |
+
|
| 1415 |
+
max_retries = len(available_accounts)
|
| 1416 |
+
last_error = None
|
| 1417 |
+
gemini_file_id = None
|
| 1418 |
+
print(f"[文件上传] 步骤3: 开始尝试上传, 最大重试次数={max_retries}")
|
| 1419 |
+
|
| 1420 |
+
for retry_idx in range(max_retries):
|
| 1421 |
+
retry_start = time.time()
|
| 1422 |
+
print(f"\n[文件上传] --- 第{retry_idx+1}次尝试 ---")
|
| 1423 |
+
account_idx = None
|
| 1424 |
+
try:
|
| 1425 |
+
# 获取账号
|
| 1426 |
+
step_start = time.time()
|
| 1427 |
+
print(f"[文件上传] 步骤3.{retry_idx+1}.1: 获取下一个可用账号...")
|
| 1428 |
+
account_idx, account = account_manager.get_next_account()
|
| 1429 |
+
print(f"[文件上传] 步骤3.{retry_idx+1}.1完成: 账号索引={account_idx}, CSESIDX={account.get('csesidx')}, 耗时={time.time()-step_start:.3f}秒")
|
| 1430 |
+
|
| 1431 |
+
# 确保会话有效
|
| 1432 |
+
step_start = time.time()
|
| 1433 |
+
print(f"[文件上传] 步骤3.{retry_idx+1}.2: 确保会话有效(JWT+Session)...")
|
| 1434 |
+
session, jwt, team_id = ensure_session_for_account(account_idx, account)
|
| 1435 |
+
print(f"[文件上传] 步骤3.{retry_idx+1}.2完成: session={session}, team_id={team_id}, 耗时={time.time()-step_start:.3f}秒")
|
| 1436 |
+
|
| 1437 |
+
proxy = get_proxy()
|
| 1438 |
+
print(f"[文件上传] 代理设置: {proxy}")
|
| 1439 |
+
|
| 1440 |
+
# 上传文件到 Gemini
|
| 1441 |
+
step_start = time.time()
|
| 1442 |
+
print(f"[文件上传] 步骤3.{retry_idx+1}.3: 上传文件到Gemini...")
|
| 1443 |
+
gemini_file_id = upload_file_to_gemini(jwt, session, team_id, file_content, file.filename, mime_type, proxy)
|
| 1444 |
+
print(f"[文件上传] 步骤3.{retry_idx+1}.3完成: gemini_file_id={gemini_file_id}, 耗时={time.time()-step_start:.3f}秒")
|
| 1445 |
+
|
| 1446 |
+
if gemini_file_id:
|
| 1447 |
+
# 生成 OpenAI 格式的 file_id
|
| 1448 |
+
step_start = time.time()
|
| 1449 |
+
print(f"[文件上传] 步骤4: 生成OpenAI格式响应...")
|
| 1450 |
+
openai_file_id = f"file-{uuid.uuid4().hex[:24]}"
|
| 1451 |
+
|
| 1452 |
+
# 保存映射关系
|
| 1453 |
+
file_manager.add_file(
|
| 1454 |
+
openai_file_id=openai_file_id,
|
| 1455 |
+
gemini_file_id=gemini_file_id,
|
| 1456 |
+
session_name=session,
|
| 1457 |
+
filename=file.filename,
|
| 1458 |
+
mime_type=mime_type,
|
| 1459 |
+
size=len(file_content)
|
| 1460 |
+
)
|
| 1461 |
+
print(f"[文件上传] 步骤4完成: openai_file_id={openai_file_id}, 耗时={time.time()-step_start:.3f}秒")
|
| 1462 |
+
|
| 1463 |
+
total_time = time.time() - request_start_time
|
| 1464 |
+
print(f"\n[文件上传] ===== 上传成功 =====")
|
| 1465 |
+
print(f"[文件上传] 总耗时: {total_time:.3f}秒")
|
| 1466 |
+
print(f"{'='*60}\n")
|
| 1467 |
+
|
| 1468 |
+
# 返回 OpenAI 格式响应
|
| 1469 |
+
return jsonify({
|
| 1470 |
+
"id": openai_file_id,
|
| 1471 |
+
"object": "file",
|
| 1472 |
+
"bytes": len(file_content),
|
| 1473 |
+
"created_at": int(time.time()),
|
| 1474 |
+
"filename": file.filename,
|
| 1475 |
+
"purpose": request.form.get('purpose', 'assistants')
|
| 1476 |
+
})
|
| 1477 |
+
else:
|
| 1478 |
+
print(f"[文件上传] 警告: gemini_file_id为空")
|
| 1479 |
+
|
| 1480 |
+
except AccountRateLimitError as e:
|
| 1481 |
+
last_error = e
|
| 1482 |
+
if account_idx is not None:
|
| 1483 |
+
pt_wait = seconds_until_next_pt_midnight()
|
| 1484 |
+
cooldown_seconds = max(account_manager.rate_limit_cooldown, pt_wait)
|
| 1485 |
+
account_manager.mark_account_cooldown(account_idx, str(e), cooldown_seconds)
|
| 1486 |
+
print(f"[文件上传] 第{retry_idx+1}次尝试失败(限额): {e}")
|
| 1487 |
+
print(f"[文件上传] 本次尝试耗时: {time.time()-retry_start:.3f}秒")
|
| 1488 |
+
continue
|
| 1489 |
+
except AccountAuthError as e:
|
| 1490 |
+
last_error = e
|
| 1491 |
+
if account_idx is not None:
|
| 1492 |
+
account_manager.mark_account_cooldown(account_idx, str(e), account_manager.auth_error_cooldown)
|
| 1493 |
+
print(f"[文件上传] 第{retry_idx+1}次尝试失败(凭证): {e}")
|
| 1494 |
+
print(f"[文件上传] 本次尝试耗时: {time.time()-retry_start:.3f}秒")
|
| 1495 |
+
continue
|
| 1496 |
+
except AccountRequestError as e:
|
| 1497 |
+
last_error = e
|
| 1498 |
+
if account_idx is not None:
|
| 1499 |
+
account_manager.mark_account_cooldown(account_idx, str(e), account_manager.generic_error_cooldown)
|
| 1500 |
+
print(f"[文件上传] 第{retry_idx+1}次尝试失败(请求异常): {e}")
|
| 1501 |
+
print(f"[文件上传] 本次尝试耗时: {time.time()-retry_start:.3f}秒")
|
| 1502 |
+
continue
|
| 1503 |
+
except NoAvailableAccount as e:
|
| 1504 |
+
last_error = e
|
| 1505 |
+
print(f"[文件上传] 无可用账号: {e}")
|
| 1506 |
+
break
|
| 1507 |
+
except Exception as e:
|
| 1508 |
+
last_error = e
|
| 1509 |
+
print(f"[文件上传] 第{retry_idx+1}次尝试失败: {type(e).__name__}: {e}")
|
| 1510 |
+
print(f"[文件上传] 堆栈跟踪:\n{traceback.format_exc()}")
|
| 1511 |
+
print(f"[文件上传] 本次尝试耗时: {time.time()-retry_start:.3f}秒")
|
| 1512 |
+
if account_idx is None:
|
| 1513 |
+
break
|
| 1514 |
+
continue
|
| 1515 |
+
|
| 1516 |
+
total_time = time.time() - request_start_time
|
| 1517 |
+
print(f"\n[文件上传] ===== 所有重试均失败 =====")
|
| 1518 |
+
error_message = last_error or "没有可用的账号"
|
| 1519 |
+
print(f"[文件上传] 最后错误: {error_message}")
|
| 1520 |
+
print(f"[文件上传] 总耗时: {total_time:.3f}秒")
|
| 1521 |
+
print(f"{'='*60}\n")
|
| 1522 |
+
status_code = 429 if isinstance(last_error, (AccountRateLimitError, NoAvailableAccount)) else 500
|
| 1523 |
+
err_type = "rate_limit" if status_code == 429 else "api_error"
|
| 1524 |
+
return jsonify({"error": {"message": f"文件上传失败: {error_message}", "type": err_type}}), status_code
|
| 1525 |
+
|
| 1526 |
+
except Exception as e:
|
| 1527 |
+
total_time = time.time() - request_start_time
|
| 1528 |
+
print(f"\n[文件上传] ===== 发生异常 =====")
|
| 1529 |
+
print(f"[文件上传] 错误类型: {type(e).__name__}")
|
| 1530 |
+
print(f"[文件上传] 错误信息: {e}")
|
| 1531 |
+
print(f"[文件上传] 堆栈跟踪:\n{traceback.format_exc()}")
|
| 1532 |
+
print(f"[文件上传] 总耗时: {total_time:.3f}秒")
|
| 1533 |
+
print(f"{'='*60}\n")
|
| 1534 |
+
return jsonify({"error": {"message": str(e), "type": "api_error"}}), 500
|
| 1535 |
+
|
| 1536 |
+
|
| 1537 |
+
@app.route('/v1/files', methods=['GET'])
|
| 1538 |
+
@require_api_auth
|
| 1539 |
+
def list_files():
|
| 1540 |
+
"""获取已上传文件列表"""
|
| 1541 |
+
files = file_manager.list_files()
|
| 1542 |
+
return jsonify({
|
| 1543 |
+
"object": "list",
|
| 1544 |
+
"data": [{
|
| 1545 |
+
"id": f["openai_file_id"],
|
| 1546 |
+
"object": "file",
|
| 1547 |
+
"bytes": f.get("size", 0),
|
| 1548 |
+
"created_at": f.get("created_at", int(time.time())),
|
| 1549 |
+
"filename": f.get("filename", ""),
|
| 1550 |
+
"purpose": "assistants"
|
| 1551 |
+
} for f in files]
|
| 1552 |
+
})
|
| 1553 |
+
|
| 1554 |
+
|
| 1555 |
+
@app.route('/v1/files/<file_id>', methods=['GET'])
|
| 1556 |
+
@require_api_auth
|
| 1557 |
+
def get_file(file_id):
|
| 1558 |
+
"""获取文件信息"""
|
| 1559 |
+
file_info = file_manager.get_file(file_id)
|
| 1560 |
+
if not file_info:
|
| 1561 |
+
return jsonify({"error": {"message": "File not found", "type": "invalid_request_error"}}), 404
|
| 1562 |
+
|
| 1563 |
+
return jsonify({
|
| 1564 |
+
"id": file_info["openai_file_id"],
|
| 1565 |
+
"object": "file",
|
| 1566 |
+
"bytes": file_info.get("size", 0),
|
| 1567 |
+
"created_at": file_info.get("created_at", int(time.time())),
|
| 1568 |
+
"filename": file_info.get("filename", ""),
|
| 1569 |
+
"purpose": "assistants"
|
| 1570 |
+
})
|
| 1571 |
+
|
| 1572 |
+
|
| 1573 |
+
@app.route('/v1/files/<file_id>', methods=['DELETE'])
|
| 1574 |
+
@require_api_auth
|
| 1575 |
+
def delete_file(file_id):
|
| 1576 |
+
"""删除文件"""
|
| 1577 |
+
if file_manager.delete_file(file_id):
|
| 1578 |
+
return jsonify({
|
| 1579 |
+
"id": file_id,
|
| 1580 |
+
"object": "file",
|
| 1581 |
+
"deleted": True
|
| 1582 |
+
})
|
| 1583 |
+
return jsonify({"error": {"message": "File not found", "type": "invalid_request_error"}}), 404
|
| 1584 |
+
|
| 1585 |
+
|
| 1586 |
+
@app.route('/v1/chat/completions', methods=['POST'])
|
| 1587 |
+
@require_api_auth
|
| 1588 |
+
def chat_completions():
|
| 1589 |
+
"""聊天对话接口(支持图��输入输出)"""
|
| 1590 |
+
try:
|
| 1591 |
+
# 每次请求时清理过期图片
|
| 1592 |
+
cleanup_expired_images()
|
| 1593 |
+
|
| 1594 |
+
data = request.json
|
| 1595 |
+
messages = data.get('messages', [])
|
| 1596 |
+
prompts = data.get('prompts', []) # 支持替代格式
|
| 1597 |
+
stream = data.get('stream', False)
|
| 1598 |
+
|
| 1599 |
+
# 提取用户消息、图片和文件ID
|
| 1600 |
+
user_message = ""
|
| 1601 |
+
input_images = []
|
| 1602 |
+
input_file_ids = [] # OpenAI file_id 列表
|
| 1603 |
+
|
| 1604 |
+
# 处理标准 OpenAI messages 格式
|
| 1605 |
+
for msg in messages:
|
| 1606 |
+
if msg.get('role') == 'user':
|
| 1607 |
+
content = msg.get('content', '')
|
| 1608 |
+
text, images = extract_images_from_openai_content(content)
|
| 1609 |
+
if text:
|
| 1610 |
+
user_message = text
|
| 1611 |
+
input_images.extend(images)
|
| 1612 |
+
|
| 1613 |
+
# 提取文件ID(支持多种格式)
|
| 1614 |
+
if isinstance(content, list):
|
| 1615 |
+
for item in content:
|
| 1616 |
+
if isinstance(item, dict):
|
| 1617 |
+
# 格式1: {"type": "file", "file_id": "xxx"}
|
| 1618 |
+
if item.get('type') == 'file' and item.get('file_id'):
|
| 1619 |
+
input_file_ids.append(item['file_id'])
|
| 1620 |
+
# 格式2: {"type": "file", "file": {"file_id": "xxx"}}
|
| 1621 |
+
elif item.get('type') == 'file' and isinstance(item.get('file'), dict):
|
| 1622 |
+
file_obj = item['file']
|
| 1623 |
+
# 支持 file_id 或 id 两种字段名
|
| 1624 |
+
fid = file_obj.get('file_id') or file_obj.get('id')
|
| 1625 |
+
if fid:
|
| 1626 |
+
input_file_ids.append(fid)
|
| 1627 |
+
|
| 1628 |
+
# 处理替代 prompts 格式(支持内联 base64 图片)
|
| 1629 |
+
# 格式: {"prompts": [{"role": "user", "text": "...", "files": [{"data": "data:image...", "type": "image"}]}]}
|
| 1630 |
+
for prompt in prompts:
|
| 1631 |
+
if prompt.get('role') == 'user':
|
| 1632 |
+
# 提取文本
|
| 1633 |
+
prompt_text = prompt.get('text', '')
|
| 1634 |
+
if prompt_text and not user_message:
|
| 1635 |
+
user_message = prompt_text
|
| 1636 |
+
elif prompt_text:
|
| 1637 |
+
user_message = prompt_text # 使用最新的用户消息
|
| 1638 |
+
|
| 1639 |
+
# 提取内联 files 数组中的图片
|
| 1640 |
+
files_array = prompt.get('files', [])
|
| 1641 |
+
if files_array:
|
| 1642 |
+
images_from_files = extract_images_from_files_array(files_array)
|
| 1643 |
+
input_images.extend(images_from_files)
|
| 1644 |
+
|
| 1645 |
+
# 将 OpenAI file_id 转换为 Gemini fileId
|
| 1646 |
+
gemini_file_ids = []
|
| 1647 |
+
for fid in input_file_ids:
|
| 1648 |
+
gemini_fid = file_manager.get_gemini_file_id(fid)
|
| 1649 |
+
if gemini_fid:
|
| 1650 |
+
gemini_file_ids.append(gemini_fid)
|
| 1651 |
+
|
| 1652 |
+
if not user_message and not input_images and not gemini_file_ids:
|
| 1653 |
+
return jsonify({"error": "No user message found"}), 400
|
| 1654 |
+
|
| 1655 |
+
# 检查是否指定了特定账号
|
| 1656 |
+
specified_account_id = data.get('account_id')
|
| 1657 |
+
|
| 1658 |
+
if specified_account_id is not None:
|
| 1659 |
+
# 使用指定的账号
|
| 1660 |
+
accounts = account_manager.accounts
|
| 1661 |
+
if specified_account_id < 0 or specified_account_id >= len(accounts):
|
| 1662 |
+
return jsonify({"error": f"无效的账号ID: {specified_account_id}"}), 400
|
| 1663 |
+
account = accounts[specified_account_id]
|
| 1664 |
+
if not account.get('enabled', True):
|
| 1665 |
+
return jsonify({"error": f"账号 {specified_account_id} 已禁用"}), 400
|
| 1666 |
+
# 检查是否在冷却中
|
| 1667 |
+
cooldown_until = account.get('cooldown_until', 0)
|
| 1668 |
+
if cooldown_until > time.time():
|
| 1669 |
+
return jsonify({"error": f"账号 {specified_account_id} 正在冷却中,请稍后重试"}), 429
|
| 1670 |
+
|
| 1671 |
+
max_retries = 1
|
| 1672 |
+
last_error = None
|
| 1673 |
+
chat_response = None
|
| 1674 |
+
account_idx = specified_account_id
|
| 1675 |
+
try:
|
| 1676 |
+
session, jwt, team_id = ensure_session_for_account(account_idx, account)
|
| 1677 |
+
proxy = get_proxy()
|
| 1678 |
+
|
| 1679 |
+
for img in input_images:
|
| 1680 |
+
uploaded_file_id = upload_inline_image_to_gemini(jwt, session, team_id, img, proxy)
|
| 1681 |
+
if uploaded_file_id:
|
| 1682 |
+
gemini_file_ids.append(uploaded_file_id)
|
| 1683 |
+
|
| 1684 |
+
chat_response = stream_chat_with_images(jwt, session, user_message, proxy, team_id, gemini_file_ids)
|
| 1685 |
+
except (AccountRateLimitError, AccountAuthError, AccountRequestError) as e:
|
| 1686 |
+
last_error = e
|
| 1687 |
+
account_manager.mark_account_cooldown(account_idx, str(e), account_manager.generic_error_cooldown)
|
| 1688 |
+
except Exception as e:
|
| 1689 |
+
last_error = e
|
| 1690 |
+
else:
|
| 1691 |
+
# 轮训获取账号
|
| 1692 |
+
available_accounts = account_manager.get_available_accounts()
|
| 1693 |
+
if not available_accounts:
|
| 1694 |
+
next_cd = account_manager.get_next_cooldown_info()
|
| 1695 |
+
wait_msg = ""
|
| 1696 |
+
if next_cd:
|
| 1697 |
+
wait_msg = f"(最近冷却账号 {next_cd['index']},约 {int(next_cd['cooldown_until']-time.time())} 秒后可重试)"
|
| 1698 |
+
return jsonify({"error": f"没有可用的账号{wait_msg}"}), 429
|
| 1699 |
+
|
| 1700 |
+
max_retries = len(available_accounts)
|
| 1701 |
+
last_error = None
|
| 1702 |
+
chat_response = None
|
| 1703 |
+
|
| 1704 |
+
for retry_idx in range(max_retries):
|
| 1705 |
+
account_idx = None
|
| 1706 |
+
try:
|
| 1707 |
+
account_idx, account = account_manager.get_next_account()
|
| 1708 |
+
session, jwt, team_id = ensure_session_for_account(account_idx, account)
|
| 1709 |
+
proxy = get_proxy()
|
| 1710 |
+
|
| 1711 |
+
# 上传内联图片获取 fileId
|
| 1712 |
+
for img in input_images:
|
| 1713 |
+
uploaded_file_id = upload_inline_image_to_gemini(jwt, session, team_id, img, proxy)
|
| 1714 |
+
if uploaded_file_id:
|
| 1715 |
+
gemini_file_ids.append(uploaded_file_id)
|
| 1716 |
+
|
| 1717 |
+
chat_response = stream_chat_with_images(jwt, session, user_message, proxy, team_id, gemini_file_ids)
|
| 1718 |
+
break
|
| 1719 |
+
except AccountRateLimitError as e:
|
| 1720 |
+
last_error = e
|
| 1721 |
+
if account_idx is not None:
|
| 1722 |
+
pt_wait = seconds_until_next_pt_midnight()
|
| 1723 |
+
cooldown_seconds = max(account_manager.rate_limit_cooldown, pt_wait)
|
| 1724 |
+
account_manager.mark_account_cooldown(account_idx, str(e), cooldown_seconds)
|
| 1725 |
+
print(f"[聊天] 第{retry_idx+1}次尝试失败(限额): {e}")
|
| 1726 |
+
continue
|
| 1727 |
+
except AccountAuthError as e:
|
| 1728 |
+
last_error = e
|
| 1729 |
+
if account_idx is not None:
|
| 1730 |
+
account_manager.mark_account_cooldown(account_idx, str(e), account_manager.auth_error_cooldown)
|
| 1731 |
+
print(f"[聊天] 第{retry_idx+1}次尝试失败(凭证): {e}")
|
| 1732 |
+
continue
|
| 1733 |
+
except AccountRequestError as e:
|
| 1734 |
+
last_error = e
|
| 1735 |
+
if account_idx is not None:
|
| 1736 |
+
account_manager.mark_account_cooldown(account_idx, str(e), account_manager.generic_error_cooldown)
|
| 1737 |
+
print(f"[聊天] 第{retry_idx+1}次尝试失败(请求异常): {e}")
|
| 1738 |
+
continue
|
| 1739 |
+
except Exception as e:
|
| 1740 |
+
last_error = e
|
| 1741 |
+
print(f"[聊天] 第{retry_idx+1}次尝试失败: {type(e).__name__}: {e}")
|
| 1742 |
+
if account_idx is None:
|
| 1743 |
+
break
|
| 1744 |
+
continue
|
| 1745 |
+
|
| 1746 |
+
if chat_response is None:
|
| 1747 |
+
error_message = last_error or "没有可用的账号"
|
| 1748 |
+
status_code = 429 if isinstance(last_error, (AccountRateLimitError, NoAvailableAccount)) else 500
|
| 1749 |
+
return jsonify({"error": f"所有账号请求失败: {error_message}"}), status_code
|
| 1750 |
+
|
| 1751 |
+
# 获取使用的账号csesidx
|
| 1752 |
+
used_account_csesidx = None
|
| 1753 |
+
if account_idx is not None and account_idx < len(account_manager.accounts):
|
| 1754 |
+
used_account = account_manager.accounts[account_idx]
|
| 1755 |
+
used_account_csesidx = used_account.get('csesidx', f'账号{account_idx}')
|
| 1756 |
+
|
| 1757 |
+
# 构建响应内容(包含图片)
|
| 1758 |
+
response_content = build_openai_response_content(chat_response, request.host_url)
|
| 1759 |
+
|
| 1760 |
+
if stream:
|
| 1761 |
+
# 流式响应
|
| 1762 |
+
def generate():
|
| 1763 |
+
chunk_id = f"chatcmpl-{uuid.uuid4().hex[:8]}"
|
| 1764 |
+
chunk = {
|
| 1765 |
+
"id": chunk_id,
|
| 1766 |
+
"object": "chat.completion.chunk",
|
| 1767 |
+
"created": int(time.time()),
|
| 1768 |
+
"model": "gemini-enterprise",
|
| 1769 |
+
"account_csesidx": used_account_csesidx,
|
| 1770 |
+
"choices": [{
|
| 1771 |
+
"index": 0,
|
| 1772 |
+
"delta": {"content": response_content},
|
| 1773 |
+
"finish_reason": None
|
| 1774 |
+
}]
|
| 1775 |
+
}
|
| 1776 |
+
yield f"data: {json.dumps(chunk, ensure_ascii=False)}\n\n"
|
| 1777 |
+
|
| 1778 |
+
# 结束标记
|
| 1779 |
+
end_chunk = {
|
| 1780 |
+
"id": chunk_id,
|
| 1781 |
+
"object": "chat.completion.chunk",
|
| 1782 |
+
"created": int(time.time()),
|
| 1783 |
+
"model": "gemini-enterprise",
|
| 1784 |
+
"choices": [{
|
| 1785 |
+
"index": 0,
|
| 1786 |
+
"delta": {},
|
| 1787 |
+
"finish_reason": "stop"
|
| 1788 |
+
}]
|
| 1789 |
+
}
|
| 1790 |
+
yield f"data: {json.dumps(end_chunk, ensure_ascii=False)}\n\n"
|
| 1791 |
+
yield "data: [DONE]\n\n"
|
| 1792 |
+
|
| 1793 |
+
return Response(generate(), mimetype='text/event-stream')
|
| 1794 |
+
else:
|
| 1795 |
+
# 非流式响应
|
| 1796 |
+
response = {
|
| 1797 |
+
"id": f"chatcmpl-{uuid.uuid4().hex[:8]}",
|
| 1798 |
+
"object": "chat.completion",
|
| 1799 |
+
"created": int(time.time()),
|
| 1800 |
+
"model": "gemini-enterprise",
|
| 1801 |
+
"account_csesidx": used_account_csesidx,
|
| 1802 |
+
"choices": [{
|
| 1803 |
+
"index": 0,
|
| 1804 |
+
"message": {
|
| 1805 |
+
"role": "assistant",
|
| 1806 |
+
"content": response_content
|
| 1807 |
+
},
|
| 1808 |
+
"finish_reason": "stop"
|
| 1809 |
+
}],
|
| 1810 |
+
"usage": {
|
| 1811 |
+
"prompt_tokens": len(user_message),
|
| 1812 |
+
"completion_tokens": len(chat_response.text),
|
| 1813 |
+
"total_tokens": len(user_message) + len(chat_response.text)
|
| 1814 |
+
}
|
| 1815 |
+
}
|
| 1816 |
+
return jsonify(response)
|
| 1817 |
+
|
| 1818 |
+
except Exception as e:
|
| 1819 |
+
import traceback
|
| 1820 |
+
traceback.print_exc()
|
| 1821 |
+
return jsonify({"error": str(e)}), 500
|
| 1822 |
+
|
| 1823 |
+
|
| 1824 |
+
def get_image_base_url(fallback_host_url: str) -> str:
|
| 1825 |
+
"""获取图片基础URL
|
| 1826 |
+
|
| 1827 |
+
优先使用配置文件中的 image_base_url,否则使用请求的 host_url
|
| 1828 |
+
"""
|
| 1829 |
+
configured_url = account_manager.config.get("image_base_url", "").strip()
|
| 1830 |
+
if configured_url:
|
| 1831 |
+
# 确保以 / 结尾
|
| 1832 |
+
if not configured_url.endswith("/"):
|
| 1833 |
+
configured_url += "/"
|
| 1834 |
+
return configured_url
|
| 1835 |
+
return fallback_host_url
|
| 1836 |
+
|
| 1837 |
+
|
| 1838 |
+
def is_base64_output_mode() -> bool:
|
| 1839 |
+
try:
|
| 1840 |
+
if account_manager.config:
|
| 1841 |
+
mode = account_manager.config.get("image_output_mode") or "url"
|
| 1842 |
+
if isinstance(mode, str) and mode.lower() == "base64":
|
| 1843 |
+
return True
|
| 1844 |
+
except Exception:
|
| 1845 |
+
pass
|
| 1846 |
+
return False
|
| 1847 |
+
|
| 1848 |
+
|
| 1849 |
+
def build_openai_response_content(chat_response: ChatResponse, host_url: str) -> str:
|
| 1850 |
+
"""构建OpenAI格式的响应内容
|
| 1851 |
+
|
| 1852 |
+
返回纯文本,如果有图片可根据配置选择:
|
| 1853 |
+
- url: 在文本末尾追加图片URL(默认行为)
|
| 1854 |
+
- base64: 在文本末尾追加 data:image/...;base64,...
|
| 1855 |
+
"""
|
| 1856 |
+
result_text = chat_response.text
|
| 1857 |
+
|
| 1858 |
+
if not chat_response.images:
|
| 1859 |
+
return result_text
|
| 1860 |
+
|
| 1861 |
+
# 从配置读取图片输出模式,默认 url
|
| 1862 |
+
image_mode = "base64" if is_base64_output_mode() else "url"
|
| 1863 |
+
|
| 1864 |
+
image_lines = []
|
| 1865 |
+
|
| 1866 |
+
if image_mode == "base64":
|
| 1867 |
+
# 优先使用已有的base64数据(使用 Markdown 图片语法,方便前端渲染)
|
| 1868 |
+
for img in chat_response.images:
|
| 1869 |
+
if img.base64_data:
|
| 1870 |
+
mime = img.mime_type or "image/png"
|
| 1871 |
+
image_lines.append(f"")
|
| 1872 |
+
|
| 1873 |
+
# 若部分图片没有base64数据,降级为URL形式,同样用 Markdown 图片语法
|
| 1874 |
+
base_url = get_image_base_url(host_url)
|
| 1875 |
+
for img in chat_response.images:
|
| 1876 |
+
if not img.base64_data and img.file_name:
|
| 1877 |
+
image_lines.append(f"")
|
| 1878 |
+
else:
|
| 1879 |
+
# 传统URL模式
|
| 1880 |
+
base_url = get_image_base_url(host_url)
|
| 1881 |
+
for img in chat_response.images:
|
| 1882 |
+
if img.file_name:
|
| 1883 |
+
image_lines.append(f"{base_url}image/{img.file_name}")
|
| 1884 |
+
|
| 1885 |
+
if image_lines:
|
| 1886 |
+
if result_text:
|
| 1887 |
+
result_text += "\n\n"
|
| 1888 |
+
result_text += "\n".join(image_lines)
|
| 1889 |
+
|
| 1890 |
+
return result_text
|
| 1891 |
+
|
| 1892 |
+
|
| 1893 |
+
# ==================== 图片服务接口 ====================
|
| 1894 |
+
|
| 1895 |
+
@app.route('/image/<path:filename>')
|
| 1896 |
+
def serve_image(filename):
|
| 1897 |
+
"""提供缓存图片的访问"""
|
| 1898 |
+
# 安全检查:防止路径遍历
|
| 1899 |
+
if '..' in filename or filename.startswith('/'):
|
| 1900 |
+
abort(404)
|
| 1901 |
+
|
| 1902 |
+
filepath = IMAGE_CACHE_DIR / filename
|
| 1903 |
+
if not filepath.exists():
|
| 1904 |
+
abort(404)
|
| 1905 |
+
|
| 1906 |
+
# 确定Content-Type
|
| 1907 |
+
ext = filepath.suffix.lower()
|
| 1908 |
+
mime_types = {
|
| 1909 |
+
'.png': 'image/png',
|
| 1910 |
+
'.jpg': 'image/jpeg',
|
| 1911 |
+
'.jpeg': 'image/jpeg',
|
| 1912 |
+
'.gif': 'image/gif',
|
| 1913 |
+
'.webp': 'image/webp',
|
| 1914 |
+
}
|
| 1915 |
+
mime_type = mime_types.get(ext, 'application/octet-stream')
|
| 1916 |
+
|
| 1917 |
+
return send_from_directory(IMAGE_CACHE_DIR, filename, mimetype=mime_type)
|
| 1918 |
+
|
| 1919 |
+
|
| 1920 |
+
@app.route('/health', methods=['GET'])
|
| 1921 |
+
def health_check():
|
| 1922 |
+
"""健康检查"""
|
| 1923 |
+
return jsonify({"status": "ok", "timestamp": datetime.now().isoformat()})
|
| 1924 |
+
|
| 1925 |
+
|
| 1926 |
+
@app.route('/api/status', methods=['GET'])
|
| 1927 |
+
@require_admin
|
| 1928 |
+
def system_status():
|
| 1929 |
+
"""获取系统状态"""
|
| 1930 |
+
total, available = account_manager.get_account_count()
|
| 1931 |
+
proxy_url = account_manager.config.get("proxy")
|
| 1932 |
+
proxy_enabled = account_manager.config.get("proxy_enabled", False)
|
| 1933 |
+
effective_proxy = get_proxy() # 实际使用的代理(考虑开关状态)
|
| 1934 |
+
|
| 1935 |
+
return jsonify({
|
| 1936 |
+
"status": "ok",
|
| 1937 |
+
"timestamp": datetime.now().isoformat(),
|
| 1938 |
+
"accounts": {
|
| 1939 |
+
"total": total,
|
| 1940 |
+
"available": available
|
| 1941 |
+
},
|
| 1942 |
+
"proxy": {
|
| 1943 |
+
"url": proxy_url,
|
| 1944 |
+
"enabled": proxy_enabled,
|
| 1945 |
+
"effective": effective_proxy, # 实际生效的代理地址
|
| 1946 |
+
"available": check_proxy(effective_proxy) if effective_proxy else False
|
| 1947 |
+
},
|
| 1948 |
+
"models": account_manager.config.get("models", [])
|
| 1949 |
+
})
|
| 1950 |
+
|
| 1951 |
+
|
| 1952 |
+
# ==================== 管理接口 ====================
|
| 1953 |
+
|
| 1954 |
+
@app.route('/')
|
| 1955 |
+
def index():
|
| 1956 |
+
"""返回管理页面"""
|
| 1957 |
+
return send_from_directory('.', 'index.html')
|
| 1958 |
+
|
| 1959 |
+
@app.route('/chat_history.html')
|
| 1960 |
+
@require_admin
|
| 1961 |
+
def chat_history():
|
| 1962 |
+
"""返回聊天记录页面"""
|
| 1963 |
+
return send_from_directory('.', 'chat_history.html')
|
| 1964 |
+
|
| 1965 |
+
@app.route('/api/accounts', methods=['GET'])
|
| 1966 |
+
@require_admin
|
| 1967 |
+
def get_accounts():
|
| 1968 |
+
"""获取账号列表"""
|
| 1969 |
+
accounts_data = []
|
| 1970 |
+
now_ts = time.time()
|
| 1971 |
+
for i, acc in enumerate(account_manager.accounts):
|
| 1972 |
+
state = account_manager.account_states.get(i, {})
|
| 1973 |
+
cooldown_until = state.get("cooldown_until")
|
| 1974 |
+
cooldown_active = bool(cooldown_until and cooldown_until > now_ts)
|
| 1975 |
+
effective_available = state.get("available", True) and not cooldown_active
|
| 1976 |
+
|
| 1977 |
+
# 返回完整值用于编辑,前端显示时再截断
|
| 1978 |
+
accounts_data.append({
|
| 1979 |
+
"id": i,
|
| 1980 |
+
"team_id": acc.get("team_id", ""),
|
| 1981 |
+
"secure_c_ses": acc.get("secure_c_ses", ""),
|
| 1982 |
+
"host_c_oses": acc.get("host_c_oses", ""),
|
| 1983 |
+
"csesidx": acc.get("csesidx", ""),
|
| 1984 |
+
"user_agent": acc.get("user_agent", ""),
|
| 1985 |
+
"available": effective_available,
|
| 1986 |
+
"unavailable_reason": acc.get("unavailable_reason", ""),
|
| 1987 |
+
"cooldown_until": cooldown_until if cooldown_active else None,
|
| 1988 |
+
"cooldown_reason": state.get("cooldown_reason", ""),
|
| 1989 |
+
"has_jwt": state.get("jwt") is not None
|
| 1990 |
+
})
|
| 1991 |
+
return jsonify({"accounts": accounts_data})
|
| 1992 |
+
|
| 1993 |
+
|
| 1994 |
+
@app.route('/api/accounts', methods=['POST'])
|
| 1995 |
+
@require_admin
|
| 1996 |
+
def add_account():
|
| 1997 |
+
"""添加账号"""
|
| 1998 |
+
data = request.json
|
| 1999 |
+
# 去重:基于 csesidx 或 team_id 检查
|
| 2000 |
+
new_csesidx = data.get("csesidx", "")
|
| 2001 |
+
new_team_id = data.get("team_id", "")
|
| 2002 |
+
for acc in account_manager.accounts:
|
| 2003 |
+
if new_csesidx and acc.get("csesidx") == new_csesidx:
|
| 2004 |
+
return jsonify({"error": "账号已存在(同 csesidx)"}), 400
|
| 2005 |
+
if new_team_id and acc.get("team_id") == new_team_id and new_csesidx == acc.get("csesidx"):
|
| 2006 |
+
return jsonify({"error": "账号已存在(同 team_id + csesidx)"}), 400
|
| 2007 |
+
|
| 2008 |
+
new_account = {
|
| 2009 |
+
"team_id": data.get("team_id", ""),
|
| 2010 |
+
"secure_c_ses": data.get("secure_c_ses", ""),
|
| 2011 |
+
"host_c_oses": data.get("host_c_oses", ""),
|
| 2012 |
+
"csesidx": data.get("csesidx", ""),
|
| 2013 |
+
"user_agent": data.get("user_agent", "Mozilla/5.0"),
|
| 2014 |
+
"available": True
|
| 2015 |
+
}
|
| 2016 |
+
|
| 2017 |
+
account_manager.accounts.append(new_account)
|
| 2018 |
+
idx = len(account_manager.accounts) - 1
|
| 2019 |
+
account_manager.account_states[idx] = {
|
| 2020 |
+
"jwt": None,
|
| 2021 |
+
"jwt_time": 0,
|
| 2022 |
+
"session": None,
|
| 2023 |
+
"available": True,
|
| 2024 |
+
"cooldown_until": None,
|
| 2025 |
+
"cooldown_reason": ""
|
| 2026 |
+
}
|
| 2027 |
+
account_manager.config["accounts"] = account_manager.accounts
|
| 2028 |
+
account_manager.save_config()
|
| 2029 |
+
|
| 2030 |
+
return jsonify({"success": True, "id": idx})
|
| 2031 |
+
|
| 2032 |
+
|
| 2033 |
+
@app.route('/api/accounts/<int:account_id>', methods=['PUT'])
|
| 2034 |
+
@require_admin
|
| 2035 |
+
def update_account(account_id):
|
| 2036 |
+
"""更新账号"""
|
| 2037 |
+
if account_id < 0 or account_id >= len(account_manager.accounts):
|
| 2038 |
+
return jsonify({"error": "账号不存在"}), 404
|
| 2039 |
+
|
| 2040 |
+
data = request.json
|
| 2041 |
+
acc = account_manager.accounts[account_id]
|
| 2042 |
+
|
| 2043 |
+
if "team_id" in data:
|
| 2044 |
+
acc["team_id"] = data["team_id"]
|
| 2045 |
+
if "secure_c_ses" in data:
|
| 2046 |
+
acc["secure_c_ses"] = data["secure_c_ses"]
|
| 2047 |
+
if "host_c_oses" in data:
|
| 2048 |
+
acc["host_c_oses"] = data["host_c_oses"]
|
| 2049 |
+
if "csesidx" in data:
|
| 2050 |
+
acc["csesidx"] = data["csesidx"]
|
| 2051 |
+
if "user_agent" in data:
|
| 2052 |
+
acc["user_agent"] = data["user_agent"]
|
| 2053 |
+
|
| 2054 |
+
# 同步更新config中的accounts
|
| 2055 |
+
account_manager.config["accounts"] = account_manager.accounts
|
| 2056 |
+
account_manager.save_config()
|
| 2057 |
+
return jsonify({"success": True})
|
| 2058 |
+
|
| 2059 |
+
|
| 2060 |
+
@app.route('/api/accounts/<int:account_id>', methods=['DELETE'])
|
| 2061 |
+
@require_admin
|
| 2062 |
+
def delete_account(account_id):
|
| 2063 |
+
"""删除账号"""
|
| 2064 |
+
if account_id < 0 or account_id >= len(account_manager.accounts):
|
| 2065 |
+
return jsonify({"error": "账号不存在"}), 404
|
| 2066 |
+
|
| 2067 |
+
account_manager.accounts.pop(account_id)
|
| 2068 |
+
# 重建状态映射
|
| 2069 |
+
new_states = {}
|
| 2070 |
+
for i in range(len(account_manager.accounts)):
|
| 2071 |
+
if i < account_id:
|
| 2072 |
+
new_states[i] = account_manager.account_states.get(i, {})
|
| 2073 |
+
else:
|
| 2074 |
+
new_states[i] = account_manager.account_states.get(i + 1, {})
|
| 2075 |
+
account_manager.account_states = new_states
|
| 2076 |
+
account_manager.config["accounts"] = account_manager.accounts
|
| 2077 |
+
account_manager.save_config()
|
| 2078 |
+
|
| 2079 |
+
return jsonify({"success": True})
|
| 2080 |
+
|
| 2081 |
+
|
| 2082 |
+
@app.route('/api/accounts/<int:account_id>/toggle', methods=['POST'])
|
| 2083 |
+
@require_admin
|
| 2084 |
+
def toggle_account(account_id):
|
| 2085 |
+
"""��换账号状态"""
|
| 2086 |
+
if account_id < 0 or account_id >= len(account_manager.accounts):
|
| 2087 |
+
return jsonify({"error": "账号不存在"}), 404
|
| 2088 |
+
|
| 2089 |
+
state = account_manager.account_states.get(account_id, {})
|
| 2090 |
+
current = state.get("available", True)
|
| 2091 |
+
state["available"] = not current
|
| 2092 |
+
account_manager.accounts[account_id]["available"] = not current
|
| 2093 |
+
|
| 2094 |
+
if not current:
|
| 2095 |
+
# 重新启用时清除错误信息
|
| 2096 |
+
account_manager.accounts[account_id].pop("unavailable_reason", None)
|
| 2097 |
+
account_manager.accounts[account_id].pop("unavailable_time", None)
|
| 2098 |
+
state.pop("cooldown_until", None)
|
| 2099 |
+
state.pop("cooldown_reason", None)
|
| 2100 |
+
account_manager.accounts[account_id].pop("cooldown_until", None)
|
| 2101 |
+
|
| 2102 |
+
account_manager.save_config()
|
| 2103 |
+
return jsonify({"success": True, "available": not current})
|
| 2104 |
+
|
| 2105 |
+
|
| 2106 |
+
@app.route('/api/accounts/<int:account_id>/refresh-cookie', methods=['POST'])
|
| 2107 |
+
@require_admin
|
| 2108 |
+
def refresh_account_cookies(account_id):
|
| 2109 |
+
"""刷新账号的secure_c_ses、host_c_oses和csesidx"""
|
| 2110 |
+
if account_id < 0 or account_id >= len(account_manager.accounts):
|
| 2111 |
+
return jsonify({"error": "账号不存在"}), 404
|
| 2112 |
+
|
| 2113 |
+
data = request.json
|
| 2114 |
+
acc = account_manager.accounts[account_id]
|
| 2115 |
+
|
| 2116 |
+
# 更新Cookie字段
|
| 2117 |
+
if "secure_c_ses" in data:
|
| 2118 |
+
acc["secure_c_ses"] = data["secure_c_ses"]
|
| 2119 |
+
if "host_c_oses" in data:
|
| 2120 |
+
acc["host_c_oses"] = data["host_c_oses"]
|
| 2121 |
+
if "csesidx" in data and data["csesidx"]:
|
| 2122 |
+
acc["csesidx"] = data["csesidx"]
|
| 2123 |
+
|
| 2124 |
+
# 清除JWT缓存,强制重新获取
|
| 2125 |
+
state = account_manager.account_states.get(account_id, {})
|
| 2126 |
+
state["jwt"] = None
|
| 2127 |
+
state["jwt_time"] = 0
|
| 2128 |
+
account_manager.account_states[account_id] = state
|
| 2129 |
+
|
| 2130 |
+
account_manager.config["accounts"] = account_manager.accounts
|
| 2131 |
+
account_manager.save_config()
|
| 2132 |
+
|
| 2133 |
+
return jsonify({"success": True, "message": "Cookie已刷新"})
|
| 2134 |
+
|
| 2135 |
+
|
| 2136 |
+
@app.route('/api/accounts/<int:account_id>/test', methods=['GET'])
|
| 2137 |
+
@require_admin
|
| 2138 |
+
def test_account(account_id):
|
| 2139 |
+
"""测试账号JWT获取"""
|
| 2140 |
+
if account_id < 0 or account_id >= len(account_manager.accounts):
|
| 2141 |
+
return jsonify({"error": "账号不存在"}), 404
|
| 2142 |
+
|
| 2143 |
+
account = account_manager.accounts[account_id]
|
| 2144 |
+
proxy = account_manager.config.get("proxy")
|
| 2145 |
+
|
| 2146 |
+
try:
|
| 2147 |
+
jwt = get_jwt_for_account(account, proxy)
|
| 2148 |
+
return jsonify({"success": True, "message": "JWT获取成功"})
|
| 2149 |
+
except AccountRateLimitError as e:
|
| 2150 |
+
pt_wait = seconds_until_next_pt_midnight()
|
| 2151 |
+
cooldown_seconds = max(account_manager.rate_limit_cooldown, pt_wait)
|
| 2152 |
+
account_manager.mark_account_cooldown(account_id, str(e), cooldown_seconds)
|
| 2153 |
+
return jsonify({"success": False, "message": str(e), "cooldown": cooldown_seconds})
|
| 2154 |
+
except AccountAuthError as e:
|
| 2155 |
+
account_manager.mark_account_cooldown(account_id, str(e), account_manager.auth_error_cooldown)
|
| 2156 |
+
return jsonify({"success": False, "message": str(e), "cooldown": account_manager.auth_error_cooldown})
|
| 2157 |
+
except AccountRequestError as e:
|
| 2158 |
+
account_manager.mark_account_cooldown(account_id, str(e), account_manager.generic_error_cooldown)
|
| 2159 |
+
return jsonify({"success": False, "message": str(e), "cooldown": account_manager.generic_error_cooldown})
|
| 2160 |
+
except Exception as e:
|
| 2161 |
+
return jsonify({"success": False, "message": str(e)})
|
| 2162 |
+
|
| 2163 |
+
|
| 2164 |
+
@app.route('/api/models', methods=['GET'])
|
| 2165 |
+
@require_admin
|
| 2166 |
+
def get_models_config():
|
| 2167 |
+
"""获取模型配置"""
|
| 2168 |
+
models = account_manager.config.get("models", [])
|
| 2169 |
+
return jsonify({"models": models})
|
| 2170 |
+
|
| 2171 |
+
|
| 2172 |
+
@app.route('/api/models', methods=['POST'])
|
| 2173 |
+
@require_admin
|
| 2174 |
+
def add_model():
|
| 2175 |
+
"""添加模型"""
|
| 2176 |
+
data = request.json
|
| 2177 |
+
new_model = {
|
| 2178 |
+
"id": data.get("id", ""),
|
| 2179 |
+
"name": data.get("name", ""),
|
| 2180 |
+
"description": data.get("description", ""),
|
| 2181 |
+
"context_length": data.get("context_length", 32768),
|
| 2182 |
+
"max_tokens": data.get("max_tokens", 8192),
|
| 2183 |
+
"enabled": data.get("enabled", True)
|
| 2184 |
+
}
|
| 2185 |
+
|
| 2186 |
+
if "models" not in account_manager.config:
|
| 2187 |
+
account_manager.config["models"] = []
|
| 2188 |
+
|
| 2189 |
+
account_manager.config["models"].append(new_model)
|
| 2190 |
+
account_manager.save_config()
|
| 2191 |
+
|
| 2192 |
+
return jsonify({"success": True})
|
| 2193 |
+
|
| 2194 |
+
|
| 2195 |
+
@app.route('/api/models/<model_id>', methods=['PUT'])
|
| 2196 |
+
@require_admin
|
| 2197 |
+
def update_model(model_id):
|
| 2198 |
+
"""更新模型"""
|
| 2199 |
+
models = account_manager.config.get("models", [])
|
| 2200 |
+
for model in models:
|
| 2201 |
+
if model.get("id") == model_id:
|
| 2202 |
+
data = request.json
|
| 2203 |
+
if "name" in data:
|
| 2204 |
+
model["name"] = data["name"]
|
| 2205 |
+
if "description" in data:
|
| 2206 |
+
model["description"] = data["description"]
|
| 2207 |
+
if "context_length" in data:
|
| 2208 |
+
model["context_length"] = data["context_length"]
|
| 2209 |
+
if "max_tokens" in data:
|
| 2210 |
+
model["max_tokens"] = data["max_tokens"]
|
| 2211 |
+
if "enabled" in data:
|
| 2212 |
+
model["enabled"] = data["enabled"]
|
| 2213 |
+
account_manager.save_config()
|
| 2214 |
+
return jsonify({"success": True})
|
| 2215 |
+
|
| 2216 |
+
return jsonify({"error": "模型不存在"}), 404
|
| 2217 |
+
|
| 2218 |
+
|
| 2219 |
+
@app.route('/api/models/<model_id>', methods=['DELETE'])
|
| 2220 |
+
@require_admin
|
| 2221 |
+
def delete_model(model_id):
|
| 2222 |
+
"""删除模型"""
|
| 2223 |
+
models = account_manager.config.get("models", [])
|
| 2224 |
+
for i, model in enumerate(models):
|
| 2225 |
+
if model.get("id") == model_id:
|
| 2226 |
+
models.pop(i)
|
| 2227 |
+
account_manager.save_config()
|
| 2228 |
+
return jsonify({"success": True})
|
| 2229 |
+
|
| 2230 |
+
return jsonify({"error": "模型不存在"}), 404
|
| 2231 |
+
|
| 2232 |
+
|
| 2233 |
+
@app.route('/api/config', methods=['GET'])
|
| 2234 |
+
@require_admin
|
| 2235 |
+
def get_config():
|
| 2236 |
+
"""获取完整配置"""
|
| 2237 |
+
return jsonify(account_manager.config)
|
| 2238 |
+
|
| 2239 |
+
|
| 2240 |
+
@app.route('/api/config', methods=['PUT'])
|
| 2241 |
+
@require_admin
|
| 2242 |
+
def update_config():
|
| 2243 |
+
"""更新配置"""
|
| 2244 |
+
data = request.json or {}
|
| 2245 |
+
if "proxy" in data:
|
| 2246 |
+
account_manager.config["proxy"] = data["proxy"]
|
| 2247 |
+
if "log_level" in data:
|
| 2248 |
+
try:
|
| 2249 |
+
set_log_level(data["log_level"], persist=True)
|
| 2250 |
+
except Exception as e:
|
| 2251 |
+
return jsonify({"error": str(e)}), 400
|
| 2252 |
+
if "image_output_mode" in data:
|
| 2253 |
+
mode = data["image_output_mode"]
|
| 2254 |
+
if isinstance(mode, str) and mode.lower() in ("url", "base64"):
|
| 2255 |
+
account_manager.config["image_output_mode"] = mode.lower()
|
| 2256 |
+
account_manager.save_config()
|
| 2257 |
+
return jsonify({"success": True})
|
| 2258 |
+
|
| 2259 |
+
|
| 2260 |
+
@app.route('/api/logging', methods=['GET', 'POST'])
|
| 2261 |
+
@require_admin
|
| 2262 |
+
def logging_config():
|
| 2263 |
+
"""获取或设置日志级别"""
|
| 2264 |
+
if request.method == 'GET':
|
| 2265 |
+
return jsonify({
|
| 2266 |
+
"level": CURRENT_LOG_LEVEL_NAME,
|
| 2267 |
+
"levels": list(LOG_LEVELS.keys())
|
| 2268 |
+
})
|
| 2269 |
+
|
| 2270 |
+
data = request.json or {}
|
| 2271 |
+
level = data.get("level", "").upper()
|
| 2272 |
+
if level not in LOG_LEVELS:
|
| 2273 |
+
return jsonify({"error": "无效日志级别"}), 400
|
| 2274 |
+
|
| 2275 |
+
set_log_level(level, persist=True)
|
| 2276 |
+
return jsonify({"success": True, "level": CURRENT_LOG_LEVEL_NAME})
|
| 2277 |
+
|
| 2278 |
+
|
| 2279 |
+
@app.route('/api/auth/login', methods=['POST'])
|
| 2280 |
+
def admin_login():
|
| 2281 |
+
"""后台登录,返回 token。若尚未设置密码,则首次设置。"""
|
| 2282 |
+
data = request.json or {}
|
| 2283 |
+
password = data.get("password", "")
|
| 2284 |
+
if not password:
|
| 2285 |
+
return jsonify({"error": "密码不能为空"}), 400
|
| 2286 |
+
|
| 2287 |
+
stored_hash = get_admin_password_hash()
|
| 2288 |
+
if stored_hash:
|
| 2289 |
+
if not check_password_hash(stored_hash, password):
|
| 2290 |
+
return jsonify({"error": "密码错误"}), 401
|
| 2291 |
+
else:
|
| 2292 |
+
# 首次设置密码
|
| 2293 |
+
set_admin_password(password)
|
| 2294 |
+
|
| 2295 |
+
token = create_admin_token()
|
| 2296 |
+
resp = jsonify({"token": token, "level": CURRENT_LOG_LEVEL_NAME})
|
| 2297 |
+
resp.set_cookie(
|
| 2298 |
+
"admin_token",
|
| 2299 |
+
token,
|
| 2300 |
+
max_age=86400,
|
| 2301 |
+
httponly=True,
|
| 2302 |
+
secure=False,
|
| 2303 |
+
samesite="Lax",
|
| 2304 |
+
path="/"
|
| 2305 |
+
)
|
| 2306 |
+
return resp
|
| 2307 |
+
|
| 2308 |
+
|
| 2309 |
+
@app.route('/api/tokens', methods=['GET', 'POST'])
|
| 2310 |
+
@require_admin
|
| 2311 |
+
def manage_tokens():
|
| 2312 |
+
"""获取或创建API访问Token"""
|
| 2313 |
+
if request.method == 'GET':
|
| 2314 |
+
return jsonify({"tokens": list(API_TOKENS)})
|
| 2315 |
+
|
| 2316 |
+
data = request.json or {}
|
| 2317 |
+
token = data.get("token")
|
| 2318 |
+
if not token:
|
| 2319 |
+
token = secrets.token_urlsafe(32)
|
| 2320 |
+
if not isinstance(token, str) or len(token) < 8:
|
| 2321 |
+
return jsonify({"error": "Token格式不合法"}), 400
|
| 2322 |
+
if token in API_TOKENS:
|
| 2323 |
+
return jsonify({"error": "Token已存在"}), 400
|
| 2324 |
+
|
| 2325 |
+
API_TOKENS.add(token)
|
| 2326 |
+
persist_api_tokens()
|
| 2327 |
+
return jsonify({"success": True, "token": token})
|
| 2328 |
+
|
| 2329 |
+
|
| 2330 |
+
@app.route('/api/tokens/<token>', methods=['DELETE'])
|
| 2331 |
+
@require_admin
|
| 2332 |
+
def delete_token(token):
|
| 2333 |
+
"""删除指定API Token"""
|
| 2334 |
+
if token in API_TOKENS:
|
| 2335 |
+
API_TOKENS.remove(token)
|
| 2336 |
+
persist_api_tokens()
|
| 2337 |
+
return jsonify({"success": True})
|
| 2338 |
+
return jsonify({"error": "Token不存在"}), 404
|
| 2339 |
+
|
| 2340 |
+
|
| 2341 |
+
@app.route('/api/config/import', methods=['POST'])
|
| 2342 |
+
@require_admin
|
| 2343 |
+
def import_config():
|
| 2344 |
+
"""导入配置"""
|
| 2345 |
+
try:
|
| 2346 |
+
data = request.json
|
| 2347 |
+
account_manager.config = data
|
| 2348 |
+
if data.get("log_level"):
|
| 2349 |
+
try:
|
| 2350 |
+
set_log_level(data.get("log_level"), persist=False)
|
| 2351 |
+
except Exception:
|
| 2352 |
+
pass
|
| 2353 |
+
if data.get("admin_secret_key"):
|
| 2354 |
+
global ADMIN_SECRET_KEY
|
| 2355 |
+
ADMIN_SECRET_KEY = data.get("admin_secret_key")
|
| 2356 |
+
else:
|
| 2357 |
+
get_admin_secret_key()
|
| 2358 |
+
load_api_tokens()
|
| 2359 |
+
account_manager.accounts = data.get("accounts", [])
|
| 2360 |
+
# 重建账号状态
|
| 2361 |
+
account_manager.account_states = {}
|
| 2362 |
+
for i, acc in enumerate(account_manager.accounts):
|
| 2363 |
+
available = acc.get("available", True)
|
| 2364 |
+
account_manager.account_states[i] = {
|
| 2365 |
+
"jwt": None,
|
| 2366 |
+
"jwt_time": 0,
|
| 2367 |
+
"session": None,
|
| 2368 |
+
"available": available,
|
| 2369 |
+
"cooldown_until": acc.get("cooldown_until"),
|
| 2370 |
+
"cooldown_reason": acc.get("unavailable_reason") or acc.get("cooldown_reason") or ""
|
| 2371 |
+
}
|
| 2372 |
+
account_manager.save_config()
|
| 2373 |
+
return jsonify({"success": True})
|
| 2374 |
+
except Exception as e:
|
| 2375 |
+
return jsonify({"error": str(e)}), 400
|
| 2376 |
+
|
| 2377 |
+
|
| 2378 |
+
@app.route('/api/proxy/test', methods=['POST'])
|
| 2379 |
+
@require_admin
|
| 2380 |
+
def test_proxy():
|
| 2381 |
+
"""测试代理"""
|
| 2382 |
+
data = request.json
|
| 2383 |
+
proxy_url = data.get("proxy") or account_manager.config.get("proxy")
|
| 2384 |
+
|
| 2385 |
+
if not proxy_url:
|
| 2386 |
+
return jsonify({"success": False, "message": "未配置代理地址"})
|
| 2387 |
+
|
| 2388 |
+
available = check_proxy(proxy_url)
|
| 2389 |
+
return jsonify({
|
| 2390 |
+
"success": available,
|
| 2391 |
+
"message": "代理可用" if available else "代理不可用或连接超时"
|
| 2392 |
+
})
|
| 2393 |
+
|
| 2394 |
+
|
| 2395 |
+
@app.route('/api/proxy/status', methods=['GET'])
|
| 2396 |
+
@require_admin
|
| 2397 |
+
def get_proxy_status():
|
| 2398 |
+
"""获取代理状态"""
|
| 2399 |
+
proxy = account_manager.config.get("proxy")
|
| 2400 |
+
if not proxy:
|
| 2401 |
+
return jsonify({"enabled": False, "url": None, "available": False})
|
| 2402 |
+
|
| 2403 |
+
available = check_proxy(proxy)
|
| 2404 |
+
return jsonify({
|
| 2405 |
+
"enabled": True,
|
| 2406 |
+
"url": proxy,
|
| 2407 |
+
"available": available
|
| 2408 |
+
})
|
| 2409 |
+
|
| 2410 |
+
|
| 2411 |
+
@app.route('/api/config/export', methods=['GET'])
|
| 2412 |
+
@require_admin
|
| 2413 |
+
def export_config():
|
| 2414 |
+
"""导出配置"""
|
| 2415 |
+
return jsonify(account_manager.config)
|
| 2416 |
+
|
| 2417 |
+
|
| 2418 |
+
def print_startup_info():
|
| 2419 |
+
"""打印启动信息"""
|
| 2420 |
+
print("="*60)
|
| 2421 |
+
print("Business Gemini OpenAPI 服务 (多账号轮训版)")
|
| 2422 |
+
print("支持图片输入输出 (OpenAI格式)")
|
| 2423 |
+
print("="*60)
|
| 2424 |
+
|
| 2425 |
+
# 检测配置文件是否存在,不存在则从 .example 复制初始化
|
| 2426 |
+
example_file = Path(__file__).parent / "business_gemini_session.json.example"
|
| 2427 |
+
if not CONFIG_FILE.exists():
|
| 2428 |
+
if example_file.exists():
|
| 2429 |
+
shutil.copy(example_file, CONFIG_FILE)
|
| 2430 |
+
print(f"\n[初始化] 配置文件不存在,已从 {example_file.name} 复制创建")
|
| 2431 |
+
else:
|
| 2432 |
+
print(f"\n[警告] 配置文件和示例文件都不存在,请创建 {CONFIG_FILE.name}")
|
| 2433 |
+
|
| 2434 |
+
# 加载配置
|
| 2435 |
+
account_manager.load_config()
|
| 2436 |
+
get_admin_secret_key()
|
| 2437 |
+
|
| 2438 |
+
# 代理信息
|
| 2439 |
+
proxy = account_manager.config.get("proxy")
|
| 2440 |
+
print(f"\n[代理配置]")
|
| 2441 |
+
print(f" 地址: {proxy or '未配置'}")
|
| 2442 |
+
if proxy:
|
| 2443 |
+
proxy_available = check_proxy(proxy)
|
| 2444 |
+
print(f" 状态: {'✓ 可用' if proxy_available else '✗ 不可用'}")
|
| 2445 |
+
|
| 2446 |
+
# 图片缓存信息
|
| 2447 |
+
print(f"\n[图片缓存]")
|
| 2448 |
+
print(f" 目录: {IMAGE_CACHE_DIR}")
|
| 2449 |
+
print(f" 缓存时间: {IMAGE_CACHE_HOURS} 小时")
|
| 2450 |
+
|
| 2451 |
+
# 账号信息
|
| 2452 |
+
total, available = account_manager.get_account_count()
|
| 2453 |
+
print(f"\n[账号配置]")
|
| 2454 |
+
print(f" 总数量: {total}")
|
| 2455 |
+
print(f" 可用数量: {available}")
|
| 2456 |
+
|
| 2457 |
+
for i, acc in enumerate(account_manager.accounts):
|
| 2458 |
+
state = account_manager.account_states.get(i, {})
|
| 2459 |
+
is_available = account_manager.is_account_available(i)
|
| 2460 |
+
status = "✓" if is_available else "✗"
|
| 2461 |
+
team_id = acc.get("team_id", "未知") + "..."
|
| 2462 |
+
cooldown_until = state.get("cooldown_until")
|
| 2463 |
+
extra = ""
|
| 2464 |
+
if cooldown_until and cooldown_until > time.time():
|
| 2465 |
+
remaining = int(cooldown_until - time.time())
|
| 2466 |
+
extra = f" (冷却中 ~{remaining}s)"
|
| 2467 |
+
print(f" [{i}] {status} team_id: {team_id}{extra}")
|
| 2468 |
+
|
| 2469 |
+
# 模型信息
|
| 2470 |
+
models = account_manager.config.get("models", [])
|
| 2471 |
+
print(f"\n[模型配置]")
|
| 2472 |
+
if models:
|
| 2473 |
+
for model in models:
|
| 2474 |
+
print(f" - {model.get('id')}: {model.get('name', '')}")
|
| 2475 |
+
else:
|
| 2476 |
+
print(" - gemini-enterprise (默认)")
|
| 2477 |
+
|
| 2478 |
+
print(f"\n[接口列表]")
|
| 2479 |
+
print(" GET /v1/models - 获取模型列表")
|
| 2480 |
+
print(" POST /v1/chat/completions - 聊天对话 (支持图片)")
|
| 2481 |
+
print(" GET /v1/status - 系统状态")
|
| 2482 |
+
print(" GET /health - 健康检查")
|
| 2483 |
+
print(" GET /image/<filename> - 获取缓存图片")
|
| 2484 |
+
print("\n" + "="*60)
|
| 2485 |
+
print("启动服务...")
|
| 2486 |
+
|
| 2487 |
+
|
| 2488 |
+
if __name__ == '__main__':
|
| 2489 |
+
print_startup_info()
|
| 2490 |
+
|
| 2491 |
+
if not account_manager.accounts:
|
| 2492 |
+
print("[!] 警告: 没有配置任何账号")
|
| 2493 |
+
|
| 2494 |
+
# 支持Hugging Face Spaces的端口配置
|
| 2495 |
+
port = int(os.environ.get("PORT", 8000))
|
| 2496 |
+
host = os.environ.get("HOST", "0.0.0.0")
|
| 2497 |
+
|
| 2498 |
+
app.run(host=host, port=port, debug=False)
|
hf_manual_deploy.md
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Hugging Face 手动部署指南
|
| 2 |
+
|
| 3 |
+
由于网络连接问题,请按照以下步骤手动部署到 Hugging Face Spaces:
|
| 4 |
+
|
| 5 |
+
## 方法 1: 通过 Web 界面上传文件(推荐)
|
| 6 |
+
|
| 7 |
+
1. **访问你的 Space**
|
| 8 |
+
- 打开浏览器访问: https://huggingface.co/spaces/Maynor996/gg2
|
| 9 |
+
|
| 10 |
+
2. **上传文件**
|
| 11 |
+
- 点击页面上的 "Files" 标签
|
| 12 |
+
- 点击 "Upload file" 按钮
|
| 13 |
+
- 上传以下文件(从 `/Users/chinamanor/Downloads/cursor编程/gg2/` 目录):
|
| 14 |
+
- `app.py`
|
| 15 |
+
- `gemini.py`
|
| 16 |
+
- `index.html`
|
| 17 |
+
- `requirements.txt`(使用 requirements-hf.txt 的内容)
|
| 18 |
+
- `README.md`(使用 README_hf.md 的内容)
|
| 19 |
+
- `business_gemini_session.json.example`
|
| 20 |
+
|
| 21 |
+
3. **文件内容参考**
|
| 22 |
+
- `requirements.txt` 内容:
|
| 23 |
+
```
|
| 24 |
+
flask>=2.0.0
|
| 25 |
+
flask-cors>=3.0.0
|
| 26 |
+
requests>=2.25.0
|
| 27 |
+
urllib3>=1.26.0
|
| 28 |
+
```
|
| 29 |
+
|
| 30 |
+
- `README.md` 开头需要添加:
|
| 31 |
+
```yaml
|
| 32 |
+
---
|
| 33 |
+
title: Business Gemini Pool
|
| 34 |
+
emoji: 🚀
|
| 35 |
+
colorFrom: blue
|
| 36 |
+
colorTo: green
|
| 37 |
+
sdk: gradio
|
| 38 |
+
sdk_version: 4.44.0
|
| 39 |
+
app_file: app.py
|
| 40 |
+
pinned: false
|
| 41 |
+
license: mit
|
| 42 |
+
---
|
| 43 |
+
```
|
| 44 |
+
|
| 45 |
+
## 方法 2: 使用 Git 命令(如果网络允许)
|
| 46 |
+
|
| 47 |
+
```bash
|
| 48 |
+
# 1. 克隆你的 Space
|
| 49 |
+
git clone https://huggingface.co/spaces/Maynor996/gg2
|
| 50 |
+
cd gg2
|
| 51 |
+
|
| 52 |
+
# 2. 复制文件(从项目目录)
|
| 53 |
+
cp /Users/chinamanor/Downloads/cursor编程/gg2/app.py ./
|
| 54 |
+
cp /Users/chinamanor/Downloads/cursor编程/gg2/gemini.py ./
|
| 55 |
+
cp /Users/chinamanor/Downloads/cursor编程/gg2/index.html ./
|
| 56 |
+
cp /Users/chinamanor/Downloads/cursor编程/gg2/requirements-hf.txt ./requirements.txt
|
| 57 |
+
cp /Users/chinamanor/Downloads/cursor编程/gg2/README_hf.md ./README.md
|
| 58 |
+
cp /Users/chinamanor/Downloads/cursor编程/gg2/business_gemini_session.json.example ./
|
| 59 |
+
|
| 60 |
+
# 3. 提交并推送
|
| 61 |
+
git add .
|
| 62 |
+
git commit -m "Deploy Business Gemini Pool"
|
| 63 |
+
git push
|
| 64 |
+
```
|
| 65 |
+
|
| 66 |
+
## 部署后配置
|
| 67 |
+
|
| 68 |
+
部署成功后:
|
| 69 |
+
|
| 70 |
+
1. **访问应用**
|
| 71 |
+
- URL: https://Maynor996-gg2.hf.space
|
| 72 |
+
|
| 73 |
+
2. **配置 Gemini 账号**
|
| 74 |
+
- 在 Web 界面点击"账号管理"
|
| 75 |
+
- 添加你的 Gemini 账号信息:
|
| 76 |
+
- Team ID
|
| 77 |
+
- Secure Cookie
|
| 78 |
+
- Host Cookie
|
| 79 |
+
- Session Index
|
| 80 |
+
- User Agent
|
| 81 |
+
|
| 82 |
+
3. **测试 API**
|
| 83 |
+
```bash
|
| 84 |
+
curl -X POST https://Maynor996-gg2.hf.space/v1/chat/completions \
|
| 85 |
+
-H "Content-Type: application/json" \
|
| 86 |
+
-d '{
|
| 87 |
+
"model": "gemini-enterprise",
|
| 88 |
+
"messages": [{"role": "user", "content": "Hello!"}]
|
| 89 |
+
}'
|
| 90 |
+
```
|
| 91 |
+
|
| 92 |
+
## 注意事项
|
| 93 |
+
|
| 94 |
+
- Hugging Face 会自动构建和部署你的应用
|
| 95 |
+
- 构建过程通常需要 2-5 分钟
|
| 96 |
+
- 可以在 Space 页面查看构建日志
|
| 97 |
+
- 免费的 CPU Space 有一些限制,但足够基本使用
|
index.html
ADDED
|
@@ -0,0 +1,2025 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="zh-CN" data-theme="light">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>Business Gemini Pool 管理控制台</title>
|
| 7 |
+
<style>
|
| 8 |
+
/* [OPTIMIZATION] 1. 全局样式优化与变量调整 */
|
| 9 |
+
:root {
|
| 10 |
+
/* 核心颜色保持不变 */
|
| 11 |
+
--primary: #4285f4;
|
| 12 |
+
--primary-hover: #3367d6;
|
| 13 |
+
--primary-light: rgba(66, 133, 244, 0.1);
|
| 14 |
+
--success: #34a853;
|
| 15 |
+
--success-light: rgba(52, 168, 83, 0.1);
|
| 16 |
+
--danger: #ea4335;
|
| 17 |
+
--danger-light: rgba(234, 67, 53, 0.1);
|
| 18 |
+
--warning: #fbbc04;
|
| 19 |
+
--warning-light: rgba(251, 188, 4, 0.1);
|
| 20 |
+
|
| 21 |
+
/* [NEW] 引入更精细的变量控制 */
|
| 22 |
+
--radius-sm: 6px;
|
| 23 |
+
--radius-md: 12px; /* 增大圆角,更柔和 */
|
| 24 |
+
--radius-lg: 16px;
|
| 25 |
+
--transition-ease: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); /* [NEW] 现代化的缓动函数 */
|
| 26 |
+
|
| 27 |
+
--font-main: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; /* [NEW] 引入更适合UI的字体 */
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
/* [OPTIMIZATION] 2. Light & Dark Theme 优化,增强对比度和质感 */
|
| 31 |
+
[data-theme="light"] {
|
| 32 |
+
--bg-color: #f7f8fc; /* 更柔和的背景色 */
|
| 33 |
+
--card-bg: #ffffff;
|
| 34 |
+
--text-main: #1f2328;
|
| 35 |
+
--text-muted: #656d76;
|
| 36 |
+
--border: #e4e7eb; /* 更浅的边框色 */
|
| 37 |
+
--hover-bg: #f2f3f5;
|
| 38 |
+
--input-bg: #ffffff;
|
| 39 |
+
--shadow-sm: 0 1px 2px 0 rgba(27, 31, 35, 0.04);
|
| 40 |
+
--shadow-md: 0 4px 8px 0 rgba(27, 31, 35, 0.06), 0 1px 2px 0 rgba(27, 31, 35, 0.05); /* 更柔和的阴影 */
|
| 41 |
+
--shadow-lg: 0 10px 20px 0 rgba(27, 31, 35, 0.07), 0 3px 6px 0 rgba(27, 31, 35, 0.05);
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
[data-theme="dark"] {
|
| 45 |
+
--bg-color: #1a1b1e;
|
| 46 |
+
--card-bg: #242528;
|
| 47 |
+
--text-main: #e8eaed;
|
| 48 |
+
--text-muted: #9aa0a6;
|
| 49 |
+
--border: #3a3c40;
|
| 50 |
+
--hover-bg: #303134;
|
| 51 |
+
--input-bg: #2f3033;
|
| 52 |
+
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.1);
|
| 53 |
+
--shadow-md: 0 4px 8px 0 rgba(0, 0, 0, 0.15), 0 1px 2px 0 rgba(0, 0, 0, 0.1);
|
| 54 |
+
--shadow-lg: 0 10px 20px 0 rgba(0, 0, 0, 0.2), 0 3px 6px 0 rgba(0, 0, 0, 0.15);
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
* {
|
| 58 |
+
margin: 0;
|
| 59 |
+
padding: 0;
|
| 60 |
+
box-sizing: border-box;
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
body {
|
| 64 |
+
font-family: var(--font-main);
|
| 65 |
+
background-color: var(--bg-color);
|
| 66 |
+
color: var(--text-main);
|
| 67 |
+
min-height: 100vh;
|
| 68 |
+
transition: background-color 0.3s, color 0.3s;
|
| 69 |
+
-webkit-font-smoothing: antialiased;
|
| 70 |
+
-moz-osx-font-smoothing: grayscale;
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
.container {
|
| 74 |
+
max-width: 1400px;
|
| 75 |
+
margin: 0 auto;
|
| 76 |
+
padding: 32px; /* 增加页面内边距 */
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
/* [OPTIMIZATION] 3. Header 重新设计,更简洁大气 */
|
| 80 |
+
.header {
|
| 81 |
+
display: flex;
|
| 82 |
+
justify-content: space-between;
|
| 83 |
+
align-items: center;
|
| 84 |
+
margin-bottom: 32px;
|
| 85 |
+
/* 移除背景和阴影,使其融入页面 */
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
.header-left { display: flex; align-items: center; gap: 16px; }
|
| 89 |
+
|
| 90 |
+
.logo {
|
| 91 |
+
width: 44px;
|
| 92 |
+
height: 44px;
|
| 93 |
+
background: linear-gradient(135deg, #4285f4, #34a853, #fbbc04, #ea4335);
|
| 94 |
+
border-radius: var(--radius-md);
|
| 95 |
+
display: flex;
|
| 96 |
+
align-items: center;
|
| 97 |
+
justify-content: center;
|
| 98 |
+
color: white;
|
| 99 |
+
font-weight: 600;
|
| 100 |
+
font-size: 22px;
|
| 101 |
+
transform: rotate(-10deg); /* [NEW] 增加一点趣味性 */
|
| 102 |
+
transition: var(--transition-ease);
|
| 103 |
+
}
|
| 104 |
+
.logo:hover { transform: rotate(0deg) scale(1.05); }
|
| 105 |
+
|
| 106 |
+
.header h1 {
|
| 107 |
+
font-size: 26px; /* 增大标题字号 */
|
| 108 |
+
font-weight: 600;
|
| 109 |
+
color: var(--text-main);
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
.header h1 span {
|
| 113 |
+
color: var(--text-muted);
|
| 114 |
+
font-weight: 400;
|
| 115 |
+
font-size: 16px;
|
| 116 |
+
margin-left: 10px;
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
.header-right { display: flex; align-items: center; gap: 16px; }
|
| 120 |
+
|
| 121 |
+
.status-indicator {
|
| 122 |
+
display: flex;
|
| 123 |
+
align-items: center;
|
| 124 |
+
gap: 8px;
|
| 125 |
+
padding: 8px 16px;
|
| 126 |
+
background: var(--success-light);
|
| 127 |
+
border: 1px solid rgba(52, 168, 83, 0.2);
|
| 128 |
+
border-radius: 50px; /* 改为胶囊形状 */
|
| 129 |
+
font-size: 14px;
|
| 130 |
+
color: var(--success);
|
| 131 |
+
font-weight: 500;
|
| 132 |
+
}
|
| 133 |
+
.status-indicator::before {
|
| 134 |
+
content: ''; width: 8px; height: 8px;
|
| 135 |
+
background: var(--success); border-radius: 50%;
|
| 136 |
+
animation: pulse 2s infinite;
|
| 137 |
+
}
|
| 138 |
+
@keyframes pulse { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.7; transform: scale(0.9); } }
|
| 139 |
+
|
| 140 |
+
.theme-toggle {
|
| 141 |
+
width: 44px; height: 44px; border: 1px solid var(--border);
|
| 142 |
+
background: var(--card-bg); border-radius: var(--radius-md);
|
| 143 |
+
cursor: pointer; display: flex; align-items: center; justify-content: center;
|
| 144 |
+
font-size: 20px; transition: var(--transition-ease);
|
| 145 |
+
}
|
| 146 |
+
.theme-toggle:hover { background: var(--hover-bg); border-color: var(--primary); transform: translateY(-2px); }
|
| 147 |
+
|
| 148 |
+
/* [OPTIMIZATION] 4. Tabs 重新设计,更现代、更 subtle */
|
| 149 |
+
.tabs {
|
| 150 |
+
display: flex;
|
| 151 |
+
gap: 16px;
|
| 152 |
+
border-bottom: 1px solid var(--border); /* 底部线条导航 */
|
| 153 |
+
margin-bottom: 32px;
|
| 154 |
+
}
|
| 155 |
+
.tab {
|
| 156 |
+
padding: 14px 4px; /* 减少水平padding,通过gap控制间距 */
|
| 157 |
+
border: none; border-bottom: 2px solid transparent;
|
| 158 |
+
background: transparent; color: var(--text-muted);
|
| 159 |
+
font-size: 15px; font-weight: 500;
|
| 160 |
+
cursor: pointer; border-radius: 0;
|
| 161 |
+
transition: var(--transition-ease);
|
| 162 |
+
display: flex; align-items: center; justify-content: center;
|
| 163 |
+
gap: 8px;
|
| 164 |
+
}
|
| 165 |
+
.tab:hover { color: var(--primary); }
|
| 166 |
+
.tab.active { color: var(--primary); border-bottom-color: var(--primary); }
|
| 167 |
+
.tab-icon { font-size: 20px; }
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
/* Status Badge */
|
| 171 |
+
.badge {
|
| 172 |
+
display: inline-flex;
|
| 173 |
+
align-items: center;
|
| 174 |
+
gap: 6px;
|
| 175 |
+
padding: 6px 12px;
|
| 176 |
+
border-radius: 20px;
|
| 177 |
+
font-size: 12px;
|
| 178 |
+
font-weight: 500;
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
.badge::before {
|
| 182 |
+
content: '';
|
| 183 |
+
width: 6px;
|
| 184 |
+
height: 6px;
|
| 185 |
+
border-radius: 50%;
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
.badge-success {
|
| 189 |
+
background: var(--success-light);
|
| 190 |
+
color: var(--success);
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
.badge-success::before {
|
| 194 |
+
background: var(--success);
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
.badge-danger {
|
| 198 |
+
background: var(--danger-light);
|
| 199 |
+
color: var(--danger);
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
.badge-danger::before {
|
| 203 |
+
background: var(--danger);
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
.cooldown-hint {
|
| 207 |
+
display: block;
|
| 208 |
+
color: var(--text-muted);
|
| 209 |
+
font-size: 12px;
|
| 210 |
+
margin-top: 4px;
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
.log-level-control {
|
| 214 |
+
display: flex;
|
| 215 |
+
align-items: center;
|
| 216 |
+
gap: 8px;
|
| 217 |
+
background: var(--card-bg);
|
| 218 |
+
border: 1px solid var(--border);
|
| 219 |
+
border-radius: var(--radius-md);
|
| 220 |
+
padding: 6px 10px;
|
| 221 |
+
}
|
| 222 |
+
.log-level-control label {
|
| 223 |
+
font-size: 12px;
|
| 224 |
+
color: var(--text-muted);
|
| 225 |
+
}
|
| 226 |
+
.log-level-select {
|
| 227 |
+
border: 1px solid var(--border);
|
| 228 |
+
background: var(--input-bg);
|
| 229 |
+
color: var(--text-main);
|
| 230 |
+
border-radius: var(--radius-sm);
|
| 231 |
+
padding: 6px 8px;
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
.token-actions {
|
| 235 |
+
display: flex;
|
| 236 |
+
gap: 8px;
|
| 237 |
+
flex-wrap: wrap;
|
| 238 |
+
margin-bottom: 12px;
|
| 239 |
+
}
|
| 240 |
+
.token-input {
|
| 241 |
+
flex: 1;
|
| 242 |
+
min-width: 240px;
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
.badge-warning {
|
| 246 |
+
background: var(--warning-light);
|
| 247 |
+
color: #b06000;
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
.badge-warning::before {
|
| 251 |
+
background: var(--warning);
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
/* [OPTIMIZATION] 5. 动画效果增强 */
|
| 255 |
+
.tab-content { display: none; }
|
| 256 |
+
.tab-content.active { display: block; animation: contentFadeIn 0.5s cubic-bezier(0.4, 0, 0.2, 1) forwards; }
|
| 257 |
+
@keyframes contentFadeIn { from { opacity: 0; transform: translateY(15px); } to { opacity: 1; transform: translateY(0); } }
|
| 258 |
+
|
| 259 |
+
/* [OPTIMIZATION] 6. Card 样式优化 */
|
| 260 |
+
.card {
|
| 261 |
+
background: var(--card-bg);
|
| 262 |
+
border-radius: var(--radius-lg);
|
| 263 |
+
box-shadow: var(--shadow-md);
|
| 264 |
+
border: 1px solid var(--border);
|
| 265 |
+
margin-bottom: 32px;
|
| 266 |
+
overflow: hidden;
|
| 267 |
+
transition: var(--transition-ease);
|
| 268 |
+
}
|
| 269 |
+
.card:hover { border-color: var(--primary-light); box-shadow: var(--shadow-lg); }
|
| 270 |
+
|
| 271 |
+
.card-header {
|
| 272 |
+
display: flex; justify-content: space-between; align-items: center;
|
| 273 |
+
padding: 20px 24px; border-bottom: 1px solid var(--border);
|
| 274 |
+
}
|
| 275 |
+
.card-title {
|
| 276 |
+
font-size: 18px; font-weight: 600; color: var(--text-main);
|
| 277 |
+
display: flex; align-items: center; gap: 12px;
|
| 278 |
+
}
|
| 279 |
+
.card-title-icon { font-size: 22px; color: var(--text-muted); }
|
| 280 |
+
.card-body { padding: 24px; }
|
| 281 |
+
|
| 282 |
+
/* [OPTIMIZATION] 7. Button 样式优化 */
|
| 283 |
+
.btn {
|
| 284 |
+
padding: 10px 20px; border: none; border-radius: var(--radius-md);
|
| 285 |
+
cursor: pointer; font-size: 14px; font-weight: 500;
|
| 286 |
+
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
|
| 287 |
+
transition: var(--transition-ease); text-decoration: none;
|
| 288 |
+
}
|
| 289 |
+
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
| 290 |
+
.btn:hover:not(:disabled) { transform: translateY(-2px); box-shadow: var(--shadow-md); }
|
| 291 |
+
.btn-primary { background: var(--primary); color: white; }
|
| 292 |
+
.btn-primary:hover:not(:disabled) { background: var(--primary-hover); }
|
| 293 |
+
|
| 294 |
+
.btn-outline {
|
| 295 |
+
background: transparent; color: var(--text-muted);
|
| 296 |
+
border: 1px solid var(--border);
|
| 297 |
+
}
|
| 298 |
+
.btn-outline:hover:not(:disabled) { border-color: var(--text-main); color: var(--text-main); }
|
| 299 |
+
/* 其他按钮颜色保持 */
|
| 300 |
+
.btn-success { background: var(--success-light); color: var(--success); border: 1px solid rgba(52, 168, 83, 0.2); }
|
| 301 |
+
.btn-success:hover:not(:disabled) { background: var(--success); color: white; border-color: var(--success); }
|
| 302 |
+
.btn-danger { background: var(--danger-light); color: var(--danger); border: 1px solid rgba(234, 67, 53, 0.2); }
|
| 303 |
+
.btn-danger:hover:not(:disabled) { background: var(--danger); color: white; border-color: var(--danger); }
|
| 304 |
+
|
| 305 |
+
.btn-sm { padding: 6px 14px; font-size: 13px; border-radius: var(--radius-sm); }
|
| 306 |
+
.btn-icon { width: 32px; height: 32px; padding: 0; border-radius: var(--radius-md); display: inline-flex; align-items: center; justify-content: center; vertical-align: middle; }
|
| 307 |
+
.btn-warning { background: #fff3cd; color: #856404; border: 1px solid rgba(133, 100, 4, 0.2); }
|
| 308 |
+
.btn-warning:hover:not(:disabled) { background: #ffc107; color: #212529; border-color: #ffc107; }
|
| 309 |
+
|
| 310 |
+
/* [OPTIMIZATION] 8. Table 样式优化,增强可读性 */
|
| 311 |
+
.table-container { overflow-x: auto; }
|
| 312 |
+
table { width: 100%; border-collapse: collapse; }
|
| 313 |
+
th {
|
| 314 |
+
text-align: left; padding: 16px 24px; font-size: 13px;
|
| 315 |
+
font-weight: 500; color: var(--text-muted); text-transform: uppercase;
|
| 316 |
+
letter-spacing: 0.5px; background: transparent; /* 移除背景色 */
|
| 317 |
+
border-bottom: 2px solid var(--border); /* 加粗底部边框 */
|
| 318 |
+
}
|
| 319 |
+
td {
|
| 320 |
+
padding: 18px 24px; border-bottom: 1px solid var(--border);
|
| 321 |
+
font-size: 14px; color: var(--text-main);
|
| 322 |
+
transition: background-color 0.2s;
|
| 323 |
+
}
|
| 324 |
+
tr:last-child td { border-bottom: none; }
|
| 325 |
+
tr:hover td { background: var(--hover-bg); }
|
| 326 |
+
|
| 327 |
+
/* [OPTIMIZATION] 9. Form 样式优化 */
|
| 328 |
+
.form-group {
|
| 329 |
+
display: flex;
|
| 330 |
+
flex-direction: column;
|
| 331 |
+
margin-bottom: 20px;
|
| 332 |
+
}
|
| 333 |
+
.form-group label,
|
| 334 |
+
.form-label {
|
| 335 |
+
display: block;
|
| 336 |
+
margin-bottom: 12px;
|
| 337 |
+
font-size: 14px;
|
| 338 |
+
font-weight: 600;
|
| 339 |
+
color: var(--text-main);
|
| 340 |
+
letter-spacing: 0.2px;
|
| 341 |
+
}
|
| 342 |
+
.form-group input, .form-group textarea, .form-group select,
|
| 343 |
+
.form-input,
|
| 344 |
+
.form-textarea {
|
| 345 |
+
width: 100%;
|
| 346 |
+
padding: 14px 16px;
|
| 347 |
+
border-radius: var(--radius-md);
|
| 348 |
+
border: 1px solid var(--border);
|
| 349 |
+
background: var(--bg);
|
| 350 |
+
color: var(--text-main);
|
| 351 |
+
font-size: 14px;
|
| 352 |
+
transition: var(--transition-ease);
|
| 353 |
+
box-sizing: border-box;
|
| 354 |
+
line-height: 1.5;
|
| 355 |
+
}
|
| 356 |
+
.form-textarea {
|
| 357 |
+
min-height: 90px;
|
| 358 |
+
resize: vertical;
|
| 359 |
+
font-family: inherit;
|
| 360 |
+
}
|
| 361 |
+
.form-group input:focus, .form-group textarea:focus, .form-group select:focus {
|
| 362 |
+
outline: none;
|
| 363 |
+
border-color: var(--primary);
|
| 364 |
+
box-shadow: 0 0 0 3px var(--primary-light), 0 1px 2px rgba(0,0,0,0.05) inset;
|
| 365 |
+
}
|
| 366 |
+
.form-group input:disabled {
|
| 367 |
+
background: var(--hover-bg);
|
| 368 |
+
color: var(--text-muted);
|
| 369 |
+
cursor: not-allowed;
|
| 370 |
+
}
|
| 371 |
+
.form-group small {
|
| 372 |
+
display: block;
|
| 373 |
+
margin-top: 6px;
|
| 374 |
+
font-size: 13px;
|
| 375 |
+
color: var(--text-muted);
|
| 376 |
+
}
|
| 377 |
+
.form-row {
|
| 378 |
+
display: grid;
|
| 379 |
+
grid-template-columns: 1fr 1fr;
|
| 380 |
+
gap: 24px;
|
| 381 |
+
}
|
| 382 |
+
|
| 383 |
+
/* Settings Section 样式 */
|
| 384 |
+
.settings-section {
|
| 385 |
+
background: var(--bg);
|
| 386 |
+
border: 1px solid var(--border);
|
| 387 |
+
border-radius: var(--radius-lg);
|
| 388 |
+
padding: 28px;
|
| 389 |
+
margin-bottom: 28px;
|
| 390 |
+
}
|
| 391 |
+
.settings-section:last-child {
|
| 392 |
+
margin-bottom: 0;
|
| 393 |
+
}
|
| 394 |
+
.settings-section h3 {
|
| 395 |
+
font-size: 17px;
|
| 396 |
+
font-weight: 600;
|
| 397 |
+
color: var(--text-main);
|
| 398 |
+
margin-bottom: 24px;
|
| 399 |
+
padding-bottom: 16px;
|
| 400 |
+
border-bottom: 1px solid var(--border);
|
| 401 |
+
display: flex;
|
| 402 |
+
align-items: center;
|
| 403 |
+
}
|
| 404 |
+
.settings-section .form-group {
|
| 405 |
+
margin-bottom: 24px;
|
| 406 |
+
}
|
| 407 |
+
.settings-section .form-group:last-of-type {
|
| 408 |
+
margin-bottom: 20px;
|
| 409 |
+
}
|
| 410 |
+
|
| 411 |
+
/* [OPTIMIZATION] 10. Modal 动画与样式优化 */
|
| 412 |
+
.modal {
|
| 413 |
+
display: flex; /* 改为flex,便于控制 */
|
| 414 |
+
align-items: center; justify-content: center;
|
| 415 |
+
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
|
| 416 |
+
background: rgba(0, 0, 0, 0.4); backdrop-filter: blur(5px);
|
| 417 |
+
z-index: 1000; opacity: 0; visibility: hidden;
|
| 418 |
+
transition: opacity 0.3s, visibility 0.3s;
|
| 419 |
+
}
|
| 420 |
+
.modal.show { opacity: 1; visibility: visible; }
|
| 421 |
+
.modal-content {
|
| 422 |
+
background: var(--card-bg); border-radius: var(--radius-lg);
|
| 423 |
+
width: 600px; max-width: 90vw; max-height: 90vh;
|
| 424 |
+
overflow-y: auto; box-shadow: var(--shadow-lg);
|
| 425 |
+
transform: translateY(20px) scale(0.98);
|
| 426 |
+
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
| 427 |
+
}
|
| 428 |
+
.modal.show .modal-content { transform: translateY(0) scale(1); }
|
| 429 |
+
.modal-header { padding: 24px; border-bottom: 1px solid var(--border); }
|
| 430 |
+
.modal-header h3 { font-size: 20px; font-weight: 600; display: inline-block; }
|
| 431 |
+
.modal-close {
|
| 432 |
+
width: 36px; height: 36px; border: none; background: transparent;
|
| 433 |
+
color: var(--text-muted); cursor: pointer; border-radius: 50%;
|
| 434 |
+
display: flex; align-items: center; justify-content: center;
|
| 435 |
+
font-size: 22px; transition: var(--transition-ease);
|
| 436 |
+
float: right;
|
| 437 |
+
}
|
| 438 |
+
.modal-close:hover { background: var(--hover-bg); color: var(--text-main); transform: rotate(90deg); }
|
| 439 |
+
.modal-body { padding: 24px; }
|
| 440 |
+
.modal-footer {
|
| 441 |
+
display: flex; justify-content: flex-end; gap: 12px;
|
| 442 |
+
padding: 20px 24px; border-top: 1px solid var(--border);
|
| 443 |
+
background: var(--hover-bg);
|
| 444 |
+
border-bottom-left-radius: var(--radius-lg);
|
| 445 |
+
border-bottom-right-radius: var(--radius-lg);
|
| 446 |
+
}
|
| 447 |
+
|
| 448 |
+
|
| 449 |
+
/* [OPTIMIZATION] 11. Stats Card 优化 */
|
| 450 |
+
.stats-grid {
|
| 451 |
+
display: grid;
|
| 452 |
+
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
| 453 |
+
gap: 24px;
|
| 454 |
+
margin-bottom: 32px;
|
| 455 |
+
}
|
| 456 |
+
.stat-card {
|
| 457 |
+
background: var(--card-bg); border: 1px solid var(--border);
|
| 458 |
+
border-radius: var(--radius-lg); padding: 24px;
|
| 459 |
+
display: flex; flex-direction: column; /* 垂直布局 */
|
| 460 |
+
align-items: flex-start; gap: 16px;
|
| 461 |
+
transition: var(--transition-ease);
|
| 462 |
+
/* [NEW] 入场动画 */
|
| 463 |
+
opacity: 0;
|
| 464 |
+
transform: translateY(20px);
|
| 465 |
+
animation: fadeIn-up 0.5s ease-out forwards;
|
| 466 |
+
}
|
| 467 |
+
/* [NEW] Staggered Animation for Stats Cards */
|
| 468 |
+
.stat-card:nth-child(1) { animation-delay: 0.1s; }
|
| 469 |
+
.stat-card:nth-child(2) { animation-delay: 0.2s; }
|
| 470 |
+
.stat-card:nth-child(3) { animation-delay: 0.3s; }
|
| 471 |
+
.stat-card:nth-child(4) { animation-delay: 0.4s; }
|
| 472 |
+
|
| 473 |
+
@keyframes fadeIn-up {
|
| 474 |
+
to {
|
| 475 |
+
opacity: 1;
|
| 476 |
+
transform: translateY(0);
|
| 477 |
+
}
|
| 478 |
+
}
|
| 479 |
+
.stat-card:hover { transform: translateY(-5px); box-shadow: var(--shadow-md); border-color: var(--primary); }
|
| 480 |
+
|
| 481 |
+
.stat-info-top { display: flex; justify-content: space-between; align-items: center; width: 100%; }
|
| 482 |
+
.stat-info-top p { font-size: 14px; font-weight: 500; color: var(--text-muted); }
|
| 483 |
+
|
| 484 |
+
.stat-icon {
|
| 485 |
+
width: 40px; height: 40px; border-radius: var(--radius-md);
|
| 486 |
+
display: flex; align-items: center; justify-content: center; font-size: 20px;
|
| 487 |
+
}
|
| 488 |
+
|
| 489 |
+
.stat-info-bottom h3 { font-size: 32px; font-weight: 600; color: var(--text-main); }
|
| 490 |
+
.stat-icon.blue { background: var(--primary-light); color: var(--primary); }
|
| 491 |
+
.stat-icon.green { background: var(--success-light); color: var(--success); }
|
| 492 |
+
.stat-icon.red { background: var(--danger-light); color: var(--danger); }
|
| 493 |
+
.stat-icon.yellow { background: var(--warning-light); color: #b06000; }
|
| 494 |
+
|
| 495 |
+
/* 其他样式保持或微调 */
|
| 496 |
+
.badge {
|
| 497 |
+
padding: 5px 12px; border-radius: 50px;
|
| 498 |
+
font-size: 12px; font-weight: 500;
|
| 499 |
+
}
|
| 500 |
+
.empty-state { text-align: center; padding: 80px 20px; color: var(--text-muted); }
|
| 501 |
+
.empty-state-icon { font-size: 56px; margin-bottom: 20px; opacity: 0.4; }
|
| 502 |
+
|
| 503 |
+
.toast {
|
| 504 |
+
position: fixed; bottom: 32px; left: 50%;
|
| 505 |
+
transform: translateX(-50%) translateY(100px);
|
| 506 |
+
background: var(--card-bg); border: 1px solid var(--border);
|
| 507 |
+
border-radius: var(--radius-md); padding: 16px 24px;
|
| 508 |
+
box-shadow: var(--shadow-lg); min-width: 320px;
|
| 509 |
+
z-index: 2000; opacity: 0; visibility: hidden;
|
| 510 |
+
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
| 511 |
+
display: flex; align-items: center; gap: 12px;
|
| 512 |
+
}
|
| 513 |
+
.toast.show { transform: translateX(-50%) translateY(0); opacity: 1; visibility: visible; }
|
| 514 |
+
|
| 515 |
+
/* [NEW] SVG Icon Styles */
|
| 516 |
+
.icon {
|
| 517 |
+
width: 1em;
|
| 518 |
+
height: 1em;
|
| 519 |
+
stroke-width: 2;
|
| 520 |
+
fill: none;
|
| 521 |
+
stroke: currentColor;
|
| 522 |
+
stroke-linecap: round;
|
| 523 |
+
stroke-linejoin: round;
|
| 524 |
+
}
|
| 525 |
+
|
| 526 |
+
/* Responsive */
|
| 527 |
+
@media (max-width: 768px) {
|
| 528 |
+
.container { padding: 24px 16px; }
|
| 529 |
+
.header { flex-direction: column; gap: 24px; text-align: center; }
|
| 530 |
+
.tabs {
|
| 531 |
+
gap: 8px;
|
| 532 |
+
/* [NEW] 允许在移动端横向滚动 */
|
| 533 |
+
overflow-x: auto;
|
| 534 |
+
white-space: nowrap;
|
| 535 |
+
-ms-overflow-style: none; /* IE and Edge */
|
| 536 |
+
scrollbar-width: none; /* Firefox */
|
| 537 |
+
}
|
| 538 |
+
.tabs::-webkit-scrollbar { display: none; } /* Chrome, Safari, and Opera */
|
| 539 |
+
.tab { flex-shrink: 0; }
|
| 540 |
+
.form-row { grid-template-columns: 1fr; }
|
| 541 |
+
.stats-grid { gap: 16px; }
|
| 542 |
+
}
|
| 543 |
+
</style>
|
| 544 |
+
</head>
|
| 545 |
+
<body>
|
| 546 |
+
<!-- [NEW] SVG Icon Definitions -->
|
| 547 |
+
<svg width="0" height="0" style="display: none;">
|
| 548 |
+
<symbol id="icon-users" viewBox="0 0 24 24"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></symbol>
|
| 549 |
+
<symbol id="icon-robot" viewBox="0 0 24 24"><path d="M12 8V4H8"></path><rect x="4" y="12" width="16" height="8" rx="2"></rect><path d="M2 12h20"></path><path d="M12 12V8a4 4 0 0 0-4-4"></path></symbol>
|
| 550 |
+
<symbol id="icon-settings" viewBox="0 0 24 24"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 0 2l-.15.08a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l-.22-.38a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1 0-2l.15-.08a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"></path><circle cx="12" cy="12" r="3"></circle></symbol>
|
| 551 |
+
<symbol id="icon-server" viewBox="0 0 24 24"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"></rect><rect x="2" y="14" width="20" height="8" rx="2" ry="2"></rect><line x1="6" y1="6" x2="6.01" y2="6"></line><line x1="6" y1="18" x2="6.01" y2="18"></line></symbol>
|
| 552 |
+
<symbol id="icon-list" viewBox="0 0 24 24"><line x1="8" y1="6" x2="21" y2="6"></line><line x1="8" y1="12" x2="21" y2="12"></line><line x1="8" y1="18" x2="21" y2="18"></line><line x1="3" y1="6" x2="3.01" y2="6"></line><line x1="3" y1="12" x2="3.01" y2="12"></line><line x1="3" y1="18" x2="3.01" y2="18"></line></symbol>
|
| 553 |
+
<symbol id="icon-plus" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></symbol>
|
| 554 |
+
<symbol id="icon-check" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"></polyline></symbol>
|
| 555 |
+
<symbol id="icon-x" viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></symbol>
|
| 556 |
+
<symbol id="icon-refresh" viewBox="0 0 24 24"><polyline points="23 4 23 10 17 10"></polyline><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"></path></symbol>
|
| 557 |
+
<symbol id="icon-sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="5"></circle><line x1="12" y1="1" x2="12" y2="3"></line><line x1="12" y1="21" x2="12" y2="23"></line><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line><line x1="1" y1="12" x2="3" y2="12"></line><line x1="21" y1="12" x2="23" y2="12"></line><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line></symbol>
|
| 558 |
+
<symbol id="icon-moon" viewBox="0 0 24 24"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path></symbol>
|
| 559 |
+
<symbol id="icon-message" viewBox="0 0 24 24"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path></symbol>
|
| 560 |
+
<symbol id="icon-play" viewBox="0 0 24 24"><polygon points="5 3 19 12 5 21 5 3"></polygon></symbol>
|
| 561 |
+
<symbol id="icon-pause" viewBox="0 0 24 24"><rect x="6" y="4" width="4" height="16"></rect><rect x="14" y="4" width="4" height="16"></rect></symbol>
|
| 562 |
+
<symbol id="icon-zap" viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></symbol>
|
| 563 |
+
<symbol id="icon-key" viewBox="0 0 24 24"><path d="M21 2l-2 2"></path><path d="M9 6l-2 2"></path><circle cx="7.5" cy="15.5" r="5.5"></circle><path d="M21 2l-9.6 9.6"></path><path d="M15.5 7.5l3 3"></path><path d="M16 13l-3-3"></path></symbol>
|
| 564 |
+
</svg>
|
| 565 |
+
|
| 566 |
+
<div class="container">
|
| 567 |
+
<!-- Header -->
|
| 568 |
+
<header class="header">
|
| 569 |
+
<div class="header-left">
|
| 570 |
+
<div class="logo">G</div>
|
| 571 |
+
<h1>Business Gemini Pool <span>管理控制台</span></h1>
|
| 572 |
+
</div>
|
| 573 |
+
<div class="header-right">
|
| 574 |
+
<div class="status-indicator" id="serviceStatus">服务运行中</div>
|
| 575 |
+
<div class="log-level-control">
|
| 576 |
+
<label for="logLevelSelect">日志</label>
|
| 577 |
+
<select id="logLevelSelect" class="log-level-select" onchange="updateLogLevel(this.value)">
|
| 578 |
+
<option value="DEBUG">DEBUG</option>
|
| 579 |
+
<option value="INFO" selected>INFO</option>
|
| 580 |
+
<option value="ERROR">ERROR</option>
|
| 581 |
+
</select>
|
| 582 |
+
</div>
|
| 583 |
+
<button class="btn btn-outline" id="loginButton" style="padding: 8px 12px;" onclick="showLoginModal()">登录</button>
|
| 584 |
+
<a href="chat_history.html" class="btn btn-primary" style="padding: 8px 16px; font-size: 14px; text-decoration: none; display: flex; align-items: center; gap: 6px;" title="进入在线对话">
|
| 585 |
+
<svg class="icon" style="width: 16px; height: 16px;"><use xlink:href="#icon-message"></use></svg>
|
| 586 |
+
在线对话
|
| 587 |
+
</a>
|
| 588 |
+
<button class="theme-toggle" onclick="toggleTheme()" title="切换主题">
|
| 589 |
+
<span id="themeIconContainer">
|
| 590 |
+
<svg class="icon"><use xlink:href="#icon-sun"></use></svg>
|
| 591 |
+
</span>
|
| 592 |
+
</button>
|
| 593 |
+
</div>
|
| 594 |
+
</header>
|
| 595 |
+
|
| 596 |
+
<!-- Tabs -->
|
| 597 |
+
<div class="tabs">
|
| 598 |
+
<button class="tab active" onclick="switchTab('accounts')">
|
| 599 |
+
<svg class="icon tab-icon"><use xlink:href="#icon-users"></use></svg>
|
| 600 |
+
账号管理
|
| 601 |
+
</button>
|
| 602 |
+
<button class="tab" onclick="switchTab('models')">
|
| 603 |
+
<svg class="icon tab-icon"><use xlink:href="#icon-robot"></use></svg>
|
| 604 |
+
模型管理
|
| 605 |
+
</button>
|
| 606 |
+
<button class="tab" onclick="switchTab('settings')">
|
| 607 |
+
<svg class="icon tab-icon"><use xlink:href="#icon-settings"></use></svg>
|
| 608 |
+
系统设置
|
| 609 |
+
</button>
|
| 610 |
+
<button class="tab" onclick="switchTab('tokens')">
|
| 611 |
+
<svg class="icon tab-icon"><use xlink:href="#icon-key"></use></svg>
|
| 612 |
+
Token 管理
|
| 613 |
+
</button>
|
| 614 |
+
</div>
|
| 615 |
+
|
| 616 |
+
<!-- 账号管理 -->
|
| 617 |
+
<div id="accounts" class="tab-content active">
|
| 618 |
+
<!-- Stats -->
|
| 619 |
+
<div class="stats-grid">
|
| 620 |
+
<div class="stat-card">
|
| 621 |
+
<div class="stat-info-top">
|
| 622 |
+
<p>总账号数</p>
|
| 623 |
+
<div class="stat-icon blue"><svg class="icon"><use xlink:href="#icon-users"></use></svg></div>
|
| 624 |
+
</div>
|
| 625 |
+
<div class="stat-info-bottom">
|
| 626 |
+
<h3 id="totalAccounts">0</h3>
|
| 627 |
+
</div>
|
| 628 |
+
</div>
|
| 629 |
+
<div class="stat-card">
|
| 630 |
+
<div class="stat-info-top">
|
| 631 |
+
<p>可用账号</p>
|
| 632 |
+
<div class="stat-icon green"><svg class="icon"><use xlink:href="#icon-check"></use></svg></div>
|
| 633 |
+
</div>
|
| 634 |
+
<div class="stat-info-bottom">
|
| 635 |
+
<h3 id="availableAccounts">0</h3>
|
| 636 |
+
</div>
|
| 637 |
+
</div>
|
| 638 |
+
<div class="stat-card">
|
| 639 |
+
<div class="stat-info-top">
|
| 640 |
+
<p>不可用账号</p>
|
| 641 |
+
<div class="stat-icon red"><svg class="icon"><use xlink:href="#icon-x"></use></svg></div>
|
| 642 |
+
</div>
|
| 643 |
+
<div class="stat-info-bottom">
|
| 644 |
+
<h3 id="unavailableAccounts">0</h3>
|
| 645 |
+
</div>
|
| 646 |
+
</div>
|
| 647 |
+
<div class="stat-card">
|
| 648 |
+
<div class="stat-info-top">
|
| 649 |
+
<p>当前轮训索引</p>
|
| 650 |
+
<div class="stat-icon yellow"><svg class="icon"><use xlink:href="#icon-refresh"></use></svg></div>
|
| 651 |
+
</div>
|
| 652 |
+
<div class="stat-info-bottom">
|
| 653 |
+
<h3 id="currentIndex">0</h3>
|
| 654 |
+
</div>
|
| 655 |
+
</div>
|
| 656 |
+
</div>
|
| 657 |
+
|
| 658 |
+
<div class="card">
|
| 659 |
+
<div class="card-header">
|
| 660 |
+
<div class="card-title">
|
| 661 |
+
<svg class="icon card-title-icon"><use xlink:href="#icon-list"></use></svg>
|
| 662 |
+
账号列表
|
| 663 |
+
</div>
|
| 664 |
+
<button class="btn btn-primary" onclick="showAddAccountModal()">
|
| 665 |
+
<svg class="icon"><use xlink:href="#icon-plus"></use></svg>
|
| 666 |
+
添加账号
|
| 667 |
+
</button>
|
| 668 |
+
</div>
|
| 669 |
+
<div class="table-container">
|
| 670 |
+
<table id="accountsTable">
|
| 671 |
+
<thead>
|
| 672 |
+
<tr>
|
| 673 |
+
<th>序号</th>
|
| 674 |
+
<th>Team ID</th>
|
| 675 |
+
<th>csesidx</th>
|
| 676 |
+
<th>User Agent</th>
|
| 677 |
+
<th>状态</th>
|
| 678 |
+
<th>操作</th>
|
| 679 |
+
</tr>
|
| 680 |
+
</thead>
|
| 681 |
+
<tbody id="accountsTableBody"></tbody>
|
| 682 |
+
</table>
|
| 683 |
+
</div>
|
| 684 |
+
</div>
|
| 685 |
+
</div>
|
| 686 |
+
|
| 687 |
+
<!-- 模型管理 (HTML结构类似,图标已替换) -->
|
| 688 |
+
<div id="models" class="tab-content">
|
| 689 |
+
<div class="card">
|
| 690 |
+
<div class="card-header">
|
| 691 |
+
<div class="card-title">
|
| 692 |
+
<svg class="icon card-title-icon"><use xlink:href="#icon-robot"></use></svg>
|
| 693 |
+
模型列表
|
| 694 |
+
</div>
|
| 695 |
+
<button class="btn btn-primary" onclick="showAddModelModal()">
|
| 696 |
+
<svg class="icon"><use xlink:href="#icon-plus"></use></svg>
|
| 697 |
+
添加模型
|
| 698 |
+
</button>
|
| 699 |
+
</div>
|
| 700 |
+
<div class="table-container">
|
| 701 |
+
<table id="modelsTable">
|
| 702 |
+
<thead>
|
| 703 |
+
<tr>
|
| 704 |
+
<th>模型ID</th>
|
| 705 |
+
<th>名称</th>
|
| 706 |
+
<th>描述</th>
|
| 707 |
+
<th>上下文长度</th>
|
| 708 |
+
<th>最大Token</th>
|
| 709 |
+
<th>状态</th>
|
| 710 |
+
<th>操作</th>
|
| 711 |
+
</tr>
|
| 712 |
+
</thead>
|
| 713 |
+
<tbody id="modelsTableBody"></tbody>
|
| 714 |
+
</table>
|
| 715 |
+
</div>
|
| 716 |
+
</div>
|
| 717 |
+
</div>
|
| 718 |
+
|
| 719 |
+
<!-- 系统设置 (HTML结构类似,图标已替换) -->
|
| 720 |
+
<div id="settings" class="tab-content">
|
| 721 |
+
<div class="card">
|
| 722 |
+
<div class="card-header">
|
| 723 |
+
<div class="card-title">
|
| 724 |
+
<svg class="icon card-title-icon"><use xlink:href="#icon-settings"></use></svg>
|
| 725 |
+
系统配置
|
| 726 |
+
</div>
|
| 727 |
+
</div>
|
| 728 |
+
<div class="card-body">
|
| 729 |
+
<form id="settingsForm">
|
| 730 |
+
<div class="settings-section">
|
| 731 |
+
<h3>代理设置</h3>
|
| 732 |
+
<div class="form-group">
|
| 733 |
+
<label class="form-label" for="proxyUrl">代理地址</label>
|
| 734 |
+
<input type="text" class="form-input" id="proxyUrl" placeholder="http://127.0.0.1:7890">
|
| 735 |
+
<small>用于访问Google API的代理服务器地址</small>
|
| 736 |
+
<div class="proxy-status" id="proxyStatus"></div>
|
| 737 |
+
</div>
|
| 738 |
+
<div class="form-group">
|
| 739 |
+
<label class="form-label" for="imageOutputMode">图片输出模式</label>
|
| 740 |
+
<select class="form-input" id="imageOutputMode">
|
| 741 |
+
<option value="url">图片URL(默认)</option>
|
| 742 |
+
<option value="base64">Base64 Data URL</option>
|
| 743 |
+
</select>
|
| 744 |
+
<small>控制聊天接口返回的图片是以URL形式还是以 data:image/...;base64,... 形式输出</small>
|
| 745 |
+
</div>
|
| 746 |
+
<div style="display: flex; gap: 12px;">
|
| 747 |
+
<button type="button" class="btn btn-outline" onclick="testProxy()">
|
| 748 |
+
测试代理
|
| 749 |
+
</button>
|
| 750 |
+
<button type="button" class="btn btn-primary" onclick="saveSettings()">
|
| 751 |
+
保存设置
|
| 752 |
+
</button>
|
| 753 |
+
</div>
|
| 754 |
+
</div>
|
| 755 |
+
|
| 756 |
+
<div class="settings-section">
|
| 757 |
+
<h3><svg class="icon" style="width: 1em; height: 1em; vertical-align: -2px; margin-right: 8px;"><use xlink:href="#icon-server"></use></svg>服务信息</h3>
|
| 758 |
+
<div class="form-row">
|
| 759 |
+
<div class="form-group">
|
| 760 |
+
<label class="form-label">服务端口</label>
|
| 761 |
+
<input type="text" class="form-input" value="8000" disabled>
|
| 762 |
+
</div>
|
| 763 |
+
<div class="form-group">
|
| 764 |
+
<label class="form-label">API地址</label>
|
| 765 |
+
<input type="text" class="form-input" value="http://localhost:8000/v1" disabled>
|
| 766 |
+
</div>
|
| 767 |
+
</div>
|
| 768 |
+
</div>
|
| 769 |
+
|
| 770 |
+
<div class="settings-section">
|
| 771 |
+
<h3>配置文件</h3>
|
| 772 |
+
<div class="form-group">
|
| 773 |
+
<label class="form-label" for="configJson">当前配置 (JSON)</label>
|
| 774 |
+
<textarea class="form-textarea" id="configJson" rows="15" readonly></textarea>
|
| 775 |
+
<small>配置文件路径: business_gemini_session.json</small>
|
| 776 |
+
</div>
|
| 777 |
+
<div style="display: flex; gap: 12px; flex-wrap: wrap;">
|
| 778 |
+
<button type="button" class="btn btn-outline" onclick="refreshConfig()">
|
| 779 |
+
刷新配置
|
| 780 |
+
</button>
|
| 781 |
+
<button type="button" class="btn btn-outline" onclick="downloadConfig()">
|
| 782 |
+
下载配置
|
| 783 |
+
</button>
|
| 784 |
+
<button type="button" class="btn btn-primary" onclick="uploadConfig()">
|
| 785 |
+
导入配置
|
| 786 |
+
</button>
|
| 787 |
+
<input type="file" id="configFileInput" accept=".json" style="display: none;" onchange="handleConfigUpload(event)">
|
| 788 |
+
</div>
|
| 789 |
+
</div>
|
| 790 |
+
</form>
|
| 791 |
+
</div>
|
| 792 |
+
</div>
|
| 793 |
+
</div>
|
| 794 |
+
|
| 795 |
+
<!-- Token 管理 -->
|
| 796 |
+
<div id="tokens" class="tab-content">
|
| 797 |
+
<div class="card">
|
| 798 |
+
<div class="card-header" style="display:flex; justify-content:space-between; align-items:center;">
|
| 799 |
+
<div class="card-title" style="display:flex; align-items:center; gap:8px;">
|
| 800 |
+
<svg class="icon card-title-icon"><use xlink:href="#icon-key"></use></svg>
|
| 801 |
+
Token 管理
|
| 802 |
+
</div>
|
| 803 |
+
<div class="token-actions">
|
| 804 |
+
<input id="manualToken" class="form-input token-input" placeholder="手动输入 Token(留空自动生成)">
|
| 805 |
+
<button class="btn btn-outline" type="button" onclick="generateToken()">生成 Token</button>
|
| 806 |
+
<button class="btn btn-primary" type="button" onclick="addToken()">添加 Token</button>
|
| 807 |
+
</div>
|
| 808 |
+
</div>
|
| 809 |
+
<table class="table">
|
| 810 |
+
<thead>
|
| 811 |
+
<tr>
|
| 812 |
+
<th style="width:70%;">Token</th>
|
| 813 |
+
<th>操作</th>
|
| 814 |
+
</tr>
|
| 815 |
+
</thead>
|
| 816 |
+
<tbody id="tokensTableBody">
|
| 817 |
+
<tr><td colspan="2" class="empty-state">加载中...</td></tr>
|
| 818 |
+
</tbody>
|
| 819 |
+
</table>
|
| 820 |
+
</div>
|
| 821 |
+
</div>
|
| 822 |
+
</div>
|
| 823 |
+
|
| 824 |
+
<!-- 模态框 (已优化关闭按钮) -->
|
| 825 |
+
<div class="modal" id="addAccountModal">
|
| 826 |
+
<div class="modal-content">
|
| 827 |
+
<div class="modal-header">
|
| 828 |
+
<h3>添加账号</h3>
|
| 829 |
+
<button class="modal-close" onclick="closeModal('addAccountModal')" title="关闭">×</button>
|
| 830 |
+
</div>
|
| 831 |
+
<!-- Modal Body and Footer ... (No functional changes needed) -->
|
| 832 |
+
<div class="modal-body">
|
| 833 |
+
<div class="form-group">
|
| 834 |
+
<label class="form-label" for="newAccountJson">粘贴账号JSON(可直接复制工具输出)</label>
|
| 835 |
+
<textarea class="form-textarea" id="newAccountJson" placeholder='{"team_id":"...","secure_c_ses":"...","host_c_oses":"...","csesidx":"...","user_agent":"..."}' rows="4"></textarea>
|
| 836 |
+
<div style="display:flex; gap:8px; margin-top:8px;">
|
| 837 |
+
<button class="btn btn-outline btn-sm" type="button" onclick="parseAccountJson()">解析填充</button>
|
| 838 |
+
<button class="btn btn-outline btn-sm" type="button" onclick="pasteAccountJson()">从剪贴板读取并填充</button>
|
| 839 |
+
</div>
|
| 840 |
+
</div>
|
| 841 |
+
<div class="form-group">
|
| 842 |
+
<label class="form-label" for="newTeamId">Team ID</label>
|
| 843 |
+
<input type="text" class="form-input" id="newTeamId" placeholder="输入Team ID">
|
| 844 |
+
</div>
|
| 845 |
+
<div class="form-group">
|
| 846 |
+
<label class="form-label" for="newSecureCses">Cookie中的__Secure-C_SES</label>
|
| 847 |
+
<textarea class="form-textarea" id="newSecureCses" placeholder="输入Cookie中的__Secure-C_SES" rows="3"></textarea>
|
| 848 |
+
</div>
|
| 849 |
+
<div class="form-group">
|
| 850 |
+
<label class="form-label" for="newHostCoses">Cookie中的__Host-C_OSES</label>
|
| 851 |
+
<textarea class="form-textarea" id="newHostCoses" placeholder="输入Cookie中的__Host-C_OSES" rows="3"></textarea>
|
| 852 |
+
</div>
|
| 853 |
+
<div class="form-group">
|
| 854 |
+
<label class="form-label" for="newCsesidx">CSESIDX</label>
|
| 855 |
+
<input type="text" class="form-input" id="newCsesidx" placeholder="输入CSESIDX">
|
| 856 |
+
</div>
|
| 857 |
+
<div class="form-group">
|
| 858 |
+
<label class="form-label" for="newUserAgent">User Agent</label>
|
| 859 |
+
<input type="text" class="form-input" id="newUserAgent" placeholder="输入User Agent">
|
| 860 |
+
</div>
|
| 861 |
+
</div>
|
| 862 |
+
<div class="modal-footer">
|
| 863 |
+
<button class="btn btn-outline" onclick="closeModal('addAccountModal')">取消</button>
|
| 864 |
+
<button class="btn btn-primary" onclick="saveNewAccount()">保存</button>
|
| 865 |
+
</div>
|
| 866 |
+
</div>
|
| 867 |
+
</div>
|
| 868 |
+
<!-- 编辑账号模态框 -->
|
| 869 |
+
<div class="modal" id="editAccountModal">
|
| 870 |
+
<div class="modal-content">
|
| 871 |
+
<div class="modal-header">
|
| 872 |
+
<h3>编辑账号</h3>
|
| 873 |
+
<button class="modal-close" onclick="closeModal('editAccountModal')" title="关闭">×</button>
|
| 874 |
+
</div>
|
| 875 |
+
<div class="modal-body">
|
| 876 |
+
<input type="hidden" id="editAccountId">
|
| 877 |
+
<div class="form-group">
|
| 878 |
+
<label class="form-label" for="editTeamId">Team ID</label>
|
| 879 |
+
<input type="text" class="form-input" id="editTeamId" placeholder="输入Team ID">
|
| 880 |
+
</div>
|
| 881 |
+
<div class="form-group">
|
| 882 |
+
<label class="form-label" for="editSecureCses">Cookie中的__Secure-C_SES</label>
|
| 883 |
+
<textarea class="form-textarea" id="editSecureCses" placeholder="输入Secure C Ses" rows="3"></textarea>
|
| 884 |
+
</div>
|
| 885 |
+
<div class="form-group">
|
| 886 |
+
<label class="form-label" for="editHostCoses">Cookie中的__Host-C_OSES</label>
|
| 887 |
+
<textarea class="form-textarea" id="editHostCoses" placeholder="输入Host C Oses" rows="3"></textarea>
|
| 888 |
+
</div>
|
| 889 |
+
<div class="form-group">
|
| 890 |
+
<label class="form-label" for="editCsesidx">CSESIDX</label>
|
| 891 |
+
<input type="text" class="form-input" id="editCsesidx" placeholder="输入CSESIDX">
|
| 892 |
+
</div>
|
| 893 |
+
<div class="form-group">
|
| 894 |
+
<label class="form-label" for="editUserAgent">User Agent</label>
|
| 895 |
+
<input type="text" class="form-input" id="editUserAgent" placeholder="输入User Agent">
|
| 896 |
+
</div>
|
| 897 |
+
</div>
|
| 898 |
+
<div class="modal-footer">
|
| 899 |
+
<button class="btn btn-outline" onclick="closeModal('editAccountModal')">取消</button>
|
| 900 |
+
<button class="btn btn-primary" onclick="updateAccount()">保存</button>
|
| 901 |
+
</div>
|
| 902 |
+
</div>
|
| 903 |
+
</div>
|
| 904 |
+
|
| 905 |
+
<!-- 刷新Cookie模态框 -->
|
| 906 |
+
<div class="modal" id="refreshCookieModal">
|
| 907 |
+
<div class="modal-content">
|
| 908 |
+
<div class="modal-header">
|
| 909 |
+
<h3>刷新账号Cookie</h3>
|
| 910 |
+
<button class="modal-close" onclick="closeModal('refreshCookieModal')" title="关闭">×</button>
|
| 911 |
+
</div>
|
| 912 |
+
<div class="modal-body">
|
| 913 |
+
<input type="hidden" id="refreshAccountId">
|
| 914 |
+
<p class="text-muted" style="margin-bottom: 16px;">请输入新的Cookie值来刷新账号认证信息。刷新后将清除JWT缓存。</p>
|
| 915 |
+
<div class="form-group">
|
| 916 |
+
<label class="form-label" for="refreshSecureCses">Cookie中的__Secure-C_SES <span style="color: var(--danger);">*</span></label>
|
| 917 |
+
<textarea class="form-textarea" id="refreshSecureCses" placeholder="输入新的__Secure-C_SES值" rows="3"></textarea>
|
| 918 |
+
</div>
|
| 919 |
+
<div class="form-group">
|
| 920 |
+
<label class="form-label" for="refreshHostCoses">Cookie中的__Host-C_OSES <span style="color: var(--danger);">*</span></label>
|
| 921 |
+
<textarea class="form-textarea" id="refreshHostCoses" placeholder="输入新的__Host-C_OSES值" rows="3"></textarea>
|
| 922 |
+
</div>
|
| 923 |
+
<div class="form-group">
|
| 924 |
+
<label class="form-label" for="refreshCsesidx">CSESIDX (可选)</label>
|
| 925 |
+
<input type="text" class="form-input" id="refreshCsesidx" placeholder="输入CSESIDX值">
|
| 926 |
+
</div>
|
| 927 |
+
<div class="form-group">
|
| 928 |
+
<label class="form-label">从JSON粘贴 (可选)</label>
|
| 929 |
+
<textarea class="form-textarea" id="refreshCookieJson" placeholder="粘贴Cookie JSON数据" rows="3"></textarea>
|
| 930 |
+
<div style="display:flex; gap:8px; margin-top:8px;">
|
| 931 |
+
<button class="btn btn-outline btn-sm" type="button" onclick="parseRefreshCookieJson()">解析填充</button>
|
| 932 |
+
<button class="btn btn-outline btn-sm" type="button" onclick="pasteRefreshCookieJson()">📋 粘贴并解析</button>
|
| 933 |
+
</div>
|
| 934 |
+
</div>
|
| 935 |
+
</div>
|
| 936 |
+
<div class="modal-footer">
|
| 937 |
+
<button class="btn btn-outline" onclick="closeModal('refreshCookieModal')">取消</button>
|
| 938 |
+
<button class="btn btn-primary" onclick="refreshAccountCookie()">刷新Cookie</button>
|
| 939 |
+
</div>
|
| 940 |
+
</div>
|
| 941 |
+
</div>
|
| 942 |
+
|
| 943 |
+
<!-- 添加模型模态框 -->
|
| 944 |
+
<div class="modal" id="addModelModal">
|
| 945 |
+
<div class="modal-content">
|
| 946 |
+
<div class="modal-header">
|
| 947 |
+
<h3>添加模型</h3>
|
| 948 |
+
<button class="modal-close" onclick="closeModal('addModelModal')" title="关闭">×</button>
|
| 949 |
+
</div>
|
| 950 |
+
<div class="modal-body">
|
| 951 |
+
<div class="form-row">
|
| 952 |
+
<div class="form-group">
|
| 953 |
+
<label class="form-label" for="newModelId">模型ID</label>
|
| 954 |
+
<input type="text" class="form-input" id="newModelId" placeholder="如: gemini-pro">
|
| 955 |
+
</div>
|
| 956 |
+
<div class="form-group">
|
| 957 |
+
<label class="form-label" for="newModelName">模型名称</label>
|
| 958 |
+
<input type="text" class="form-input" id="newModelName" placeholder="如: Gemini Pro">
|
| 959 |
+
</div>
|
| 960 |
+
</div>
|
| 961 |
+
<div class="form-group">
|
| 962 |
+
<label class="form-label" for="newModelDesc">描述</label>
|
| 963 |
+
<input type="text" class="form-input" id="newModelDesc" placeholder="模型描述">
|
| 964 |
+
</div>
|
| 965 |
+
<div class="form-row">
|
| 966 |
+
<div class="form-group">
|
| 967 |
+
<label class="form-label" for="newContextLength">上下文长度</label>
|
| 968 |
+
<input type="number" class="form-input" id="newContextLength" value="32768">
|
| 969 |
+
</div>
|
| 970 |
+
<div class="form-group">
|
| 971 |
+
<label class="form-label" for="newMaxTokens">最大Token</label>
|
| 972 |
+
<input type="number" class="form-input" id="newMaxTokens" value="8192">
|
| 973 |
+
</div>
|
| 974 |
+
</div>
|
| 975 |
+
</div>
|
| 976 |
+
<div class="modal-footer">
|
| 977 |
+
<button class="btn btn-outline" onclick="closeModal('addModelModal')">取消</button>
|
| 978 |
+
<button class="btn btn-primary" onclick="saveNewModel()">保存</button>
|
| 979 |
+
</div>
|
| 980 |
+
</div>
|
| 981 |
+
</div>
|
| 982 |
+
|
| 983 |
+
<!-- 登录模态框 -->
|
| 984 |
+
<div class="modal" id="loginModal">
|
| 985 |
+
<div class="modal-content">
|
| 986 |
+
<div class="modal-header">
|
| 987 |
+
<h3>管理员登录</h3>
|
| 988 |
+
<button class="modal-close" onclick="closeModal('loginModal')" title="关闭">×</button>
|
| 989 |
+
</div>
|
| 990 |
+
<div class="modal-body">
|
| 991 |
+
<div class="form-group">
|
| 992 |
+
<label class="form-label" for="loginPassword">后台密码</label>
|
| 993 |
+
<input type="password" class="form-input" id="loginPassword" placeholder="输入后台密码">
|
| 994 |
+
</div>
|
| 995 |
+
<p class="text-muted" style="font-size: 12px;">首次登录将设置当前密码为后台密码。</p>
|
| 996 |
+
</div>
|
| 997 |
+
<div class="modal-footer">
|
| 998 |
+
<button class="btn btn-outline" onclick="closeModal('loginModal')">取消</button>
|
| 999 |
+
<button class="btn btn-primary" onclick="submitLogin()">登录</button>
|
| 1000 |
+
</div>
|
| 1001 |
+
</div>
|
| 1002 |
+
</div>
|
| 1003 |
+
|
| 1004 |
+
<!-- 编辑模型模态框 -->
|
| 1005 |
+
<div class="modal" id="editModelModal">
|
| 1006 |
+
<div class="modal-content">
|
| 1007 |
+
<div class="modal-header">
|
| 1008 |
+
<h3>编辑模型</h3>
|
| 1009 |
+
<button class="modal-close" onclick="closeModal('editModelModal')" title="关闭">×</button>
|
| 1010 |
+
</div>
|
| 1011 |
+
<div class="modal-body">
|
| 1012 |
+
<input type="hidden" id="editModelOriginalId">
|
| 1013 |
+
<div class="form-row">
|
| 1014 |
+
<div class="form-group">
|
| 1015 |
+
<label class="form-label" for="editModelId">模型ID</label>
|
| 1016 |
+
<input type="text" class="form-input" id="editModelId" placeholder="如: gemini-pro" readonly style="background-color: var(--bg-tertiary); cursor: not-allowed;">
|
| 1017 |
+
</div>
|
| 1018 |
+
<div class="form-group">
|
| 1019 |
+
<label class="form-label" for="editModelName">模型名称</label>
|
| 1020 |
+
<input type="text" class="form-input" id="editModelName" placeholder="如: Gemini Pro">
|
| 1021 |
+
</div>
|
| 1022 |
+
</div>
|
| 1023 |
+
<div class="form-group">
|
| 1024 |
+
<label class="form-label" for="editModelDesc">描述</label>
|
| 1025 |
+
<input type="text" class="form-input" id="editModelDesc" placeholder="模型描述">
|
| 1026 |
+
</div>
|
| 1027 |
+
<div class="form-row">
|
| 1028 |
+
<div class="form-group">
|
| 1029 |
+
<label class="form-label" for="editContextLength">上下文长度</label>
|
| 1030 |
+
<input type="number" class="form-input" id="editContextLength">
|
| 1031 |
+
</div>
|
| 1032 |
+
<div class="form-group">
|
| 1033 |
+
<label class="form-label" for="editMaxTokens">最大Token</label>
|
| 1034 |
+
<input type="number" class="form-input" id="editMaxTokens">
|
| 1035 |
+
</div>
|
| 1036 |
+
</div>
|
| 1037 |
+
</div>
|
| 1038 |
+
<div class="modal-footer">
|
| 1039 |
+
<button class="btn btn-outline" onclick="closeModal('editModelModal')">取消</button>
|
| 1040 |
+
<button class="btn btn-primary" onclick="updateModel()">保存</button>
|
| 1041 |
+
</div>
|
| 1042 |
+
</div>
|
| 1043 |
+
</div>
|
| 1044 |
+
|
| 1045 |
+
|
| 1046 |
+
<!-- Toast通知 -->
|
| 1047 |
+
<div id="toastContainer" class="toast-container">
|
| 1048 |
+
<!-- Toasts will be injected here by JS -->
|
| 1049 |
+
</div>
|
| 1050 |
+
<div class="toast" id="toast"></div>
|
| 1051 |
+
|
| 1052 |
+
<script>
|
| 1053 |
+
// [OPTIMIZATION] 1. 脚本微调以适应新的图标
|
| 1054 |
+
function updateThemeIcon(theme) {
|
| 1055 |
+
const iconContainer = document.getElementById('themeIconContainer');
|
| 1056 |
+
if (iconContainer) {
|
| 1057 |
+
const iconId = theme === 'dark' ? 'icon-sun' : 'icon-moon';
|
| 1058 |
+
iconContainer.innerHTML = `<svg class="icon"><use xlink:href="#${iconId}"></use></svg>`;
|
| 1059 |
+
}
|
| 1060 |
+
}
|
| 1061 |
+
|
| 1062 |
+
// [OPTIMIZATION] 2. 改进Toast通知
|
| 1063 |
+
let toastTimeout;
|
| 1064 |
+
function showToast(message, type = 'info') {
|
| 1065 |
+
const toast = document.getElementById('toast');
|
| 1066 |
+
if (!toast) return;
|
| 1067 |
+
|
| 1068 |
+
let icon = '';
|
| 1069 |
+
let borderType = type; // 'success', 'error', 'info'
|
| 1070 |
+
switch(type) {
|
| 1071 |
+
case 'success':
|
| 1072 |
+
icon = '<svg class="icon" style="color: var(--success);"><use xlink:href="#icon-check"></use></svg>';
|
| 1073 |
+
break;
|
| 1074 |
+
case 'error':
|
| 1075 |
+
icon = '<svg class="icon" style="color: var(--danger);"><use xlink:href="#icon-x"></use></svg>';
|
| 1076 |
+
break;
|
| 1077 |
+
default:
|
| 1078 |
+
icon = '<svg class="icon" style="color: var(--primary);"><use xlink:href="#icon-server"></use></svg>';
|
| 1079 |
+
borderType = 'primary';
|
| 1080 |
+
break;
|
| 1081 |
+
}
|
| 1082 |
+
|
| 1083 |
+
toast.innerHTML = `${icon} <span class="toast-message">${message}</span>`;
|
| 1084 |
+
toast.className = `toast show`;
|
| 1085 |
+
toast.style.borderLeft = `4px solid var(--${borderType})`;
|
| 1086 |
+
|
| 1087 |
+
clearTimeout(toastTimeout);
|
| 1088 |
+
toastTimeout = setTimeout(() => {
|
| 1089 |
+
toast.classList.remove('show');
|
| 1090 |
+
}, 3500);
|
| 1091 |
+
}
|
| 1092 |
+
|
| 1093 |
+
// =======================================================
|
| 1094 |
+
// [FULL SCRIPT] 以下是完整的、未删减的功能性 JavaScript 代码
|
| 1095 |
+
// =======================================================
|
| 1096 |
+
|
| 1097 |
+
// API 基础 URL
|
| 1098 |
+
const API_BASE = '.';
|
| 1099 |
+
|
| 1100 |
+
// 全局数据缓存
|
| 1101 |
+
let accountsData = [];
|
| 1102 |
+
let modelsData = [];
|
| 1103 |
+
let configData = {};
|
| 1104 |
+
let currentEditAccountId = null;
|
| 1105 |
+
let currentEditModelId = null;
|
| 1106 |
+
const ADMIN_TOKEN_KEY = 'admin_token';
|
| 1107 |
+
let tokensData = [];
|
| 1108 |
+
|
| 1109 |
+
// --- 初始化 ---
|
| 1110 |
+
document.addEventListener('DOMContentLoaded', () => {
|
| 1111 |
+
initTheme();
|
| 1112 |
+
loadAllData();
|
| 1113 |
+
setInterval(checkServerStatus, 30000); // 每30秒检查一次服务状态
|
| 1114 |
+
updateLoginButton();
|
| 1115 |
+
});
|
| 1116 |
+
|
| 1117 |
+
// --- 核心加载与渲染 ---
|
| 1118 |
+
async function loadAllData() {
|
| 1119 |
+
await Promise.all([
|
| 1120 |
+
loadAccounts(),
|
| 1121 |
+
loadModels(),
|
| 1122 |
+
loadConfig(),
|
| 1123 |
+
checkServerStatus(),
|
| 1124 |
+
loadLogLevel(),
|
| 1125 |
+
loadTokens()
|
| 1126 |
+
]);
|
| 1127 |
+
}
|
| 1128 |
+
|
| 1129 |
+
function getAuthHeaders() {
|
| 1130 |
+
const token = localStorage.getItem(ADMIN_TOKEN_KEY);
|
| 1131 |
+
return token ? { 'X-Admin-Token': token } : {};
|
| 1132 |
+
}
|
| 1133 |
+
|
| 1134 |
+
function updateLoginButton() {
|
| 1135 |
+
const token = localStorage.getItem(ADMIN_TOKEN_KEY);
|
| 1136 |
+
const btn = document.getElementById('loginButton');
|
| 1137 |
+
if (!btn) return;
|
| 1138 |
+
if (token) {
|
| 1139 |
+
btn.textContent = '注销';
|
| 1140 |
+
btn.disabled = false;
|
| 1141 |
+
btn.classList.remove('btn-disabled');
|
| 1142 |
+
btn.title = '注销登录';
|
| 1143 |
+
btn.onclick = logoutAdmin;
|
| 1144 |
+
} else {
|
| 1145 |
+
btn.textContent = '登录';
|
| 1146 |
+
btn.disabled = false;
|
| 1147 |
+
btn.classList.remove('btn-disabled');
|
| 1148 |
+
btn.title = '管理员登录';
|
| 1149 |
+
btn.onclick = showLoginModal;
|
| 1150 |
+
}
|
| 1151 |
+
}
|
| 1152 |
+
|
| 1153 |
+
async function apiFetch(url, options = {}) {
|
| 1154 |
+
const headers = Object.assign({}, options.headers || {}, getAuthHeaders());
|
| 1155 |
+
const res = await fetch(url, { ...options, headers });
|
| 1156 |
+
if (res.status === 401 || res.status === 403) {
|
| 1157 |
+
showLoginModal();
|
| 1158 |
+
updateLoginButton();
|
| 1159 |
+
throw new Error('需要登录');
|
| 1160 |
+
}
|
| 1161 |
+
return res;
|
| 1162 |
+
}
|
| 1163 |
+
|
| 1164 |
+
// --- 主题控制 ---
|
| 1165 |
+
function initTheme() {
|
| 1166 |
+
const savedTheme = localStorage.getItem('theme') || 'light';
|
| 1167 |
+
document.documentElement.setAttribute('data-theme', savedTheme);
|
| 1168 |
+
updateThemeIcon(savedTheme);
|
| 1169 |
+
}
|
| 1170 |
+
|
| 1171 |
+
function toggleTheme() {
|
| 1172 |
+
const current = document.documentElement.getAttribute('data-theme');
|
| 1173 |
+
const newTheme = current === 'dark' ? 'light' : 'dark';
|
| 1174 |
+
document.documentElement.setAttribute('data-theme', newTheme);
|
| 1175 |
+
localStorage.setItem('theme', newTheme);
|
| 1176 |
+
updateThemeIcon(newTheme);
|
| 1177 |
+
}
|
| 1178 |
+
|
| 1179 |
+
// --- 标签页控制 ---
|
| 1180 |
+
function switchTab(tabName) {
|
| 1181 |
+
document.querySelectorAll('.tab').forEach(btn => btn.classList.remove('active'));
|
| 1182 |
+
document.querySelectorAll('.tab-content').forEach(content => content.classList.remove('active'));
|
| 1183 |
+
|
| 1184 |
+
const tabBtn = document.querySelector(`[onclick="switchTab('${tabName}')"]`);
|
| 1185 |
+
const tabContent = document.getElementById(tabName);
|
| 1186 |
+
|
| 1187 |
+
if (tabBtn) tabBtn.classList.add('active');
|
| 1188 |
+
if (tabContent) tabContent.classList.add('active');
|
| 1189 |
+
}
|
| 1190 |
+
|
| 1191 |
+
// --- 状态检查 ---
|
| 1192 |
+
async function checkServerStatus() {
|
| 1193 |
+
const indicator = document.getElementById('serviceStatus');
|
| 1194 |
+
if (!indicator) return;
|
| 1195 |
+
try {
|
| 1196 |
+
const res = await apiFetch(`${API_BASE}/api/status`);
|
| 1197 |
+
console.log('Server Status Response:', res);
|
| 1198 |
+
if (!res.ok) throw new Error(`HTTP error! status: ${res.status}`);
|
| 1199 |
+
const data = await res.json();
|
| 1200 |
+
indicator.textContent = '服务运行中';
|
| 1201 |
+
indicator.classList.remove('offline');
|
| 1202 |
+
indicator.title = '服务连接正常 - ' + new Date().toLocaleString();
|
| 1203 |
+
} catch (e) {
|
| 1204 |
+
indicator.textContent = '服务离线';
|
| 1205 |
+
indicator.classList.add('offline');
|
| 1206 |
+
indicator.title = '无法连接到后端服务';
|
| 1207 |
+
}
|
| 1208 |
+
}
|
| 1209 |
+
|
| 1210 |
+
// --- 账号管理 (Accounts) ---
|
| 1211 |
+
async function loadAccounts() {
|
| 1212 |
+
try {
|
| 1213 |
+
const res = await apiFetch(`${API_BASE}/api/accounts`);
|
| 1214 |
+
const data = await res.json();
|
| 1215 |
+
accountsData = data.accounts || [];
|
| 1216 |
+
document.getElementById('currentIndex').textContent = data.current_index || 0;
|
| 1217 |
+
renderAccounts();
|
| 1218 |
+
updateAccountStats();
|
| 1219 |
+
} catch (e) {
|
| 1220 |
+
showToast('加载账号列表失败: ' + e.message, 'error');
|
| 1221 |
+
}
|
| 1222 |
+
}
|
| 1223 |
+
|
| 1224 |
+
function renderAccounts() {
|
| 1225 |
+
const tbody = document.getElementById('accountsTableBody');
|
| 1226 |
+
if (!tbody) return;
|
| 1227 |
+
|
| 1228 |
+
if (accountsData.length === 0) {
|
| 1229 |
+
tbody.innerHTML = `<tr><td colspan="6" class="empty-state">
|
| 1230 |
+
<div class="empty-state-icon"><svg class="icon"><use xlink:href="#icon-users"></use></svg></div>
|
| 1231 |
+
<h3>暂无账号</h3><p>点击 "添加账号" 按钮来创建一个。</p>
|
| 1232 |
+
</td></tr>`;
|
| 1233 |
+
return;
|
| 1234 |
+
}
|
| 1235 |
+
|
| 1236 |
+
tbody.innerHTML = accountsData.map((acc, index) => `
|
| 1237 |
+
<tr>
|
| 1238 |
+
<td>${index + 1}</td>
|
| 1239 |
+
<td><code>${acc.team_id || '-'}</code></td>
|
| 1240 |
+
<td><code>${acc.csesidx || '-'}</code></td>
|
| 1241 |
+
<td title="${acc.user_agent}">${acc.user_agent ? acc.user_agent.substring(0, 30) + '...' : '-'}</td>
|
| 1242 |
+
<td>
|
| 1243 |
+
<span class="badge ${acc.available ? 'badge-success' : 'badge-danger'}">${acc.available ? '可用' : '不可用'}</span>
|
| 1244 |
+
${renderNextRefresh(acc)}
|
| 1245 |
+
</td>
|
| 1246 |
+
<td style="white-space: nowrap;">
|
| 1247 |
+
<button class="btn btn-sm ${acc.enabled !== false ? 'btn-warning' : 'btn-success'} btn-icon" onclick="toggleAccount(${acc.id})" title="${acc.enabled !== false ? '停用' : '启用'}"><svg class="icon" style="width:16px; height:16px;"><use xlink:href="#icon-${acc.enabled !== false ? 'pause' : 'play'}"></use></svg></button>
|
| 1248 |
+
<button class="btn btn-sm btn-outline btn-icon" onclick="testAccount(${acc.id})" title="测试连接"><svg class="icon" style="width:16px; height:16px;"><use xlink:href="#icon-zap"></use></svg></button>
|
| 1249 |
+
<button class="btn btn-sm btn-outline btn-icon" onclick="showRefreshCookieModal(${acc.id})" title="刷新Cookie"><svg class="icon" style="width:16px; height:16px;"><use xlink:href="#icon-refresh"></use></svg></button>
|
| 1250 |
+
<button class="btn btn-sm btn-outline btn-icon" onclick="showEditAccountModal(${acc.id})" title="编辑"><svg class="icon" style="width:16px; height:16px;"><use xlink:href="#icon-settings"></use></svg></button>
|
| 1251 |
+
<button class="btn btn-sm btn-danger btn-icon" onclick="deleteAccount(${acc.id})" title="删除"><svg class="icon" style="width:16px; height:16px;"><use xlink:href="#icon-x"></use></svg></button>
|
| 1252 |
+
</td>
|
| 1253 |
+
</tr>
|
| 1254 |
+
`).join('');
|
| 1255 |
+
}
|
| 1256 |
+
|
| 1257 |
+
function updateAccountStats() {
|
| 1258 |
+
document.getElementById('totalAccounts').textContent = accountsData.length;
|
| 1259 |
+
document.getElementById('availableAccounts').textContent = accountsData.filter(a => a.available).length;
|
| 1260 |
+
document.getElementById('unavailableAccounts').textContent = accountsData.length - accountsData.filter(a => a.available).length;
|
| 1261 |
+
}
|
| 1262 |
+
|
| 1263 |
+
function renderNextRefresh(acc) {
|
| 1264 |
+
if (!acc || !acc.cooldown_until) return '';
|
| 1265 |
+
const now = Date.now();
|
| 1266 |
+
const ts = acc.cooldown_until * 1000;
|
| 1267 |
+
if (ts <= now) return '';
|
| 1268 |
+
const next = new Date(ts);
|
| 1269 |
+
const remaining = Math.max(0, ts - now);
|
| 1270 |
+
const minutes = Math.floor(remaining / 60000);
|
| 1271 |
+
const label = minutes >= 60
|
| 1272 |
+
? `${Math.floor(minutes / 60)}小时${minutes % 60}分`
|
| 1273 |
+
: `${minutes}分`;
|
| 1274 |
+
return `<span class="cooldown-hint">下次恢复: ${next.toLocaleString()}(约${label})</span>`;
|
| 1275 |
+
}
|
| 1276 |
+
|
| 1277 |
+
function showAddAccountModal() {
|
| 1278 |
+
// 清空表单字段
|
| 1279 |
+
document.getElementById('newAccountJson').value = '';
|
| 1280 |
+
document.getElementById('newTeamId').value = '';
|
| 1281 |
+
document.getElementById('newSecureCses').value = '';
|
| 1282 |
+
document.getElementById('newHostCoses').value = '';
|
| 1283 |
+
document.getElementById('newCsesidx').value = '';
|
| 1284 |
+
document.getElementById('newUserAgent').value = '';
|
| 1285 |
+
openModal('addAccountModal');
|
| 1286 |
+
}
|
| 1287 |
+
|
| 1288 |
+
function showEditAccountModal(id) {
|
| 1289 |
+
const acc = accountsData.find(a => a.id === id);
|
| 1290 |
+
if (!acc) return;
|
| 1291 |
+
|
| 1292 |
+
document.getElementById('editAccountId').value = id;
|
| 1293 |
+
document.getElementById('editTeamId').value = acc.team_id || '';
|
| 1294 |
+
document.getElementById('editSecureCses').value = acc.secure_c_ses || '';
|
| 1295 |
+
document.getElementById('editHostCoses').value = acc.host_c_oses || '';
|
| 1296 |
+
document.getElementById('editCsesidx').value = acc.csesidx || '';
|
| 1297 |
+
document.getElementById('editUserAgent').value = acc.user_agent ? acc.user_agent.replace('...', '') : '';
|
| 1298 |
+
|
| 1299 |
+
openModal('editAccountModal');
|
| 1300 |
+
}
|
| 1301 |
+
|
| 1302 |
+
async function updateAccount() {
|
| 1303 |
+
const id = document.getElementById('editAccountId').value;
|
| 1304 |
+
const account = {};
|
| 1305 |
+
|
| 1306 |
+
const teamId = document.getElementById('editTeamId').value;
|
| 1307 |
+
const secureCses = document.getElementById('editSecureCses').value;
|
| 1308 |
+
const hostCoses = document.getElementById('editHostCoses').value;
|
| 1309 |
+
const csesidx = document.getElementById('editCsesidx').value;
|
| 1310 |
+
const userAgent = document.getElementById('editUserAgent').value;
|
| 1311 |
+
|
| 1312 |
+
if (teamId) account.team_id = teamId;
|
| 1313 |
+
if (secureCses) account.secure_c_ses = secureCses;
|
| 1314 |
+
if (hostCoses) account.host_c_oses = hostCoses;
|
| 1315 |
+
if (csesidx) account.csesidx = csesidx;
|
| 1316 |
+
if (userAgent) account.user_agent = userAgent;
|
| 1317 |
+
|
| 1318 |
+
try {
|
| 1319 |
+
const res = await apiFetch(`${API_BASE}/api/accounts/${id}`, {
|
| 1320 |
+
method: 'PUT',
|
| 1321 |
+
headers: { 'Content-Type': 'application/json' },
|
| 1322 |
+
body: JSON.stringify(account)
|
| 1323 |
+
});
|
| 1324 |
+
const data = await res.json();
|
| 1325 |
+
|
| 1326 |
+
if (data.success) {
|
| 1327 |
+
showToast('账号更新成功', 'success');
|
| 1328 |
+
closeModal('editAccountModal');
|
| 1329 |
+
loadAccounts();
|
| 1330 |
+
} else {
|
| 1331 |
+
showToast('更新失败: ' + (data.error || '未知错误'), 'error');
|
| 1332 |
+
}
|
| 1333 |
+
} catch (e) {
|
| 1334 |
+
showToast('更新失败: ' + e.message, 'error');
|
| 1335 |
+
}
|
| 1336 |
+
}
|
| 1337 |
+
|
| 1338 |
+
async function saveNewAccount() {
|
| 1339 |
+
const teamId = document.getElementById('newTeamId').value;
|
| 1340 |
+
const secureCses = document.getElementById('newSecureCses').value;
|
| 1341 |
+
const hostCoses = document.getElementById('newHostCoses').value;
|
| 1342 |
+
const csesidx = document.getElementById('newCsesidx').value;
|
| 1343 |
+
const userAgent = document.getElementById('newUserAgent').value;
|
| 1344 |
+
|
| 1345 |
+
try {
|
| 1346 |
+
const res = await apiFetch(`${API_BASE}/api/accounts`, {
|
| 1347 |
+
method: 'POST',
|
| 1348 |
+
headers: { 'Content-Type': 'application/json' },
|
| 1349 |
+
body: JSON.stringify({
|
| 1350 |
+
team_id: teamId,
|
| 1351 |
+
"secure_c_ses": secureCses,
|
| 1352 |
+
"host_c_oses": hostCoses,
|
| 1353 |
+
"csesidx": csesidx,
|
| 1354 |
+
"user_agent": userAgent })
|
| 1355 |
+
});
|
| 1356 |
+
const data = await res.json();
|
| 1357 |
+
if (!res.ok || data.error) throw new Error(data.error || data.detail || '添加失败');
|
| 1358 |
+
showToast('账号添加成功!', 'success');
|
| 1359 |
+
closeModal('addAccountModal');
|
| 1360 |
+
loadAccounts();
|
| 1361 |
+
} catch (e) {
|
| 1362 |
+
showToast('添加失败: ' + e.message, 'error');
|
| 1363 |
+
}
|
| 1364 |
+
}
|
| 1365 |
+
|
| 1366 |
+
function parseAccountJson(text) {
|
| 1367 |
+
const textarea = document.getElementById('newAccountJson');
|
| 1368 |
+
const raw = (typeof text === 'string' ? text : textarea.value || '').trim();
|
| 1369 |
+
if (!raw) {
|
| 1370 |
+
showToast('请先粘贴账号JSON', 'warning');
|
| 1371 |
+
return;
|
| 1372 |
+
}
|
| 1373 |
+
let acc;
|
| 1374 |
+
try {
|
| 1375 |
+
const parsed = JSON.parse(raw);
|
| 1376 |
+
acc = Array.isArray(parsed) ? parsed[0] : parsed;
|
| 1377 |
+
if (!acc || typeof acc !== 'object') throw new Error('格式不正确');
|
| 1378 |
+
} catch (err) {
|
| 1379 |
+
showToast('解析失败: ' + err.message, 'error');
|
| 1380 |
+
return;
|
| 1381 |
+
}
|
| 1382 |
+
|
| 1383 |
+
document.getElementById('newTeamId').value = acc.team_id || '';
|
| 1384 |
+
document.getElementById('newSecureCses').value = acc.secure_c_ses || '';
|
| 1385 |
+
document.getElementById('newHostCoses').value = acc.host_c_oses || '';
|
| 1386 |
+
document.getElementById('newCsesidx').value = acc.csesidx || '';
|
| 1387 |
+
document.getElementById('newUserAgent').value = acc.user_agent || '';
|
| 1388 |
+
showToast('已填充账号信息', 'success');
|
| 1389 |
+
}
|
| 1390 |
+
|
| 1391 |
+
async function pasteAccountJson() {
|
| 1392 |
+
try {
|
| 1393 |
+
if (!navigator.clipboard || !navigator.clipboard.readText) {
|
| 1394 |
+
showToast('当前环境不支持剪贴板API,请使用HTTPS或手动粘贴', 'warning');
|
| 1395 |
+
return;
|
| 1396 |
+
}
|
| 1397 |
+
const text = await navigator.clipboard.readText();
|
| 1398 |
+
document.getElementById('newAccountJson').value = text;
|
| 1399 |
+
parseAccountJson(text);
|
| 1400 |
+
} catch (e) {
|
| 1401 |
+
showToast('无法读取剪贴板: ' + e.message, 'error');
|
| 1402 |
+
}
|
| 1403 |
+
}
|
| 1404 |
+
|
| 1405 |
+
async function deleteAccount(id) {
|
| 1406 |
+
if (!confirm('确定要删除这个账号吗?')) return;
|
| 1407 |
+
try {
|
| 1408 |
+
const res = await apiFetch(`${API_BASE}/api/accounts/${id}`, { method: 'DELETE' });
|
| 1409 |
+
if (!res.ok) throw new Error((await res.json()).detail);
|
| 1410 |
+
showToast('账号删除成功!', 'success');
|
| 1411 |
+
loadAccounts();
|
| 1412 |
+
} catch (e) {
|
| 1413 |
+
showToast('删除失败: ' + e.message, 'error');
|
| 1414 |
+
}
|
| 1415 |
+
}
|
| 1416 |
+
|
| 1417 |
+
async function testAccount(id) {
|
| 1418 |
+
showToast(`正在测试账号ID: ${id}...`, 'info');
|
| 1419 |
+
try {
|
| 1420 |
+
const res = await apiFetch(`${API_BASE}/api/accounts/${id}/test`);
|
| 1421 |
+
const data = await res.json();
|
| 1422 |
+
if (res.ok && data.success) {
|
| 1423 |
+
showToast(`账号 ${id} 测试成功!`, 'success');
|
| 1424 |
+
} else {
|
| 1425 |
+
throw new Error(data.detail || '未知错误');
|
| 1426 |
+
}
|
| 1427 |
+
loadAccounts();
|
| 1428 |
+
} catch (e) {
|
| 1429 |
+
showToast(`账号 ${id} 测试失败: ${e.message}`, 'error');
|
| 1430 |
+
}
|
| 1431 |
+
}
|
| 1432 |
+
|
| 1433 |
+
async function toggleAccount(id) {
|
| 1434 |
+
const acc = accountsData.find(a => a.id === id);
|
| 1435 |
+
const action = acc && acc.enabled !== false ? '停用' : '启用';
|
| 1436 |
+
try {
|
| 1437 |
+
const res = await apiFetch(`${API_BASE}/api/accounts/${id}/toggle`, {
|
| 1438 |
+
method: 'POST',
|
| 1439 |
+
headers: { 'Content-Type': 'application/json' }
|
| 1440 |
+
});
|
| 1441 |
+
const data = await res.json();
|
| 1442 |
+
if (res.ok && data.success) {
|
| 1443 |
+
showToast(`账号 ${id} ${action}成功!`, 'success');
|
| 1444 |
+
loadAccounts();
|
| 1445 |
+
} else {
|
| 1446 |
+
throw new Error(data.error || data.detail || '未知错误');
|
| 1447 |
+
}
|
| 1448 |
+
} catch (e) {
|
| 1449 |
+
showToast(`账号 ${id} ${action}失败: ${e.message}`, 'error');
|
| 1450 |
+
}
|
| 1451 |
+
}
|
| 1452 |
+
|
| 1453 |
+
/**
|
| 1454 |
+
* 显示刷新Cookie的模态框
|
| 1455 |
+
* @param {number} id - 账号ID
|
| 1456 |
+
*/
|
| 1457 |
+
function showRefreshCookieModal(id) {
|
| 1458 |
+
const acc = accountsData.find(a => a.id === id);
|
| 1459 |
+
if (!acc) {
|
| 1460 |
+
showToast('账号不存在', 'error');
|
| 1461 |
+
return;
|
| 1462 |
+
}
|
| 1463 |
+
|
| 1464 |
+
document.getElementById('refreshAccountId').value = id;
|
| 1465 |
+
document.getElementById('refreshSecureCses').value = '';
|
| 1466 |
+
document.getElementById('refreshHostCoses').value = '';
|
| 1467 |
+
document.getElementById('refreshCsesidx').value = '';
|
| 1468 |
+
document.getElementById('refreshCookieJson').value = '';
|
| 1469 |
+
|
| 1470 |
+
openModal('refreshCookieModal');
|
| 1471 |
+
}
|
| 1472 |
+
|
| 1473 |
+
/**
|
| 1474 |
+
* 从JSON解析并填充刷新Cookie表单
|
| 1475 |
+
* @param {string} text - JSON字符串
|
| 1476 |
+
*/
|
| 1477 |
+
function parseRefreshCookieJson(text) {
|
| 1478 |
+
const textarea = document.getElementById('refreshCookieJson');
|
| 1479 |
+
const raw = (typeof text === 'string' ? text : textarea.value || '').trim();
|
| 1480 |
+
if (!raw) {
|
| 1481 |
+
showToast('请先粘贴Cookie JSON', 'warning');
|
| 1482 |
+
return;
|
| 1483 |
+
}
|
| 1484 |
+
let acc;
|
| 1485 |
+
try {
|
| 1486 |
+
const parsed = JSON.parse(raw);
|
| 1487 |
+
acc = Array.isArray(parsed) ? parsed[0] : parsed;
|
| 1488 |
+
if (!acc || typeof acc !== 'object') throw new Error('格式不正确');
|
| 1489 |
+
} catch (err) {
|
| 1490 |
+
showToast('解析失败: ' + err.message, 'error');
|
| 1491 |
+
return;
|
| 1492 |
+
}
|
| 1493 |
+
|
| 1494 |
+
document.getElementById('refreshSecureCses').value = acc.secure_c_ses || '';
|
| 1495 |
+
document.getElementById('refreshHostCoses').value = acc.host_c_oses || '';
|
| 1496 |
+
document.getElementById('refreshCsesidx').value = acc.csesidx || '';
|
| 1497 |
+
showToast('已填充Cookie信息', 'success');
|
| 1498 |
+
}
|
| 1499 |
+
|
| 1500 |
+
/**
|
| 1501 |
+
* 从剪贴板粘贴并解析刷新Cookie JSON
|
| 1502 |
+
*/
|
| 1503 |
+
async function pasteRefreshCookieJson() {
|
| 1504 |
+
try {
|
| 1505 |
+
if (!navigator.clipboard || !navigator.clipboard.readText) {
|
| 1506 |
+
showToast('当前环境不支持剪贴板API,请使用HTTPS或手动粘贴', 'warning');
|
| 1507 |
+
return;
|
| 1508 |
+
}
|
| 1509 |
+
const text = await navigator.clipboard.readText();
|
| 1510 |
+
document.getElementById('refreshCookieJson').value = text;
|
| 1511 |
+
parseRefreshCookieJson(text);
|
| 1512 |
+
} catch (e) {
|
| 1513 |
+
showToast('无法读取剪贴板: ' + e.message, 'error');
|
| 1514 |
+
}
|
| 1515 |
+
}
|
| 1516 |
+
|
| 1517 |
+
/**
|
| 1518 |
+
* 刷新账号Cookie
|
| 1519 |
+
* 调用后端API更新账号的Cookie信息
|
| 1520 |
+
*/
|
| 1521 |
+
async function refreshAccountCookie() {
|
| 1522 |
+
const id = document.getElementById('refreshAccountId').value;
|
| 1523 |
+
const secureCses = document.getElementById('refreshSecureCses').value.trim();
|
| 1524 |
+
const hostCoses = document.getElementById('refreshHostCoses').value.trim();
|
| 1525 |
+
const csesidx = document.getElementById('refreshCsesidx').value.trim();
|
| 1526 |
+
|
| 1527 |
+
// 验证必填字段
|
| 1528 |
+
if (!secureCses || !hostCoses) {
|
| 1529 |
+
showToast('secure_c_ses 和 host_c_oses 为必填项', 'warning');
|
| 1530 |
+
return;
|
| 1531 |
+
}
|
| 1532 |
+
|
| 1533 |
+
try {
|
| 1534 |
+
const res = await apiFetch(`${API_BASE}/api/accounts/${id}/refresh-cookie`, {
|
| 1535 |
+
method: 'POST',
|
| 1536 |
+
headers: { 'Content-Type': 'application/json' },
|
| 1537 |
+
body: JSON.stringify({
|
| 1538 |
+
secure_c_ses: secureCses,
|
| 1539 |
+
host_c_oses: hostCoses,
|
| 1540 |
+
csesidx: csesidx || undefined
|
| 1541 |
+
})
|
| 1542 |
+
});
|
| 1543 |
+
const data = await res.json();
|
| 1544 |
+
|
| 1545 |
+
if (res.ok && data.success) {
|
| 1546 |
+
showToast('Cookie刷新成功!', 'success');
|
| 1547 |
+
closeModal('refreshCookieModal');
|
| 1548 |
+
loadAccounts();
|
| 1549 |
+
} else {
|
| 1550 |
+
throw new Error(data.error || data.detail || '未知错误');
|
| 1551 |
+
}
|
| 1552 |
+
} catch (e) {
|
| 1553 |
+
showToast('Cookie刷新失败: ' + e.message, 'error');
|
| 1554 |
+
}
|
| 1555 |
+
}
|
| 1556 |
+
|
| 1557 |
+
// --- 模型管理 (Models) ---
|
| 1558 |
+
async function loadModels() {
|
| 1559 |
+
try {
|
| 1560 |
+
const res = await apiFetch(`${API_BASE}/api/models`);
|
| 1561 |
+
const data = await res.json();
|
| 1562 |
+
modelsData = data.models || [];
|
| 1563 |
+
renderModels();
|
| 1564 |
+
} catch (e) {
|
| 1565 |
+
showToast('加载模型列表失败: ' + e.message, 'error');
|
| 1566 |
+
}
|
| 1567 |
+
}
|
| 1568 |
+
|
| 1569 |
+
function escapeHtml(str) {
|
| 1570 |
+
if (!str) return '';
|
| 1571 |
+
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
| 1572 |
+
}
|
| 1573 |
+
|
| 1574 |
+
function renderModels() {
|
| 1575 |
+
const tbody = document.getElementById('modelsTableBody');
|
| 1576 |
+
if (!tbody) return;
|
| 1577 |
+
if (modelsData.length === 0) {
|
| 1578 |
+
tbody.innerHTML = `<tr><td colspan="7" class="empty-state">
|
| 1579 |
+
<div class="empty-state-icon"><svg class="icon"><use xlink:href="#icon-robot"></use></svg></div>
|
| 1580 |
+
<h3>暂无模型</h3><p>点击 "添加模型" 按钮来创建一个。</p>
|
| 1581 |
+
</td></tr>`;
|
| 1582 |
+
return;
|
| 1583 |
+
}
|
| 1584 |
+
tbody.innerHTML = modelsData.map((model, index) => {
|
| 1585 |
+
const safeId = escapeHtml(model.id);
|
| 1586 |
+
const safeName = escapeHtml(model.name);
|
| 1587 |
+
const safeDesc = escapeHtml(model.description);
|
| 1588 |
+
return `
|
| 1589 |
+
<tr>
|
| 1590 |
+
<td><code>${safeId}</code></td>
|
| 1591 |
+
<td>${safeName}</td>
|
| 1592 |
+
<td title="${safeDesc}">${model.description ? safeDesc.substring(0, 40) + '...' : ''}</td>
|
| 1593 |
+
<td>${model.context_length}</td>
|
| 1594 |
+
<td>${model.max_tokens}</td>
|
| 1595 |
+
<td><span class="badge ${model.is_public ? 'badge-success' : 'badge-warning'}">${model.is_public ? '公共' : '私有'}</span></td>
|
| 1596 |
+
<td>
|
| 1597 |
+
<button class="btn btn-sm btn-outline btn-icon" onclick="showEditModelModalByIndex(${index})" title="编辑">✏️</button>
|
| 1598 |
+
<button class="btn btn-sm btn-danger btn-icon" onclick="deleteModelByIndex(${index})" title="删除">🗑️</button>
|
| 1599 |
+
</td>
|
| 1600 |
+
</tr>
|
| 1601 |
+
`;
|
| 1602 |
+
}).join('');
|
| 1603 |
+
}
|
| 1604 |
+
|
| 1605 |
+
function showAddModelModal() {
|
| 1606 |
+
openModal('addModelModal');
|
| 1607 |
+
}
|
| 1608 |
+
|
| 1609 |
+
function showEditModelModalByIndex(index) {
|
| 1610 |
+
const model = modelsData[index];
|
| 1611 |
+
if (!model) return;
|
| 1612 |
+
|
| 1613 |
+
document.getElementById('editModelOriginalId').value = model.id;
|
| 1614 |
+
document.getElementById('editModelId').value = model.id;
|
| 1615 |
+
document.getElementById('editModelName').value = model.name || '';
|
| 1616 |
+
document.getElementById('editModelDesc').value = model.description || '';
|
| 1617 |
+
document.getElementById('editContextLength').value = model.context_length || '';
|
| 1618 |
+
document.getElementById('editMaxTokens').value = model.max_tokens || '';
|
| 1619 |
+
|
| 1620 |
+
openModal('editModelModal');
|
| 1621 |
+
}
|
| 1622 |
+
|
| 1623 |
+
async function updateModel() {
|
| 1624 |
+
const originalId = document.getElementById('editModelOriginalId').value;
|
| 1625 |
+
const model = {
|
| 1626 |
+
name: document.getElementById('editModelName').value,
|
| 1627 |
+
description: document.getElementById('editModelDesc').value,
|
| 1628 |
+
context_length: parseInt(document.getElementById('editContextLength').value) || 32000,
|
| 1629 |
+
max_tokens: parseInt(document.getElementById('editMaxTokens').value) || 8096
|
| 1630 |
+
};
|
| 1631 |
+
|
| 1632 |
+
try {
|
| 1633 |
+
const res = await apiFetch(`${API_BASE}/api/models/${encodeURIComponent(originalId)}`, {
|
| 1634 |
+
method: 'PUT',
|
| 1635 |
+
headers: { 'Content-Type': 'application/json' },
|
| 1636 |
+
body: JSON.stringify(model)
|
| 1637 |
+
});
|
| 1638 |
+
const data = await res.json();
|
| 1639 |
+
|
| 1640 |
+
if (data.success) {
|
| 1641 |
+
showToast('模型更新成功', 'success');
|
| 1642 |
+
closeModal('editModelModal');
|
| 1643 |
+
loadModels();
|
| 1644 |
+
} else {
|
| 1645 |
+
showToast('更新失败: ' + (data.error || '未知错误'), 'error');
|
| 1646 |
+
}
|
| 1647 |
+
} catch (e) {
|
| 1648 |
+
showToast('更新失败: ' + e.message, 'error');
|
| 1649 |
+
}
|
| 1650 |
+
}
|
| 1651 |
+
|
| 1652 |
+
/**
|
| 1653 |
+
* 保存新模型
|
| 1654 |
+
* 从添加模型模态框获取数据并调用API创建新模型
|
| 1655 |
+
*/
|
| 1656 |
+
async function saveNewModel() {
|
| 1657 |
+
const modelId = document.getElementById('newModelId').value.trim();
|
| 1658 |
+
const modelName = document.getElementById('newModelName').value.trim();
|
| 1659 |
+
const modelDesc = document.getElementById('newModelDesc').value.trim();
|
| 1660 |
+
const contextLength = parseInt(document.getElementById('newContextLength').value) || 32000;
|
| 1661 |
+
const maxTokens = parseInt(document.getElementById('newMaxTokens').value) || 8096;
|
| 1662 |
+
|
| 1663 |
+
// 验证必填字段
|
| 1664 |
+
if (!modelId) {
|
| 1665 |
+
showToast('请输入模型ID', 'warning');
|
| 1666 |
+
return;
|
| 1667 |
+
}
|
| 1668 |
+
if (!modelName) {
|
| 1669 |
+
showToast('请输入模型名称', 'warning');
|
| 1670 |
+
return;
|
| 1671 |
+
}
|
| 1672 |
+
|
| 1673 |
+
const model = {
|
| 1674 |
+
id: modelId,
|
| 1675 |
+
name: modelName,
|
| 1676 |
+
description: modelDesc,
|
| 1677 |
+
context_length: contextLength,
|
| 1678 |
+
max_tokens: maxTokens
|
| 1679 |
+
};
|
| 1680 |
+
|
| 1681 |
+
try {
|
| 1682 |
+
const res = await apiFetch(`${API_BASE}/api/models`, {
|
| 1683 |
+
method: 'POST',
|
| 1684 |
+
headers: { 'Content-Type': 'application/json' },
|
| 1685 |
+
body: JSON.stringify(model)
|
| 1686 |
+
});
|
| 1687 |
+
const data = await res.json();
|
| 1688 |
+
|
| 1689 |
+
if (res.ok && (data.success || !data.error)) {
|
| 1690 |
+
showToast('模型添加成功', 'success');
|
| 1691 |
+
closeModal('addModelModal');
|
| 1692 |
+
// 清空表单
|
| 1693 |
+
document.getElementById('newModelId').value = '';
|
| 1694 |
+
document.getElementById('newModelName').value = '';
|
| 1695 |
+
document.getElementById('newModelDesc').value = '';
|
| 1696 |
+
document.getElementById('newContextLength').value = '';
|
| 1697 |
+
document.getElementById('newMaxTokens').value = '';
|
| 1698 |
+
loadModels();
|
| 1699 |
+
} else {
|
| 1700 |
+
throw new Error(data.error || '添加失败');
|
| 1701 |
+
}
|
| 1702 |
+
} catch (e) {
|
| 1703 |
+
showToast('添加模型失败: ' + e.message, 'error');
|
| 1704 |
+
}
|
| 1705 |
+
}
|
| 1706 |
+
|
| 1707 |
+
/**
|
| 1708 |
+
* 删除模型
|
| 1709 |
+
* @param {string} id - 模型ID
|
| 1710 |
+
*/
|
| 1711 |
+
async function deleteModelByIndex(index) {
|
| 1712 |
+
const model = modelsData[index];
|
| 1713 |
+
if (!model) return;
|
| 1714 |
+
const id = model.id;
|
| 1715 |
+
if (!confirm(`确定要删除模型 "${id}" 吗?此操作不可恢复。`)) {
|
| 1716 |
+
return;
|
| 1717 |
+
}
|
| 1718 |
+
|
| 1719 |
+
try {
|
| 1720 |
+
const res = await apiFetch(`${API_BASE}/api/models/${encodeURIComponent(id)}`, {
|
| 1721 |
+
method: 'DELETE'
|
| 1722 |
+
});
|
| 1723 |
+
const data = await res.json();
|
| 1724 |
+
|
| 1725 |
+
if (res.ok && (data.success || !data.error)) {
|
| 1726 |
+
showToast('模型删除成功', 'success');
|
| 1727 |
+
loadModels();
|
| 1728 |
+
} else {
|
| 1729 |
+
throw new Error(data.error || '删除失败');
|
| 1730 |
+
}
|
| 1731 |
+
} catch (e) {
|
| 1732 |
+
showToast('删除模型失败: ' + e.message, 'error');
|
| 1733 |
+
}
|
| 1734 |
+
}
|
| 1735 |
+
|
| 1736 |
+
// --- 系统设置 (Settings) ---
|
| 1737 |
+
async function loadConfig() {
|
| 1738 |
+
try {
|
| 1739 |
+
const res = await apiFetch(`${API_BASE}/api/config`);
|
| 1740 |
+
configData = await res.json();
|
| 1741 |
+
document.getElementById('proxyUrl').value = configData.proxy || '';
|
| 1742 |
+
const imageModeSelect = document.getElementById('imageOutputMode');
|
| 1743 |
+
if (imageModeSelect) {
|
| 1744 |
+
const mode = (configData.image_output_mode || 'url');
|
| 1745 |
+
imageModeSelect.value = mode === 'base64' ? 'base64' : 'url';
|
| 1746 |
+
}
|
| 1747 |
+
document.getElementById('configJson').value = JSON.stringify(configData, null, 2);
|
| 1748 |
+
} catch (e) {
|
| 1749 |
+
showToast('加载配置失败: ' + e.message, 'error');
|
| 1750 |
+
}
|
| 1751 |
+
}
|
| 1752 |
+
|
| 1753 |
+
async function loadLogLevel() {
|
| 1754 |
+
try {
|
| 1755 |
+
const res = await apiFetch(`${API_BASE}/api/logging`);
|
| 1756 |
+
const data = await res.json();
|
| 1757 |
+
const select = document.getElementById('logLevelSelect');
|
| 1758 |
+
if (select && data.level) {
|
| 1759 |
+
select.value = data.level;
|
| 1760 |
+
}
|
| 1761 |
+
} catch (e) {
|
| 1762 |
+
console.warn('日志级别加载失败', e);
|
| 1763 |
+
}
|
| 1764 |
+
}
|
| 1765 |
+
|
| 1766 |
+
async function updateLogLevel(level) {
|
| 1767 |
+
try {
|
| 1768 |
+
const res = await apiFetch(`${API_BASE}/api/logging`, {
|
| 1769 |
+
method: 'POST',
|
| 1770 |
+
headers: { 'Content-Type': 'application/json' },
|
| 1771 |
+
body: JSON.stringify({ level })
|
| 1772 |
+
});
|
| 1773 |
+
const data = await res.json();
|
| 1774 |
+
if (!res.ok || data.error) {
|
| 1775 |
+
throw new Error(data.error || '设置失败');
|
| 1776 |
+
}
|
| 1777 |
+
showToast(`日志级别已切换为 ${data.level}`, 'success');
|
| 1778 |
+
} catch (e) {
|
| 1779 |
+
showToast('日志级别设置失败: ' + e.message, 'error');
|
| 1780 |
+
}
|
| 1781 |
+
}
|
| 1782 |
+
|
| 1783 |
+
// --- Token 管理 ---
|
| 1784 |
+
async function loadTokens() {
|
| 1785 |
+
try {
|
| 1786 |
+
const res = await apiFetch(`${API_BASE}/api/tokens`);
|
| 1787 |
+
const data = await res.json();
|
| 1788 |
+
tokensData = data.tokens || [];
|
| 1789 |
+
renderTokens();
|
| 1790 |
+
} catch (e) {
|
| 1791 |
+
showToast('加载 Token 失败: ' + e.message, 'error');
|
| 1792 |
+
}
|
| 1793 |
+
}
|
| 1794 |
+
|
| 1795 |
+
function renderTokens() {
|
| 1796 |
+
const tbody = document.getElementById('tokensTableBody');
|
| 1797 |
+
if (!tbody) return;
|
| 1798 |
+
if (!tokensData.length) {
|
| 1799 |
+
tbody.innerHTML = `<tr><td colspan="2" class="empty-state">暂无 Token</td></tr>`;
|
| 1800 |
+
return;
|
| 1801 |
+
}
|
| 1802 |
+
tbody.innerHTML = tokensData.map(token => `
|
| 1803 |
+
<tr>
|
| 1804 |
+
<td><code>${token}</code></td>
|
| 1805 |
+
<td style="white-space: nowrap;">
|
| 1806 |
+
<button class="btn btn-outline btn-sm" data-token="${token}" onclick="copyToken(this.dataset.token)" title="复制Token">复制</button>
|
| 1807 |
+
<button class="btn btn-danger btn-sm" data-token="${token}" onclick="deleteToken(this.dataset.token)" title="删除Token">删除</button>
|
| 1808 |
+
</td>
|
| 1809 |
+
</tr>
|
| 1810 |
+
`).join('');
|
| 1811 |
+
}
|
| 1812 |
+
|
| 1813 |
+
async function addToken() {
|
| 1814 |
+
const manual = document.getElementById('manualToken').value.trim();
|
| 1815 |
+
try {
|
| 1816 |
+
const res = await apiFetch(`${API_BASE}/api/tokens`, {
|
| 1817 |
+
method: 'POST',
|
| 1818 |
+
headers: { 'Content-Type': 'application/json' },
|
| 1819 |
+
body: JSON.stringify(manual ? { token: manual } : {})
|
| 1820 |
+
});
|
| 1821 |
+
const data = await res.json();
|
| 1822 |
+
if (!res.ok || data.error) throw new Error(data.error || '创建失败');
|
| 1823 |
+
document.getElementById('manualToken').value = data.token;
|
| 1824 |
+
showToast('Token 创建成功', 'success');
|
| 1825 |
+
loadTokens();
|
| 1826 |
+
} catch (e) {
|
| 1827 |
+
showToast('创建 Token 失败: ' + e.message, 'error');
|
| 1828 |
+
}
|
| 1829 |
+
}
|
| 1830 |
+
|
| 1831 |
+
function generateToken() {
|
| 1832 |
+
if (window.crypto && crypto.randomUUID) {
|
| 1833 |
+
document.getElementById('manualToken').value = crypto.randomUUID().replace(/-/g, '');
|
| 1834 |
+
} else {
|
| 1835 |
+
document.getElementById('manualToken').value = Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2);
|
| 1836 |
+
}
|
| 1837 |
+
}
|
| 1838 |
+
|
| 1839 |
+
async function deleteToken(token) {
|
| 1840 |
+
if (!confirm('确定删除该 Token 吗?')) return;
|
| 1841 |
+
try {
|
| 1842 |
+
const res = await apiFetch(`${API_BASE}/api/tokens/${token}`, { method: 'DELETE' });
|
| 1843 |
+
const data = await res.json();
|
| 1844 |
+
if (!res.ok || data.error) throw new Error(data.error || '删除失败');
|
| 1845 |
+
showToast('Token 删除成功', 'success');
|
| 1846 |
+
loadTokens();
|
| 1847 |
+
} catch (e) {
|
| 1848 |
+
showToast('删除 Token 失败: ' + e.message, 'error');
|
| 1849 |
+
}
|
| 1850 |
+
}
|
| 1851 |
+
|
| 1852 |
+
function copyToken(token) {
|
| 1853 |
+
if (!token) {
|
| 1854 |
+
showToast('无效的 Token', 'warning');
|
| 1855 |
+
return;
|
| 1856 |
+
}
|
| 1857 |
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
| 1858 |
+
navigator.clipboard.writeText(token).then(() => {
|
| 1859 |
+
showToast('已复制', 'success');
|
| 1860 |
+
}).catch(() => {
|
| 1861 |
+
fallbackCopy(token);
|
| 1862 |
+
});
|
| 1863 |
+
} else {
|
| 1864 |
+
fallbackCopy(token);
|
| 1865 |
+
}
|
| 1866 |
+
}
|
| 1867 |
+
|
| 1868 |
+
function fallbackCopy(text) {
|
| 1869 |
+
try {
|
| 1870 |
+
const textarea = document.createElement('textarea');
|
| 1871 |
+
textarea.value = text;
|
| 1872 |
+
document.body.appendChild(textarea);
|
| 1873 |
+
textarea.select();
|
| 1874 |
+
document.execCommand('copy');
|
| 1875 |
+
document.body.removeChild(textarea);
|
| 1876 |
+
showToast('已复制', 'success');
|
| 1877 |
+
} catch (err) {
|
| 1878 |
+
showToast('复制失败', 'error');
|
| 1879 |
+
}
|
| 1880 |
+
}
|
| 1881 |
+
|
| 1882 |
+
function logoutAdmin() {
|
| 1883 |
+
localStorage.removeItem(ADMIN_TOKEN_KEY);
|
| 1884 |
+
document.cookie = 'admin_token=; Max-Age=0; path=/';
|
| 1885 |
+
showToast('已注销', 'success');
|
| 1886 |
+
updateLoginButton();
|
| 1887 |
+
}
|
| 1888 |
+
|
| 1889 |
+
function showLoginModal() {
|
| 1890 |
+
document.getElementById('loginPassword').value = '';
|
| 1891 |
+
openModal('loginModal');
|
| 1892 |
+
}
|
| 1893 |
+
|
| 1894 |
+
async function submitLogin() {
|
| 1895 |
+
const pwd = document.getElementById('loginPassword').value;
|
| 1896 |
+
if (!pwd) {
|
| 1897 |
+
showToast('请输入密码', 'warning');
|
| 1898 |
+
return;
|
| 1899 |
+
}
|
| 1900 |
+
try {
|
| 1901 |
+
const res = await fetch(`${API_BASE}/api/auth/login`, {
|
| 1902 |
+
method: 'POST',
|
| 1903 |
+
headers: { 'Content-Type': 'application/json' },
|
| 1904 |
+
body: JSON.stringify({ password: pwd })
|
| 1905 |
+
});
|
| 1906 |
+
const data = await res.json();
|
| 1907 |
+
if (!res.ok || data.error) {
|
| 1908 |
+
throw new Error(data.error || '登录失败');
|
| 1909 |
+
}
|
| 1910 |
+
localStorage.setItem(ADMIN_TOKEN_KEY, data.token);
|
| 1911 |
+
showToast('登录成功', 'success');
|
| 1912 |
+
closeModal('loginModal');
|
| 1913 |
+
loadAllData();
|
| 1914 |
+
updateLoginButton();
|
| 1915 |
+
} catch (e) {
|
| 1916 |
+
showToast('登录失败: ' + e.message, 'error');
|
| 1917 |
+
}
|
| 1918 |
+
}
|
| 1919 |
+
|
| 1920 |
+
async function saveSettings() {
|
| 1921 |
+
const proxyUrl = document.getElementById('proxyUrl').value;
|
| 1922 |
+
const imageModeSelect = document.getElementById('imageOutputMode');
|
| 1923 |
+
const imageOutputMode = imageModeSelect ? imageModeSelect.value : 'url';
|
| 1924 |
+
try {
|
| 1925 |
+
const res = await apiFetch(`${API_BASE}/api/config`, {
|
| 1926 |
+
method: 'PUT',
|
| 1927 |
+
headers: { 'Content-Type': 'application/json' },
|
| 1928 |
+
body: JSON.stringify({ proxy: proxyUrl, image_output_mode: imageOutputMode })
|
| 1929 |
+
});
|
| 1930 |
+
if (!res.ok) throw new Error((await res.json()).detail);
|
| 1931 |
+
showToast('设置保存成功!', 'success');
|
| 1932 |
+
loadConfig();
|
| 1933 |
+
} catch (e) {
|
| 1934 |
+
showToast('保存失败: ' + e.message, 'error');
|
| 1935 |
+
}
|
| 1936 |
+
}
|
| 1937 |
+
|
| 1938 |
+
async function testProxy() {
|
| 1939 |
+
const proxyUrl = document.getElementById('proxyUrl').value;
|
| 1940 |
+
const proxyStatus = document.getElementById('proxyStatus');
|
| 1941 |
+
proxyStatus.textContent = '测试中...';
|
| 1942 |
+
proxyStatus.style.color = 'var(--text-muted)';
|
| 1943 |
+
try {
|
| 1944 |
+
const res = await apiFetch(`${API_BASE}/api/proxy/test`, {
|
| 1945 |
+
method: 'POST',
|
| 1946 |
+
headers: { 'Content-Type': 'application/json' },
|
| 1947 |
+
body: JSON.stringify({ proxy: proxyUrl })
|
| 1948 |
+
});
|
| 1949 |
+
const data = await res.json();
|
| 1950 |
+
if (res.ok && data.success) {
|
| 1951 |
+
proxyStatus.textContent = `测试成功! (${data.delay_ms}ms)`;
|
| 1952 |
+
proxyStatus.style.color = 'var(--success)';
|
| 1953 |
+
} else {
|
| 1954 |
+
throw new Error(data.detail);
|
| 1955 |
+
}
|
| 1956 |
+
} catch (e) {
|
| 1957 |
+
proxyStatus.textContent = `测试失败: ${e.message}`;
|
| 1958 |
+
proxyStatus.style.color = 'var(--danger)';
|
| 1959 |
+
}
|
| 1960 |
+
}
|
| 1961 |
+
|
| 1962 |
+
function refreshConfig() {
|
| 1963 |
+
loadConfig();
|
| 1964 |
+
showToast('配置已刷新', 'info');
|
| 1965 |
+
}
|
| 1966 |
+
|
| 1967 |
+
function downloadConfig() {
|
| 1968 |
+
const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(configData, null, 2));
|
| 1969 |
+
const downloadAnchorNode = document.createElement('a');
|
| 1970 |
+
downloadAnchorNode.setAttribute("href", dataStr);
|
| 1971 |
+
downloadAnchorNode.setAttribute("download", "business_gemini_session.json");
|
| 1972 |
+
document.body.appendChild(downloadAnchorNode);
|
| 1973 |
+
downloadAnchorNode.click();
|
| 1974 |
+
downloadAnchorNode.remove();
|
| 1975 |
+
showToast('配置文件已开始下载', 'success');
|
| 1976 |
+
}
|
| 1977 |
+
|
| 1978 |
+
function uploadConfig() {
|
| 1979 |
+
document.getElementById('configFileInput').click();
|
| 1980 |
+
}
|
| 1981 |
+
|
| 1982 |
+
function handleConfigUpload(event) {
|
| 1983 |
+
const file = event.target.files[0];
|
| 1984 |
+
if (!file) return;
|
| 1985 |
+
const reader = new FileReader();
|
| 1986 |
+
reader.onload = async (e) => {
|
| 1987 |
+
try {
|
| 1988 |
+
const newConfig = JSON.parse(e.target.result);
|
| 1989 |
+
const res = await apiFetch(`${API_BASE}/api/config/import`, {
|
| 1990 |
+
method: 'POST',
|
| 1991 |
+
headers: { 'Content-Type': 'application/json' },
|
| 1992 |
+
body: JSON.stringify(newConfig)
|
| 1993 |
+
});
|
| 1994 |
+
if (!res.ok) throw new Error((await res.json()).detail);
|
| 1995 |
+
showToast('配置导入成功!', 'success');
|
| 1996 |
+
loadAllData();
|
| 1997 |
+
} catch (err) {
|
| 1998 |
+
showToast('导入失败: ' + err.message, 'error');
|
| 1999 |
+
}
|
| 2000 |
+
};
|
| 2001 |
+
reader.readAsText(file);
|
| 2002 |
+
}
|
| 2003 |
+
|
| 2004 |
+
// --- 模态框控制 ---
|
| 2005 |
+
function openModal(modalId) {
|
| 2006 |
+
const modal = document.getElementById(modalId);
|
| 2007 |
+
if (modal) modal.classList.add('show');
|
| 2008 |
+
}
|
| 2009 |
+
|
| 2010 |
+
function closeModal(modalId) {
|
| 2011 |
+
const modal = document.getElementById(modalId);
|
| 2012 |
+
if (modal) modal.classList.remove('show');
|
| 2013 |
+
}
|
| 2014 |
+
|
| 2015 |
+
document.querySelectorAll('.modal').forEach(modal => {
|
| 2016 |
+
modal.addEventListener('click', (e) => {
|
| 2017 |
+
if (e.target.classList.contains('modal')) {
|
| 2018 |
+
closeModal(modal.id);
|
| 2019 |
+
}
|
| 2020 |
+
});
|
| 2021 |
+
});
|
| 2022 |
+
</script>
|
| 2023 |
+
|
| 2024 |
+
</body>
|
| 2025 |
+
</html>
|
requirements-hf.txt
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Hugging Face Spaces requirements
|
| 2 |
+
flask>=2.0.0
|
| 3 |
+
flask-cors>=3.0.0
|
| 4 |
+
requests>=2.25.0
|
| 5 |
+
urllib3>=1.26.0
|