feat: 添加代理支持 (HTTP/SOCKS5)

为应用程序添加了通过代理服务器访问 Gemini API 的功能。

主要变更包括:

*   **配置**:
    *   在 `.env.example` 和 `app/config/config.py` 中添加了 `PROXIES` 配置项,允许用户指定一个或多个 HTTP 或 SOCKS5 代理服务器列表。
    *   更新 `README.md` 以包含关于代理配置的说明。
*   **后端**:
    *   修改 `app/service/client/api_client.py` 中的 `GeminiApiClient`,使其在发起请求时能从配置的 `PROXIES` 列表中随机选择一个代理使用。
    *   添加了 `app/log/logger.py` 中的 `get_api_client_logger`,用于记录 API 客户端(包括代理使用)的相关日志。
*   **前端**:
    *   在 `app/templates/config_editor.html` 配置编辑器页面添加了代理列表的显示区域和“添加代理”按钮。
    *   实现了用于批量添加代理的模态框 UI。
    *   在 `app/static/js/config_editor.js` 中添加了处理代理列表显示、打开/关闭模态框以及处理批量添加代理(包括提取、去重和更新 UI)的 JavaScript 逻辑。
    *   确保在初始化配置时为 `PROXIES` 设置默认空列表。

此功能使得用户可以在需要通过代理访问外部网络的环境下使用该应用。
This commit is contained in:
snaily
2025-04-30 10:57:17 +08:00
parent e9d19de7c6
commit 7da9110704
7 changed files with 151 additions and 7 deletions

View File

@@ -23,6 +23,9 @@ CHECK_INTERVAL_HOURS=1
TIMEZONE=Asia/Shanghai
# 请求超时时间(秒)
TIME_OUT=300
# 代理服务器配置 (支持 http 和 socks5)
# 示例: PROXIES=["http://user:pass@host:port", "socks5://host:port"]
PROXIES=[]
#########################image_generate 相关配置###########################
PAID_KEY=AIzaSyxxxxxxxxxxxxxxxxxxx
CREATE_IMAGE_MODEL=imagen-3.0-generate-002

View File

@@ -67,6 +67,7 @@ app/
>镜像地址: docker pull ghcr.io/snailyp/gemini-balance:latest
* **模型列表自动维护**: 支持openai和gemini模型列表获取与newapi自动获取模型列表完美兼容无需手动填写。
* **支持移除不使用的模型**: 默认提供的模型太多,很多用不上,可以通过`FILTERED_MODELS`过滤掉。
* **代理支持**: 支持配置 HTTP/SOCKS5 代理服务器 (`PROXIES`),用于访问 Gemini API方便在特殊网络环境下使用。支持批量添加代理。
## 🚀 快速开始
@@ -166,6 +167,7 @@ app/
| `CHECK_INTERVAL_HOURS` | 可选,检查禁用 Key 是否恢复的时间间隔 (小时) | `1` |
| `TIMEZONE` | 可选,应用程序使用的时区 | `Asia/Shanghai` |
| `TIME_OUT` | 可选,请求超时时间 (秒) | `300` |
| `PROXIES` | 可选,代理服务器列表 (例如 `http://user:pass@host:port`, `socks5://host:port`) | `[]` |
| `LOG_LEVEL` | 可选,日志级别,例如 DEBUG, INFO, WARNING, ERROR, CRITICAL | `INFO` |
| **图像生成相关** | | |
| `PAID_KEY` | 可选付费版API Key用于图片生成等高级功能 | `your-paid-api-key` |

View File

@@ -30,7 +30,8 @@ class Settings(BaseSettings):
TEST_MODEL: str = DEFAULT_MODEL
TIME_OUT: int = DEFAULT_TIMEOUT
MAX_RETRIES: int = MAX_RETRIES
PROXIES: List[str] = [] # 新增:代理服务器列表
# 模型相关配置
SEARCH_MODELS: List[str] = ["gemini-2.0-flash-exp"]
IMAGE_MODELS: List[str] = ["gemini-2.0-flash-exp"]

View File

@@ -207,5 +207,10 @@ def get_update_logger():
def get_scheduler_routes():
return Logger.setup_logger("scheduler_routes")
def get_message_converter_logger():
return Logger.setup_logger("message_converter")
def get_api_client_logger():
return Logger.setup_logger("api_client")

View File

