feat(update): 实现应用内更新检查和版本显示

- 新增 `VERSION` 文件用于跟踪当前应用版本 (当前为 2.0.7)。
- 创建 `app/service/update/update_service.py` 服务,用于:
    - 从 `VERSION` 文件读取当前版本。
    - 通过 GitHub API 获取指定仓库 (`GITHUB_REPO_OWNER`/`GITHUB_REPO_NAME`) 的最新 Release Tag。
    - 使用 `packaging` 库比较版本,判断是否有可用更新。
- 在应用启动 (`app/core/application.py`) 时异步调用更新检查服务。
- 将当前版本和更新检查结果(是否可用、最新版本号、错误信息)存储在 `app.state.update_info` 中,供模板使用。
- 在基础模板 (`app/templates/base.html`) 的页脚动态显示当前版本。
- 如果检测到新版本,在页脚显示更新提示和指向最新 Release 的链接。
- 如果更新检查失败,在页脚显示错误提示。
- 在 `app/config/config.py` 中添加 `GITHUB_REPO_OWNER` 和 `GITHUB_REPO_NAME` 配置项,并提供默认值。
- 在 `requirements.txt` 中添加 `packaging` 依赖。
- 添加 `update_service` 专用的 logger (`app/log/logger.py`)。
- 改进配置编辑器 (`config_editor.js`, `config_editor.html`):
    - 限制预算输入框 (`budget_map`) 的值在 0 到 24576 之间。
    - 移除了预算映射项的删除按钮(预算项应随模型列表自动增删)。
    - 更新了预算输入的提示文本。
This commit is contained in:
snaily
2025-04-19 23:45:33 +08:00
parent ef4a528611
commit c254077a66
9 changed files with 217 additions and 17 deletions

1
VERSION Normal file
View File

@@ -0,0 +1 @@
2.0.7

View File

@@ -21,7 +21,6 @@ from app.log.logger import Logger
class Settings(BaseSettings):
"""应用程序配置"""
# 数据库配置
MYSQL_HOST: str
MYSQL_PORT: int
@@ -69,6 +68,10 @@ class Settings(BaseSettings):
# 调度器配置
CHECK_INTERVAL_HOURS: int = 1 # 默认检查间隔为1小时
TIMEZONE: str = "Asia/Shanghai" # 默认时区
# github
GITHUB_REPO_OWNER: str = "snailyp"
GITHUB_REPO_NAME: str = "gemini-balance"
# 日志配置
LOG_LEVEL: str = "INFO" # 默认日志级别

View File