@@ -2,10 +2,13 @@
from typing import Dict, Any, AsyncGenerator
import httpx
import random
from abc import ABC, abstractmethod
from app.config.config import settings
from app.log.logger import get_api_client_logger
from app.core.constants import DEFAULT_TIMEOUT
logger = get_api_client_logger()
class ApiClient(ABC):
"""API客户端基类"""
@@ -41,7 +44,12 @@ class GeminiApiClient(ApiClient):
timeout = httpx.Timeout(self.timeout, read=self.timeout)
model = self._get_real_model(model)
async with httpx.AsyncClient(timeout=timeout) as client:
proxy_to_use = None
if settings.PROXIES:
proxy_to_use = random.choice(settings.PROXIES)
logger.info(f"using proxy: {proxy_to_use}")
async with httpx.AsyncClient(timeout=timeout, proxy=proxy_to_use) as client: # 修改:直接传递代理字符串
url = f"{self.base_url}/models/{model}:generateContent?key={api_key}"
response = await client.post(url, json=payload)
if response.status_code != 200:
@@ -53,7 +61,12 @@ class GeminiApiClient(ApiClient):
timeout = httpx.Timeout(self.timeout, read=self.timeout)
model = self._get_real_model(model)
async with httpx.AsyncClient(timeout=timeout) as client:
proxy_to_use = None
if settings.PROXIES:
proxy_to_use = random.choice(settings.PROXIES)
logger.info(f"using proxy: {proxy_to_use}")
async with httpx.AsyncClient(timeout=timeout, proxy=proxy_to_use) as client: # 修改:直接传递代理字符串
url = f"{self.base_url}/models/{model}:streamGenerateContent?alt=sse&key={api_key}"
async with client.stream(method="POST", url=url, json=payload) as response:
if response.status_code != 200:

View File