@@ -4,6 +4,7 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from app.config.config import settings, sync_initial_settings
from app.log.logger import get_application_logger
@@ -15,9 +16,41 @@ from app.core.initialization import initialize_app
from app.database.connection import connect_to_db, disconnect_from_db
from app.database.initialization import initialize_database
from app.scheduler.key_checker import start_scheduler, stop_scheduler # 导入调度器函数
from app.service.update.update_service import check_for_updates # 导入更新检查服务
logger = get_application_logger()
VERSION_FILE_PATH = "VERSION" # Path relative to project root
def _get_current_version(default_version: str = "0.0.0") -> str:
"""Reads the current version from the VERSION file."""
try:
# Assuming execution from project root d:/develop/pythonProjects/gemini-balance
with open(VERSION_FILE_PATH, 'r', encoding='utf-8') as f:
version = f.read().strip()
if not version:
logger.warning(f"VERSION file ('{VERSION_FILE_PATH}') is empty. Using default version '{default_version}'.")
return default_version
return version
except FileNotFoundError:
logger.warning(f"VERSION file not found at '{VERSION_FILE_PATH}'. Using default version '{default_version}'.")
return default_version
except IOError as e:
logger.error(f"Error reading VERSION file ('{VERSION_FILE_PATH}'): {e}. Using default version '{default_version}'.")
return default_version
# 初始化模板引擎,并添加全局变量
templates = Jinja2Templates(directory="app/templates")
# 定义一个函数来更新模板全局变量
def update_template_globals(app: FastAPI, update_info: dict):
# Jinja2Templates 实例没有直接更新全局变量的方法
# 我们需要在请求上下文中传递这些变量,或者修改 Jinja 环境
# 更简单的方法是将其存储在 app.state 中,并在渲染时传递
app.state.update_info = update_info
logger.info(f"Update info stored in app.state: {update_info}")
@asynccontextmanager
async def lifespan(app: FastAPI):
"""
@@ -44,11 +77,29 @@ async def lifespan(app: FastAPI):
logger.info("KeyManager initialized successfully")
except Exception as e:
logger.error(f"Failed to initialize application: {str(e)}")
raise
# 不重新抛出,允许应用继续运行,但记录错误
# raise # 取消注释以在初始化失败时停止应用
# 检查更新 (在核心初始化之后)
update_available, latest_version, error_message = await check_for_updates()
update_info = {
"update_available": update_available,
"latest_version": latest_version,
"error_message": error_message,
"current_version": _get_current_version() # Read from VERSION file
}
# 将更新信息存储在 app.state 中
app.state.update_info = update_info
logger.info(f"Update check completed. Info: {update_info}")
# 启动调度器 (如果初始化成功)
try:
start_scheduler()
logger.info("Scheduler started successfully.")
except Exception as e:
logger.error(f"Failed to start scheduler: {e}")
# 启动调度器
start_scheduler()
logger.info("Scheduler started successfully.")
yield # 应用程序运行期间
@@ -79,7 +130,15 @@ def create_app() -> FastAPI:
version="1.0.0",
lifespan=lifespan
)
# 初始化 app.state (如果尚未存在)
if not hasattr(app, "state"):
from starlette.datastructures import State
app.state = State()
# 确保 update_info 即使在 lifespan 之前访问也不会出错
app.state.update_info = {"update_available": False, "latest_version": None, "error_message": "Checking...", "current_version": _get_current_version()} # Read from VERSION file for initial state
# 配置静态文件
app.mount("/static", StaticFiles(directory="app/static"), name="static")

View File

@@ -199,4 +199,8 @@ def get_log_routes_logger():
def get_stats_logger():
return Logger.setup_logger("stats")
return Logger.setup_logger("stats")
def get_update_logger():
return Logger.setup_logger("update_service")

View File

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

View File

@@ -702,21 +702,30 @@ function createAndAppendBudgetMapItem(mapKey, mapValue, modelId) {
valueInput.value = isNaN(intValue) ? 0 : intValue;
valueInput.placeholder = '预算 (整数)';
valueInput.className = 'map-value-input w-24 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50';
valueInput.min = 0; // 添加最小值
valueInput.max = 24576; // 添加最大值
valueInput.addEventListener('input', function() {
this.value = this.value.replace(/[^0-9]/g, ''); // Remove non-digit characters
// 限制输入为0到24576之间的整数
let value = this.value.replace(/[^0-9]/g, '');
if (value !== '') {
value = parseInt(value, 10);
if (value < 0) value = 0;
if (value > 24576) value = 24576;
}
this.value = value;
});
// Remove Button - Disabled
const removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.className = 'remove-btn text-gray-300 cursor-not-allowed focus:outline-none';
removeBtn.innerHTML = '<i class="fas fa-trash-alt"></i>';
removeBtn.title = '请从上方模型列表删除';
removeBtn.disabled = true;
// Remove Button - Removed for budget map items
// const removeBtn = document.createElement('button');
// removeBtn.type = 'button';
// removeBtn.className = 'remove-btn text-gray-300 cursor-not-allowed focus:outline-none'; // Kept original class for reference
// removeBtn.innerHTML = '<i class="fas fa-trash-alt"></i>';
// removeBtn.title = '请从上方模型列表删除';
// removeBtn.disabled = true;
mapItem.appendChild(keyInput);
mapItem.appendChild(valueInput);
mapItem.appendChild(removeBtn);
// mapItem.appendChild(removeBtn); // Do not append the remove button
container.appendChild(mapItem);
}

View File

@@ -196,6 +196,20 @@
<a href="https://github.com/snailyp/gemini-balance" target="_blank" class="text-primary-600 hover:text-primary-800 transition duration-300">
<i class="fab fa-github"></i> GitHub
</a>
{% if request and request.app.state.update_info %}
{% set update_info = request.app.state.update_info %}
<span class="mx-1">|</span>
<span class="text-xs text-gray-500">v{{ update_info.current_version }}</span>
{% if update_info.update_available %}
<span class="mx-1">|</span>
<a href="https://github.com/snailyp/gemini-balance/releases/latest" target="_blank" class="text-yellow-600 hover:text-yellow-800 transition duration-300 animate-pulse">
<i class="fas fa-arrow-up"></i> 新版本: v{{ update_info.latest_version }}
</a>
{% elif update_info.error_message and update_info.error_message != 'Checking...' %}
<span class="mx-1">|</span>
<span class="text-xs text-red-500" title="{{ update_info.error_message }}">更新检查失败</span>
{% endif %}
{% endif %}
</div>
<!-- 通用JS -->

View File

@@ -293,7 +293,7 @@
<i class="fas fa-plus"></i> 添加预算映射
</button>
</div> -->
<small class="text-gray-500 mt-1 block">为每个思考模型设置预算(整数),此项与上方模型列表自动关联。</small>
<small class="text-gray-500 mt-1 block">为每个思考模型设置预算(整数,最大值 24576),此项与上方模型列表自动关联。</small>
</div>
</div>

View File

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