@@ -63,7 +63,16 @@ document.addEventListener('DOMContentLoaded', function() {
const cancelBulkDeleteApiKeyBtn = document.getElementById('cancelBulkDeleteApiKeyBtn'); // 新增
const confirmBulkDeleteApiKeyBtn = document.getElementById('confirmBulkDeleteApiKeyBtn'); // 新增
const bulkDeleteApiKeyInput = document.getElementById('bulkDeleteApiKeyInput'); // 新增
// --- 新增Proxy 模态框相关 ---
const proxyModal = document.getElementById('proxyModal');
const addProxyBtn = document.getElementById('addProxyBtn'); // Changed from bulkAddProxyBtn
const closeProxyModalBtn = document.getElementById('closeProxyModalBtn');
const cancelAddProxyBtn = document.getElementById('cancelAddProxyBtn');
const confirmAddProxyBtn = document.getElementById('confirmAddProxyBtn');
const proxyBulkInput = document.getElementById('proxyBulkInput');
// --- 结束Proxy 模态框相关 ---
// --- 新增:重置确认模态框相关 ---
const resetConfirmModal = document.getElementById('resetConfirmModal');
const closeResetModalBtn = document.getElementById('closeResetModalBtn');
@@ -111,8 +120,11 @@ document.addEventListener('DOMContentLoaded', function() {
if (event.target == bulkDeleteApiKeyModal) { // 新增对批量删除模态框的处理
bulkDeleteApiKeyModal.classList.remove('show');
}
if (event.target == proxyModal) { // 新增对代理模态框的处理
proxyModal.classList.remove('show');
}
});
// 确认添加 API Key
if (confirmAddApiKeyBtn) {
confirmAddApiKeyBtn.addEventListener('click', handleBulkAddApiKeys);
@@ -158,7 +170,42 @@ document.addEventListener('DOMContentLoaded', function() {
}
// --- 结束:批量删除 API Key 相关 ---
// --- 结束API Key 相关 ---
// --- 新增Proxy 模态框事件 ---
// 打开模态框 (Changed event listener to addProxyBtn)
if (addProxyBtn) {
addProxyBtn.addEventListener('click', () => {
if (proxyModal) {
proxyModal.classList.add('show');
}
if (proxyBulkInput) proxyBulkInput.value = ''; // 清空输入框
});
}
// 关闭模态框 (X 按钮)
if (closeProxyModalBtn) {
closeProxyModalBtn.addEventListener('click', () => {
if (proxyModal) {
proxyModal.classList.remove('show');
}
});
}
// 关闭模态框 (取消按钮)
if (cancelAddProxyBtn) {
cancelAddProxyBtn.addEventListener('click', () => {
if (proxyModal) {
proxyModal.classList.remove('show');
}
});
}
// 确认添加 Proxy
if (confirmAddProxyBtn) {
confirmAddProxyBtn.addEventListener('click', handleBulkAddProxies);
}
// --- 结束Proxy 模态框事件 ---
// --- 新增:重置确认模态框事件监听 (移到 DOMContentLoaded 内部) ---
if (closeResetModalBtn) {
closeResetModalBtn.addEventListener('click', () => {
@@ -265,6 +312,10 @@ async function initConfig() {
if (!config.FILTERED_MODELS || !Array.isArray(config.FILTERED_MODELS) || config.FILTERED_MODELS.length === 0) {
config.FILTERED_MODELS = ['gemini-1.0-pro-latest'];
}
// --- 新增:处理 PROXIES 默认值 ---
if (!config.PROXIES || !Array.isArray(config.PROXIES)) {
config.PROXIES = []; // 默认为空数组
}
// --- 新增:处理新字段的默认值 ---
if (!config.THINKING_MODELS || !Array.isArray(config.THINKING_MODELS)) {
config.THINKING_MODELS = []; // 默认为空数组
@@ -296,6 +347,7 @@ async function initConfig() {
SEARCH_MODELS: ['gemini-1.5-flash-latest'],
FILTERED_MODELS: ['gemini-1.0-pro-latest'],
UPLOAD_PROVIDER: 'smms',
PROXIES: [], // 添加默认值
THINKING_MODELS: [],
THINKING_BUDGET_MAP: {}
};
@@ -521,6 +573,42 @@ function handleBulkDeleteApiKeys() {
bulkDeleteTextarea.value = '';
}
// --- 新增:处理批量添加 Proxy 的逻辑 ---
function handleBulkAddProxies() {
const proxyBulkInput = document.getElementById('proxyBulkInput');
const proxyContainer = document.getElementById('PROXIES_container');
const proxyModal = document.getElementById('proxyModal');
if (!proxyBulkInput || !proxyContainer || !proxyModal) return;
const bulkText = proxyBulkInput.value;
// 匹配 http(s):// 或 socks5:// 格式的代理,允许包含用户名密码
const proxyRegex = /(?:https?|socks5):\/\/(?:[^:@\/]+(?::[^@\/]+)?@)?(?:[^:\/\s]+)(?::\d+)?/g;
const extractedProxies = bulkText.match(proxyRegex) || [];
// 获取当前已有的 proxies
const currentProxyInputs = proxyContainer.querySelectorAll('.array-input');
const currentProxies = Array.from(currentProxyInputs).map(input => input.value).filter(proxy => proxy.trim() !== '');
// 合并并去重
const combinedProxies = new Set([...currentProxies, ...extractedProxies]);
const uniqueProxies = Array.from(combinedProxies);
// 清空现有列表显示
const existingItems = proxyContainer.querySelectorAll('.array-item');
existingItems.forEach(item => item.remove());
// 重新填充列表
uniqueProxies.forEach(proxy => {
addArrayItemWithValue('PROXIES', proxy);
});
// 关闭模态框
proxyModal.classList.remove('show');
showNotification(`添加/更新了 ${uniqueProxies.length} 个唯一代理`, 'success');
}
// --- 结束:处理批量添加 Proxy 的逻辑 ---
// 切换标签
function switchTab(tabId) {
// 更新标签按钮状态

View File

@@ -182,6 +182,19 @@
<input type="number" id="MAX_RETRIES" name="MAX_RETRIES" min="0" max="10" class="w-full px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50">
<small class="text-gray-500 mt-1 block">API请求失败后的最大重试次数</small>
</div>
<!-- 代理服务器列表 -->
<div class="mb-6">
<label for="PROXIES" class="block font-semibold mb-2 text-gray-700">代理服务器列表</label>
<div class="array-container bg-white rounded-lg border border-gray-200 p-4 mb-2" id="PROXIES_container">
<!-- 代理项将在这里动态添加 -->
</div>
<div class="flex justify-end gap-2">
<button type="button" class="bg-primary-600 hover:bg-primary-700 text-white px-4 py-2 rounded-lg font-medium transition-all duration-200 flex items-center gap-2" id="addProxyBtn">
<i class="fas fa-plus"></i> 添加代理
</button>
</div>
<small class="text-gray-500 mt-1 block">代理服务器列表,支持 http 和 socks5 格式,例如: http://user:pass@host:port 或 socks5://host:port。点击按钮可批量添加。</small>
</div>
</div>
<!-- 模型相关配置 -->
@@ -511,6 +524,25 @@
</div>
</div>
</div>
<!-- Proxy Add Modal -->
<div id="proxyModal" class="modal">
<div class="w-full max-w-lg mx-auto bg-white rounded-2xl shadow-2xl overflow-hidden animate-fade-in">
<div class="p-6">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold text-gray-800">批量添加代理服务器</h2>
<button id="closeProxyModalBtn" class="text-gray-400 hover:text-gray-600 text-xl">&times;</button>
</div>
<p class="text-gray-600 mb-4">每行粘贴一个或多个代理地址,将自动提取有效地址并去重。</p>
<textarea id="proxyBulkInput" rows="10" placeholder="在此处粘贴代理地址 (例如 http://user:pass@host:port 或 socks5://host:port)..." class="w-full px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50 font-mono text-sm"></textarea>
<div class="flex justify-end gap-3 mt-6">
<button type="button" id="confirmAddProxyBtn" class="bg-primary-600 hover:bg-primary-700 text-white px-6 py-2 rounded-lg font-medium transition">确认添加</button>
<button type="button" id="cancelAddProxyBtn" class="bg-gray-200 hover:bg-gray-300 text-gray-700 px-6 py-2 rounded-lg font-medium transition">取消</button>
</div>
</div>
</div>
</div>
<!-- Reset Confirmation Modal -->
<div id="resetConfirmModal" class="modal">
<div class="w-full max-w-md mx-auto bg-white rounded-2xl shadow-2xl overflow-hidden animate-fade-in">