feat: enhance update dialog with progress tracking and localization updates

- Added advanced settings option in update dialog for better user control.
- Implemented detailed progress tracking for update stages including download size and speed.
- Updated localization files for English, Russian, and Chinese to include new strings for update progress and advanced settings.
- Improved UI for update dialog with better layout and responsiveness.
- Enhanced test coverage for update process including progress tracking.
This commit is contained in:
Soulter
2026-05-14 00:40:24 +08:00
parent 1b09132e4a
commit 37142fd253
9 changed files with 1104 additions and 230 deletions
+13 -2
View File
@@ -143,7 +143,14 @@ class AstrBotUpdator(RepoZipUpdator):
async def get_releases(self) -> list:
return await self.fetch_release_info(self.ASTRBOT_RELEASE_API)
async def update(self, reboot=False, latest=True, version=None, proxy="") -> None:
async def update(
self,
reboot=False,
latest=True,
version=None,
proxy="",
progress_callback=None,
) -> None:
update_data = await self.fetch_release_info(self.ASTRBOT_RELEASE_API, latest)
file_url = None
@@ -175,7 +182,11 @@ class AstrBotUpdator(RepoZipUpdator):
file_url = f"{proxy}/{file_url}"
try:
await self._download_file(file_url, "temp.zip")
await self._download_file(
file_url,
"temp.zip",
progress_callback=progress_callback,
)
logger.info("下载 AstrBot Core 更新文件完成,正在执行解压...")
self.unzip_file("temp.zip", self.MAIN_PATH)
except BaseException as e:
+100 -12
View File
@@ -1,4 +1,5 @@
import base64
import inspect
import logging
import os
import shutil
@@ -147,7 +148,20 @@ async def download_image_by_url(
raise e
async def download_file(url: str, path: str, show_progress: bool = False) -> None:
async def _emit_download_progress(progress_callback, payload: dict) -> None:
if not progress_callback:
return
result = progress_callback(payload)
if inspect.isawaitable(result):
await result
async def download_file(
url: str,
path: str,
show_progress: bool = False,
progress_callback=None,
) -> None:
"""从指定 url 下载文件到指定路径 path"""
try:
ssl_context = ssl.create_default_context(
@@ -168,6 +182,16 @@ async def download_file(url: str, path: str, show_progress: bool = False) -> Non
start_time = time.time()
if show_progress:
print(f"Downloading: {url} | Size: {total_size / 1024:.2f} KB")
await _emit_download_progress(
progress_callback,
{
"url": url,
"downloaded": 0,
"total": total_size,
"percent": 0,
"speed": 0,
},
)
with open(path, "wb") as f:
while True:
chunk = await resp.content.read(8192)
@@ -175,17 +199,38 @@ async def download_file(url: str, path: str, show_progress: bool = False) -> Non
break
f.write(chunk)
downloaded_size += len(chunk)
elapsed_time = (
time.time() - start_time
if time.time() - start_time > 0
else 1
)
speed = downloaded_size / 1024 / elapsed_time # KB/s
percent = downloaded_size / total_size if total_size > 0 else 0
await _emit_download_progress(
progress_callback,
{
"url": url,
"downloaded": downloaded_size,
"total": total_size,
"percent": percent,
"speed": speed,
},
)
if show_progress:
elapsed_time = (
time.time() - start_time
if time.time() - start_time > 0
else 1
)
speed = downloaded_size / 1024 / elapsed_time # KB/s
print(
f"\rProgress: {downloaded_size / total_size:.2%} Speed: {speed:.2f} KB/s",
f"\rProgress: {percent:.2%} Speed: {speed:.2f} KB/s",
end="",
)
await _emit_download_progress(
progress_callback,
{
"url": url,
"downloaded": downloaded_size,
"total": total_size,
"percent": 1,
"speed": 0,
},
)
except (aiohttp.ClientConnectorSSLError, aiohttp.ClientConnectorCertificateError):
# 关闭SSL验证(仅在证书验证失败时作为fallback)
logger.warning(
@@ -208,6 +253,16 @@ async def download_file(url: str, path: str, show_progress: bool = False) -> Non
start_time = time.time()
if show_progress:
print(f"Size: {total_size / 1024:.2f} KB | URL: {url}")
await _emit_download_progress(
progress_callback,
{
"url": url,
"downloaded": 0,
"total": total_size,
"percent": 0,
"speed": 0,
},
)
with open(path, "wb") as f:
while True:
chunk = await resp.content.read(8192)
@@ -215,13 +270,38 @@ async def download_file(url: str, path: str, show_progress: bool = False) -> Non
break
f.write(chunk)
downloaded_size += len(chunk)
elapsed_time = (
time.time() - start_time
if time.time() - start_time > 0
else 1
)
speed = downloaded_size / 1024 / elapsed_time # KB/s
percent = downloaded_size / total_size if total_size > 0 else 0
await _emit_download_progress(
progress_callback,
{
"url": url,
"downloaded": downloaded_size,
"total": total_size,
"percent": percent,
"speed": speed,
},
)
if show_progress:
elapsed_time = time.time() - start_time
speed = downloaded_size / 1024 / elapsed_time # KB/s
print(
f"\rProgress: {downloaded_size / total_size:.2%} Speed: {speed:.2f} KB/s",
f"\rProgress: {percent:.2%} Speed: {speed:.2f} KB/s",
end="",
)
await _emit_download_progress(
progress_callback,
{
"url": url,
"downloaded": downloaded_size,
"total": total_size,
"percent": 1,
"speed": 0,
},
)
if show_progress:
print()
@@ -268,6 +348,7 @@ async def download_dashboard(
latest: bool = True,
version: str | None = None,
proxy: str | None = None,
progress_callback=None,
) -> None:
"""下载管理面板文件"""
if path is None:
@@ -286,6 +367,7 @@ async def download_dashboard(
dashboard_release_url,
str(zip_path),
show_progress=True,
progress_callback=progress_callback,
)
except BaseException as _:
if latest:
@@ -312,12 +394,18 @@ async def download_dashboard(
dashboard_release_url,
str(zip_path),
show_progress=True,
progress_callback=progress_callback,
)
else:
url = f"https://github.com/AstrBotDevs/astrbot-release-harbour/releases/download/release-{version}/dist.zip"
logger.info(f"Downloading AstrBot WebUI from {url}")
if proxy:
url = f"{proxy}/{url}"
await download_file(url, str(zip_path), show_progress=True)
await download_file(
url,
str(zip_path),
show_progress=True,
progress_callback=progress_callback,
)
with zipfile.ZipFile(zip_path, "r") as z:
z.extractall(extract_path)
+49 -1
View File
@@ -1,6 +1,8 @@
import inspect
import os
import re
import shutil
import time
import zipfile
from pathlib import Path
from typing import NoReturn
@@ -53,18 +55,64 @@ class RepoZipUpdator:
return body[:max_len] + "...[truncated]"
async def _download_file(
self, url: str, path: str, timeout: float = 1800.0
self,
url: str,
path: str,
timeout: float = 1800.0,
progress_callback=None,
) -> None:
target_path = Path(path)
ensure_dir(target_path.parent)
async def _emit_progress(payload: dict) -> None:
if not progress_callback:
return
result = progress_callback(payload)
if inspect.isawaitable(result):
await result
try:
async with self._create_httpx_client(timeout=timeout) as client:
async with client.stream("GET", url) as response:
response.raise_for_status()
headers = getattr(response, "headers", {})
total_size = int(headers.get("content-length", 0))
downloaded_size = 0
start_time = time.time()
await _emit_progress(
{
"url": url,
"downloaded": 0,
"total": total_size,
"percent": 0,
"speed": 0,
},
)
with target_path.open("wb") as file:
async for chunk in response.aiter_bytes(8192):
file.write(chunk)
downloaded_size += len(chunk)
elapsed_time = max(time.time() - start_time, 1)
await _emit_progress(
{
"url": url,
"downloaded": downloaded_size,
"total": total_size,
"percent": downloaded_size / total_size
if total_size > 0
else 0,
"speed": downloaded_size / 1024 / elapsed_time,
},
)
await _emit_progress(
{
"url": url,
"downloaded": downloaded_size,
"total": total_size,
"percent": 1,
"speed": 0,
},
)
except Exception as e:
logger.error(f"下载文件失败: {url} -> {target_path}, 错误: {e}")
if self.rm_on_error and target_path.exists():
+189 -5
View File
@@ -1,4 +1,5 @@
import traceback
import uuid
from quart import request
@@ -24,6 +25,7 @@ class UpdateRoute(Route):
super().__init__(context)
self.routes = {
"/update/check": ("GET", self.check_update),
"/update/progress": ("GET", self.get_update_progress),
"/update/releases": ("GET", self.get_releases),
"/update/do": ("POST", self.update_project),
"/update/dashboard": ("POST", self.update_dashboard),
@@ -32,8 +34,104 @@ class UpdateRoute(Route):
}
self.astrbot_updator = astrbot_updator
self.core_lifecycle = core_lifecycle
self.update_progress: dict[str, dict] = {}
self.register_routes()
def _init_update_progress(self, progress_id: str, version: str) -> None:
self.update_progress[progress_id] = {
"id": progress_id,
"status": "running",
"stage": "preparing",
"version": version or "latest",
"message": "正在准备更新...",
"overall_percent": 0,
"stages": {
"dashboard": self._empty_stage("pending"),
"core": self._empty_stage("pending"),
},
}
@staticmethod
def _empty_stage(status: str = "pending") -> dict:
return {
"status": status,
"downloaded": 0,
"total": 0,
"percent": 0,
"speed": 0,
}
def _set_update_stage(
self,
progress_id: str,
stage: str,
status: str,
message: str,
overall_percent: int | None = None,
) -> None:
progress = self.update_progress.get(progress_id)
if not progress:
return
progress["stage"] = stage
progress["message"] = message
progress["stages"].setdefault(stage, self._empty_stage())
progress["stages"][stage]["status"] = status
if overall_percent is not None:
progress["overall_percent"] = overall_percent
@staticmethod
def _normalize_percent(value) -> int:
try:
percent = float(value or 0)
except (TypeError, ValueError):
return 0
if percent <= 1:
percent *= 100
return max(0, min(100, int(percent)))
def _make_progress_callback(
self,
progress_id: str,
stage: str,
stage_start: int,
stage_weight: int,
):
def _callback(payload: dict) -> None:
progress = self.update_progress.get(progress_id)
if not progress:
return
stage_percent = self._normalize_percent(payload.get("percent"))
progress["stage"] = stage
progress["stages"][stage] = {
"status": "running" if stage_percent < 100 else "done",
"downloaded": payload.get("downloaded", 0),
"total": payload.get("total", 0),
"percent": stage_percent,
"speed": payload.get("speed", 0),
}
progress["overall_percent"] = min(
99,
stage_start + int(stage_percent * stage_weight / 100),
)
return _callback
async def get_update_progress(self):
progress_id = request.args.get("id", "")
if not progress_id:
return Response().error("缺少参数 id。").__dict__
progress = self.update_progress.get(progress_id)
if not progress:
return (
Response()
.ok(
{"id": progress_id, "status": "idle"},
"没有正在进行的更新。",
)
.__dict__
)
return Response().ok(progress).__dict__
async def do_migration(self):
need_migration = await check_migration_needed_v4(self.core_lifecycle.db)
if not need_migration:
@@ -89,6 +187,7 @@ class UpdateRoute(Route):
data = await request.json
version = data.get("version", "")
reboot = data.get("reboot", True)
progress_id = data.get("progress_id") or uuid.uuid4().hex
if version == "" or version == "latest":
latest = True
version = ""
@@ -99,33 +198,112 @@ class UpdateRoute(Route):
if proxy:
proxy = proxy.removesuffix("/")
self._init_update_progress(progress_id, version)
try:
self._set_update_stage(
progress_id,
"dashboard",
"running",
"正在下载 WebUI...",
0,
)
await download_dashboard(
latest=latest,
version=version,
proxy=proxy,
progress_callback=self._make_progress_callback(
progress_id,
"dashboard",
0,
45,
),
)
self._set_update_stage(
progress_id,
"dashboard",
"done",
"WebUI 下载完成。",
45,
)
self._set_update_stage(
progress_id,
"core",
"running",
"正在下载 AstrBot 项目代码...",
45,
)
await self.astrbot_updator.update(
latest=latest,
version=version,
proxy=proxy,
progress_callback=self._make_progress_callback(
progress_id,
"core",
45,
45,
),
)
self._set_update_stage(
progress_id,
"core",
"done",
"项目代码下载完成。",
90,
)
try:
await download_dashboard(latest=latest, version=version, proxy=proxy)
except Exception as e:
logger.error(f"下载管理面板文件失败: {e}。")
# pip 更新依赖
self._set_update_stage(
progress_id,
"dependencies",
"running",
"正在更新依赖...",
92,
)
logger.info("更新依赖中...")
try:
await pip_installer.install(requirements_path="requirements.txt")
except Exception as e:
logger.error(f"更新依赖失败: {e}")
self._set_update_stage(
progress_id,
"dependencies",
"done",
"依赖更新完成。",
96,
)
if reboot:
self._set_update_stage(
progress_id,
"restart",
"running",
"更新成功,正在准备重启...",
98,
)
await self.core_lifecycle.restart()
self.update_progress[progress_id].update(
{
"status": "success",
"stage": "done",
"message": "更新成功,AstrBot 将在 2 秒内全量重启以应用新的代码。",
"overall_percent": 100,
},
)
ret = (
Response()
.ok(None, "更新成功,AstrBot 将在 2 秒内全量重启以应用新的代码。")
.__dict__
)
return ret, 200, CLEAR_SITE_DATA_HEADERS
self.update_progress[progress_id].update(
{
"status": "success",
"stage": "done",
"message": "更新成功,AstrBot 将在下次启动时应用新的代码。",
"overall_percent": 100,
},
)
ret = (
Response()
.ok(None, "更新成功,AstrBot 将在下次启动时应用新的代码。")
@@ -133,6 +311,12 @@ class UpdateRoute(Route):
)
return ret, 200, CLEAR_SITE_DATA_HEADERS
except Exception as e:
self.update_progress[progress_id].update(
{
"status": "error",
"message": e.__str__(),
},
)
logger.error(f"/api/update_project: {traceback.format_exc()}")
return Response().error(e.__str__()).__dict__
@@ -23,6 +23,7 @@
"tabs": {
"release": "😊 Release"
},
"advancedSettings": "Advanced settings",
"updateToLatest": "Update to Latest Version",
"preRelease": "Pre-release",
"preReleaseWarning": {
@@ -45,7 +46,19 @@
"switch": "Switch"
},
"releaseNotes": {
"title": "Release Notes"
"title": "Release Notes",
"latestLabel": "Latest version"
},
"progress": {
"target": "Target version: ",
"preparing": "Preparing update...",
"dashboard": "Download WebUI",
"core": "Download project code",
"dependencies": "Updating dependencies...",
"restart": "Preparing restart...",
"restarting": "Restarting AstrBot",
"completed": "Update complete",
"failed": "Update failed"
},
"redirectConfirm": {
"title": "Leaving AstrBot",
@@ -75,6 +88,7 @@
"currentVersion": "Current Version",
"hasNewVersion": "New version available!",
"isLatest": "Already the latest version.",
"fallback": "Use this to re-download the WebUI if the full update path fails.",
"downloadAndUpdate": "Download and Update"
}
},
+124 -110
View File
@@ -1,113 +1,127 @@
{
"logoTitle": "Панель управления AstrBot",
"version": {
"hasNewVersion": "Доступна новая версия AstrBot!",
"dashboardHasNewVersion": "Доступна новая версия WebUI!"
},
"buttons": {
"update": "Обновить",
"account": "Аккаунт",
"theme": {
"light": "Светлая тема",
"dark": "Темная тема"
}
},
"updateDialog": {
"title": "Обновить AstrBot",
"currentVersion": "Текущая версия",
"status": {
"checking": "Проверка обновлений...",
"switching": "Переключение версии...",
"updating": "Обновление..."
},
"tabs": {
"release": "😊 Релиз"
},
"updateToLatest": "Обновить до последней версии",
"preRelease": "Предварительная версия",
"preReleaseWarning": {
"title": "Внимание: предварительная версия",
"description": "Версии с меткой Pre-release могут содержать неизвестные ошибки. Не рекомендуется использовать в рабочих средах. Если вы обнаружили ошибку, пожалуйста, сообщите о ней в ",
"issueLink": "GitHub Issues"
},
"tip": "💡 ПОДСКАЗКА: ",
"tipContinue": "По умолчанию при переключении версии загружаются соответствующие файлы WebUI. Код WebUI находится в директории dashboard, вы можете собрать его самостоятельно с помощью npm.",
"dockerTip": "При переключении версии будет предпринята попытка обновить как основной процесс бота, так и панель управления. Если вы используете Docker, вы также можете обновить образ или использовать",
"dockerTipLink": "watchtower",
"dockerTipContinue": "для автоматического мониторинга и обновления.",
"table": {
"tag": "Тег",
"publishDate": "Дата публикации",
"content": "Содержание",
"sourceUrl": "Исходный код",
"actions": "Действия",
"view": "Просмотр",
"switch": "Переключить"
},
"releaseNotes": {
"title": "Журнал изменений"
},
"redirectConfirm": {
"title": "Переход по ссылке",
"message": "Вы будете перенаправлены на страницу GitHub Releases. Продолжить?",
"latestLabel": "Последняя версия",
"targetVersion": "Целевая версия:",
"currentVersion": "Текущая версия:",
"guideTitle": "Рекомендации после перехода:",
"guideStep1": "Загрузите пакет, соответствующий архитектуре вашей системы.",
"guideStep2": "После завершения установки перезапустите AstrBot.",
"guideStep3": "Если вы используете Docker, отдайте приоритет обновлению через образ."
},
"desktopApp": {
"title": "Обновить десктопное приложение",
"message": "Проверка и обновление десктопной версии AstrBot.",
"currentVersion": "Текущая версия:",
"latestVersion": "Последняя версия:",
"checking": "Проверка обновлений десктопного приложения...",
"hasNewVersion": "Найдена новая версия. Нажмите для подтверждения обновления.",
"isLatest": "Установлена последняя версия",
"installing": "Загрузка и установка обновления... Приложение будет перезапущено автоматически.",
"checkFailed": "Ошибка проверки обновлений. Попробуйте позже.",
"installFailed": "Ошибка обновления. Попробуйте позже."
},
"dashboardUpdate": {
"title": "Обновить только панель управления",
"currentVersion": "Текущая версия",
"hasNewVersion": "Доступна новая версия!",
"isLatest": "Установлена последняя версия.",
"downloadAndUpdate": "Скачать и обновить"
}
},
"accountDialog": {
"title": "Изменить аккаунт",
"securityWarning": "Безопасность: Пожалуйста, смените пароль для защиты аккаунта",
"securityWarningLegacy": "Новая версия AstrBot улучшила безопасность. Пожалуйста, измените пароль.",
"securityWarningUpgrade": "Обнаружено устаревшее хранение пароля. Измените пароль, чтобы завершить обновление безопасности.",
"form": {
"currentPassword": "Текущий пароль",
"newPassword": "Новый пароль",
"confirmPassword": "Подтвердите новый пароль",
"newUsername": "Новое имя пользователя (опционально)",
"passwordHint": "Не менее 8 символов, включая заглавные и строчные буквы, а также цифры",
"confirmPasswordHint": "Введите новый пароль еще раз",
"usernameHint": "Оставьте пустым, если не хотите менять имя пользователя",
"defaultCredentials": "Имя пользователя по умолчанию — astrbot. Начальный пароль генерируется при первом запуске и отображается в консоли."
},
"validation": {
"passwordRequired": "Введите пароль",
"passwordMinLength": "Пароль должен быть не менее 8 символов",
"passwordUppercase": "Пароль должен содержать хотя бы одну заглавную букву",
"passwordLowercase": "Пароль должен содержать хотя бы одну строчную букву",
"passwordDigit": "Пароль должен содержать хотя бы одну цифру",
"passwordMatch": "Паролы не совпадают",
"usernameMinLength": "Имя пользователя должно быть не менее 3 символов"
},
"actions": {
"save": "Сохранить изменения",
"cancel": "Отмена"
},
"messages": {
"updateFailed": "Ошибка обновления, попробуйте еще раз"
}
"logoTitle": "Панель управления AstrBot",
"version": {
"hasNewVersion": "Доступна новая версия AstrBot!",
"dashboardHasNewVersion": "Доступна новая версия WebUI!"
},
"buttons": {
"update": "Обновить",
"account": "Аккаунт",
"theme": {
"light": "Светлая тема",
"dark": "Темная тема"
}
},
"updateDialog": {
"title": "Обновить AstrBot",
"currentVersion": "Текущая версия",
"status": {
"checking": "Проверка обновлений...",
"switching": "Переключение версии...",
"updating": "Обновление..."
},
"tabs": {
"release": "😊 Релиз"
},
"advancedSettings": "Расширенные настройки",
"updateToLatest": "Обновить до последней версии",
"preRelease": "Предварительная версия",
"preReleaseWarning": {
"title": "Внимание: предварительная версия",
"description": "Версии с меткой Pre-release могут содержать неизвестные ошибки. Не рекомендуется использовать в рабочих средах. Если вы обнаружили ошибку, пожалуйста, сообщите о ней в ",
"issueLink": "GitHub Issues"
},
"tip": "💡 ПОДСКАЗКА: ",
"tipContinue": "По умолчанию при переключении версии загружаются соответствующие файлы WebUI. Код WebUI находится в директории dashboard, вы можете собрать его самостоятельно с помощью npm.",
"dockerTip": "При переключении версии будет предпринята попытка обновить как основной процесс бота, так и панель управления. Если вы используете Docker, вы также можете обновить образ или использовать",
"dockerTipLink": "watchtower",
"dockerTipContinue": "для автоматического мониторинга и обновления.",
"table": {
"tag": "Тег",
"publishDate": "Дата публикации",
"content": "Содержание",
"sourceUrl": "Исходный код",
"actions": "Действия",
"view": "Просмотр",
"switch": "Переключить"
},
"releaseNotes": {
"title": "Журнал изменений",
"latestLabel": "Последняя версия"
},
"progress": {
"target": "Целевая версия:",
"preparing": "Подготовка обновления...",
"dashboard": "Загрузка WebUI",
"core": "Загрузка кода проекта",
"dependencies": "Обновление зависимостей...",
"restart": "Подготовка перезапуска...",
"restarting": "Перезапуск AstrBot",
"completed": "Обновление завершено",
"failed": "Ошибка обновления"
},
"redirectConfirm": {
"title": "Переход по ссылке",
"message": "Вы будете перенаправлены на страницу GitHub Releases. Продолжить?",
"latestLabel": "Последняя версия",
"targetVersion": "Целевая версия:",
"currentVersion": "Текущая версия:",
"guideTitle": "Рекомендации после перехода:",
"guideStep1": "Загрузите пакет, соответствующий архитектуре вашей системы.",
"guideStep2": "После завершения установки перезапустите AstrBot.",
"guideStep3": "Если вы используете Docker, отдайте приоритет обновлению через образ."
},
"desktopApp": {
"title": "Обновить десктопное приложение",
"message": "Проверка и обновление десктопной версии AstrBot.",
"currentVersion": "Текущая версия:",
"latestVersion": "Последняя версия:",
"checking": "Проверка обновлений десктопного приложения...",
"hasNewVersion": "Найдена новая версия. Нажмите для подтверждения обновления.",
"isLatest": "Установлена последняя версия",
"installing": "Загрузка и установка обновления... Приложение будет перезапущено автоматически.",
"checkFailed": "Ошибка проверки обновлений. Попробуйте позже.",
"installFailed": "Ошибка обновления. Попробуйте позже."
},
"dashboardUpdate": {
"title": "Обновить только панель управления",
"currentVersion": "Текущая версия",
"hasNewVersion": "Доступна новая версия!",
"isLatest": "Установлена последняя версия.",
"fallback": "Используйте это для повторной загрузки WebUI, если полное обновление завершилось ошибкой.",
"downloadAndUpdate": "Скачать и обновить"
}
},
"accountDialog": {
"title": "Изменить аккаунт",
"securityWarning": "Безопасность: Пожалуйста, смените пароль для защиты аккаунта",
"securityWarningLegacy": "Новая версия AstrBot улучшила безопасность. Пожалуйста, измените пароль.",
"securityWarningUpgrade": "Обнаружено устаревшее хранение пароля. Измените пароль, чтобы завершить обновление безопасности.",
"form": {
"currentPassword": "Текущий пароль",
"newPassword": "Новый пароль",
"confirmPassword": "Подтвердите новый пароль",
"newUsername": "Новое имя пользователя (опционально)",
"passwordHint": "Не менее 8 символов, включая заглавные и строчные буквы, а также цифры",
"confirmPasswordHint": "Введите новый пароль еще раз",
"usernameHint": "Оставьте пустым, если не хотите менять имя пользователя",
"defaultCredentials": "Имя пользователя по умолчанию — astrbot. Начальный пароль генерируется при первом запуске и отображается в консоли."
},
"validation": {
"passwordRequired": "Введите пароль",
"passwordMinLength": "Пароль должен быть не менее 8 символов",
"passwordUppercase": "Пароль должен содержать хотя бы одну заглавную букву",
"passwordLowercase": "Пароль должен содержать хотя бы одну строчную букву",
"passwordDigit": "Пароль должен содержать хотя бы одну цифру",
"passwordMatch": "Паролы не совпадают",
"usernameMinLength": "Имя пользователя должно быть не менее 3 символов"
},
"actions": {
"save": "Сохранить изменения",
"cancel": "Отмена"
},
"messages": {
"updateFailed": "Ошибка обновления, попробуйте еще раз"
}
}
}
@@ -23,6 +23,7 @@
"tabs": {
"release": "😊 正式版"
},
"advancedSettings": "高级设置",
"updateToLatest": "更新到最新版本",
"preRelease": "预发布",
"preReleaseWarning": {
@@ -45,7 +46,19 @@
"switch": "切换"
},
"releaseNotes": {
"title": "更新日志"
"title": "更新日志",
"latestLabel": "最新版本"
},
"progress": {
"target": "目标版本:",
"preparing": "正在准备更新...",
"dashboard": "下载 WebUI",
"core": "下载项目代码",
"dependencies": "正在更新依赖...",
"restart": "正在准备重启...",
"restarting": "正在重启 AstrBot",
"completed": "更新完成",
"failed": "更新失败"
},
"redirectConfirm": {
"title": "即将跳转",
@@ -75,6 +88,7 @@
"currentVersion": "当前版本",
"hasNewVersion": "有新版本!",
"isLatest": "已经是最新版本了。",
"fallback": "完整更新异常时,可仅重新下载 WebUI 作为兜底。",
"downloadAndUpdate": "下载并更新"
}
},
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted } from "vue";
import { ref, computed, watch, onMounted, onUnmounted } from "vue";
import { useCustomizerStore } from "@/stores/customizer";
import axios from "axios";
import Logo from "@/components/shared/Logo.vue";
@@ -47,10 +47,54 @@ let hasNewVersion = ref(false);
let botCurrVersion = ref("");
let dashboardHasNewVersion = ref(false);
let dashboardCurrentVersion = ref("");
let version = ref("");
let releases = ref([]);
let releasesLoading = ref(false);
let updatingDashboardLoading = ref(false);
let installLoading = ref(false);
let showAdvancedUpdateSettings = ref(false);
let restartWaiting = ref(false);
let restartStartTime = ref<number | string | null>(null);
let restartPollTimer: ReturnType<typeof setInterval> | null = null;
type DownloadStageStatus = "pending" | "running" | "done" | "error";
type DownloadStage = {
status: DownloadStageStatus;
downloaded: number;
total: number;
percent: number;
speed: number;
};
type UpdateProgress = {
id: string;
status: "idle" | "running" | "success" | "error";
stage: string;
version: string;
message: string;
overall_percent: number;
stages: Record<string, DownloadStage>;
};
const createEmptyDownloadStage = (
status: DownloadStageStatus = "pending",
): DownloadStage => ({
status,
downloaded: 0,
total: 0,
percent: 0,
speed: 0,
});
const createEmptyUpdateProgress = (): UpdateProgress => ({
id: "",
status: "idle",
stage: "preparing",
version: "",
message: "",
overall_percent: 0,
stages: {
dashboard: createEmptyDownloadStage(),
core: createEmptyDownloadStage(),
},
});
let updateProgress = ref<UpdateProgress>(createEmptyUpdateProgress());
let updateProgressTimer: ReturnType<typeof setInterval> | null = null;
const isDesktopReleaseMode = ref(
typeof window !== "undefined" && !!window.astrbotDesktop?.isDesktop,
);
@@ -98,9 +142,49 @@ const releasesHeader = computed(() => [
key: "published_at",
},
{ title: t("core.header.updateDialog.table.content"), key: "body" },
{ title: t("core.header.updateDialog.table.sourceUrl"), key: "zipball_url" },
{ title: t("core.header.updateDialog.table.actions"), key: "switch" },
]);
const firstReleasePageItems = computed(() => releases.value.slice(0, 6));
const firstReleasePageHasPreRelease = computed(() =>
firstReleasePageItems.value.some((item: any) => isPreRelease(item.tag_name)),
);
const updateStageItems = computed(() => [
{
key: "dashboard",
title: t("core.header.updateDialog.progress.dashboard"),
progress:
updateProgress.value.stages.dashboard || createEmptyDownloadStage(),
},
{
key: "core",
title: t("core.header.updateDialog.progress.core"),
progress: updateProgress.value.stages.core || createEmptyDownloadStage(),
},
]);
const updateProgressMessage = computed(() => {
if (updateProgress.value.status === "error") {
return (
updateProgress.value.message ||
t("core.header.updateDialog.progress.failed")
);
}
if (updateProgress.value.status === "success") {
return (
updateProgress.value.message ||
t("core.header.updateDialog.progress.completed")
);
}
if (updateProgress.value.stage === "dependencies") {
return t("core.header.updateDialog.progress.dependencies");
}
if (updateProgress.value.stage === "restart") {
return t("core.header.updateDialog.progress.restart");
}
return (
updateProgress.value.message ||
t("core.header.updateDialog.progress.preparing")
);
});
// Form validation
const formValid = ref(true);
const passwordRules = computed(() => [
@@ -262,7 +346,9 @@ function accountEdit() {
const currentPasswordValue = password.value ? password.value : "";
const newPasswordValue = newPassword.value ? newPassword.value : "";
const confirmPasswordValue = confirmPassword.value ? confirmPassword.value : "";
const confirmPasswordValue = confirmPassword.value
? confirmPassword.value
: "";
axios
.post("/api/auth/account/edit", {
@@ -324,7 +410,10 @@ function getVersion() {
accountWarningUpgrade.value = !!password_upgrade_required;
accountWarningLegacy.value =
!!legacy_pwd_hint && !password_upgrade_required;
if (change_pwd_hint || (legacy_pwd_hint && !password_upgrade_required)) {
if (
change_pwd_hint ||
(legacy_pwd_hint && !password_upgrade_required)
) {
localStorage.setItem("change_pwd_hint", "true");
} else {
localStorage.removeItem("change_pwd_hint");
@@ -396,6 +485,7 @@ function checkUpdate() {
}
function getReleases() {
releasesLoading.value = true;
return axios
.get("/api/update/releases")
.then((res) => {
@@ -406,31 +496,210 @@ function getReleases() {
})
.catch((err) => {
console.log(err);
})
.finally(() => {
releasesLoading.value = false;
});
}
function switchVersion(version: string) {
function formatDownloadSize(value: number) {
if (!value || value <= 0) {
return "-";
}
if (value < 1024 * 1024) {
return `${(value / 1024).toFixed(1)} KB`;
}
return `${(value / 1024 / 1024).toFixed(1)} MB`;
}
function formatDownloadSpeed(value: number) {
if (!value || value <= 0) {
return "-";
}
if (value < 1024) {
return `${value.toFixed(1)} KB/s`;
}
return `${(value / 1024).toFixed(1)} MB/s`;
}
function getStageStatusColor(status: DownloadStageStatus) {
if (status === "done") {
return "success";
}
if (status === "running") {
return "primary";
}
if (status === "error") {
return "error";
}
return "grey";
}
function getStageStatusIcon(status: DownloadStageStatus) {
if (status === "done") {
return "mdi-check-circle";
}
if (status === "running") {
return "mdi-progress-download";
}
if (status === "error") {
return "mdi-alert-circle";
}
return "mdi-circle-outline";
}
function stopUpdateProgressPolling() {
if (updateProgressTimer) {
clearInterval(updateProgressTimer);
updateProgressTimer = null;
}
}
function stopRestartPolling() {
if (restartPollTimer) {
clearInterval(restartPollTimer);
restartPollTimer = null;
}
}
async function fetchAstrBotStartTime() {
const res = await axios.get("/api/stat/start-time", { timeout: 3000 });
const startTime = res.data?.data?.start_time ?? null;
commonStore.startTime = startTime;
return startTime;
}
function waitForAstrBotRestart(initialStartTime: number | string | null) {
if (restartWaiting.value) {
return;
}
stopRestartPolling();
restartWaiting.value = true;
restartStartTime.value = initialStartTime;
updateProgress.value = {
...updateProgress.value,
stage: "restart",
status: "success",
message: t("core.header.updateDialog.progress.restarting"),
overall_percent: 100,
};
const poll = async () => {
try {
const currentStartTime = await fetchAstrBotStartTime();
if (
initialStartTime !== null &&
currentStartTime !== null &&
currentStartTime !== initialStartTime
) {
stopRestartPolling();
restartWaiting.value = false;
window.location.reload();
}
} catch (_error) {
// Backend may be unavailable while the process is restarting.
}
};
restartPollTimer = setInterval(() => {
void poll();
}, 1000);
}
function applyUpdateProgress(payload: UpdateProgress) {
updateProgress.value = {
...createEmptyUpdateProgress(),
...payload,
stages: {
...createEmptyUpdateProgress().stages,
...(payload.stages || {}),
},
};
if (payload.status === "success" || payload.status === "error") {
stopUpdateProgressPolling();
}
if (payload.status === "success") {
waitForAstrBotRestart(restartStartTime.value);
}
}
function startUpdateProgressPolling(progressId: string) {
stopUpdateProgressPolling();
const poll = () => {
axios
.get("/api/update/progress", { params: { id: progressId } })
.then((res) => {
if (res.data?.data) {
applyUpdateProgress(res.data.data);
}
})
.catch((err) => {
console.log(err);
});
};
poll();
updateProgressTimer = setInterval(poll, 800);
}
async function switchVersion(targetVersion: string) {
const progressId =
typeof crypto !== "undefined" && "randomUUID" in crypto
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(16).slice(2)}`;
let initialStartTime: number | string | null = null;
updateProgress.value = {
...createEmptyUpdateProgress(),
id: progressId,
status: "running",
version: targetVersion,
message: t("core.header.updateDialog.progress.preparing"),
} as UpdateProgress;
updateStatus.value = t("core.header.updateDialog.status.switching");
installLoading.value = true;
try {
initialStartTime = await fetchAstrBotStartTime();
} catch (_error) {
initialStartTime = commonStore.getStartTime();
}
restartStartTime.value = initialStartTime;
startUpdateProgressPolling(progressId);
axios
.post("/api/update/do", {
version: version,
version: targetVersion,
proxy: getSelectedGitHubProxy(),
progress_id: progressId,
})
.then((res) => {
updateStatus.value = res.data.message;
updateProgress.value = {
...updateProgress.value,
status:
res.data.status === "ok" ? "success" : updateProgress.value.status,
message: res.data.message,
overall_percent:
res.data.status === "ok" ? 100 : updateProgress.value.overall_percent,
};
if (res.data.status == "ok") {
setTimeout(() => {
window.location.reload();
}, 1000);
waitForAstrBotRestart(initialStartTime);
}
})
.catch((err) => {
console.log(err);
updateStatus.value = err;
updateProgress.value = {
...updateProgress.value,
status: "error",
message:
err?.response?.data?.message ||
err?.message ||
t("core.header.updateDialog.progress.failed"),
};
})
.finally(() => {
installLoading.value = false;
stopUpdateProgressPolling();
});
}
@@ -486,6 +755,11 @@ initPasswordWarningFromStorage();
commonStore.createEventSource(); // log
commonStore.getStartTime();
onUnmounted(() => {
stopUpdateProgressPolling();
stopRestartPolling();
});
// 视图模式切换
onMounted(() => {
// 初次加載時保存當前路由
@@ -865,12 +1139,14 @@ onMounted(async () => {
<!-- 更新对话框 -->
<v-dialog
v-model="updateStatusDialog"
:width="$vuetify.display.smAndDown ? '100%' : '1200'"
:width="$vuetify.display.smAndDown ? '100%' : '920'"
:fullscreen="$vuetify.display.xs"
>
<v-card>
<v-card-title class="mobile-card-title">
<span class="text-h5">{{ t("core.header.updateDialog.title") }}</span>
<span class="text-h3 pa-4">{{
t("core.header.updateDialog.title")
}}</span>
<v-btn
v-if="$vuetify.display.xs"
icon
@@ -881,62 +1157,142 @@ onMounted(async () => {
</v-card-title>
<v-card-text>
<v-container>
<v-progress-linear
v-show="installLoading"
class="mb-4"
indeterminate
color="primary"
></v-progress-linear>
<div>
<h1 style="display: inline-block">{{ botCurrVersion }}</h1>
<small style="margin-left: 4px">{{ updateStatus }}</small>
<div class="update-summary">
<div>
<div class="text-caption text-medium-emphasis">
{{ t("core.header.updateDialog.currentVersion") }}
</div>
<div class="text-h2 font-weight-bold">{{ botCurrVersion }}</div>
</div>
<v-chip
:color="hasNewVersion ? 'primary' : 'success'"
variant="tonal"
size="small"
>
{{
hasNewVersion
? t("core.header.version.hasNewVersion")
: updateStatus
}}
</v-chip>
</div>
<div
v-if="releaseMessage"
style="
background-color: #646cff24;
padding: 16px;
border-radius: 10px;
font-size: 14px;
max-height: 400px;
overflow-y: auto;
"
v-if="installLoading || updateProgress.status !== 'idle'"
class="update-progress-panel mt-5"
>
<MarkdownRender
:content="releaseMessage"
:typewriter="false"
class="markdown-content"
/>
<div v-if="restartWaiting" class="restart-waiting-panel">
<v-progress-circular
indeterminate
color="primary"
size="42"
width="4"
></v-progress-circular>
<div class="text-subtitle-1 font-weight-medium">
{{ t("core.header.updateDialog.progress.restarting") }}
</div>
</div>
<template v-else>
<div class="d-flex align-center justify-space-between mb-2">
<div>
<div class="text-subtitle-1 font-weight-medium">
{{ updateProgressMessage }}
</div>
<div class="text-caption text-medium-emphasis">
{{ t("core.header.updateDialog.progress.target") }}
{{ updateProgress.version || "latest" }}
</div>
</div>
<div class="text-h6 font-weight-bold">
{{ updateProgress.overall_percent }}%
</div>
</div>
<v-progress-linear
:model-value="updateProgress.overall_percent"
height="8"
rounded
color="primary"
></v-progress-linear>
<div class="update-stage-list mt-4">
<div
v-for="stage in updateStageItems"
:key="stage.key"
class="update-stage-row"
>
<v-icon
:icon="getStageStatusIcon(stage.progress.status)"
:color="getStageStatusColor(stage.progress.status)"
size="22"
></v-icon>
<div class="update-stage-content">
<div class="d-flex align-center justify-space-between">
<span class="font-weight-medium">{{
stage.title
}}</span>
<span class="text-caption">
{{ stage.progress.percent }}%
</span>
</div>
<v-progress-linear
:model-value="stage.progress.percent"
height="5"
rounded
:color="getStageStatusColor(stage.progress.status)"
class="mt-2"
></v-progress-linear>
<div class="update-stage-meta">
<span>
{{ formatDownloadSize(stage.progress.downloaded) }} /
{{ formatDownloadSize(stage.progress.total) }}
</span>
<span>{{
formatDownloadSpeed(stage.progress.speed)
}}</span>
</div>
</div>
</div>
</div>
</template>
</div>
<div class="mb-4 mt-4">
<small
>{{ t("core.header.updateDialog.tip") }}
{{ t("core.header.updateDialog.tipContinue") }}</small
>
<div v-if="releaseMessage && !installLoading" class="mt-5">
<div class="d-flex align-center justify-space-between mb-2">
<h3 class="text-subtitle-1 font-weight-medium">
{{ t("core.header.updateDialog.releaseNotes.title") }}
</h3>
<v-btn
variant="text"
size="small"
@click="
openReleaseNotesDialog(
releaseMessage,
t('core.header.updateDialog.releaseNotes.latestLabel'),
)
"
>
{{ t("core.header.updateDialog.table.view") }}
</v-btn>
</div>
<div class="release-message-preview">
<MarkdownRender
:content="releaseMessage"
:typewriter="false"
class="markdown-content"
/>
</div>
</div>
<!-- 发行版 -->
<div>
<div class="mb-4">
<small
>{{ t("core.header.updateDialog.dockerTip") }}
<a href="https://containrrr.dev/watchtower/usage-overview/">{{
t("core.header.updateDialog.dockerTipLink")
}}</a>
{{ t("core.header.updateDialog.dockerTipContinue") }}</small
>
</div>
<div class="mt-5">
<v-alert
v-if="
releases.some((item: any) => isPreRelease(item['tag_name']))
"
v-if="!installLoading && firstReleasePageHasPreRelease"
type="warning"
variant="tonal"
border="start"
density="compact"
class="mb-4"
>
<template v-slot:prepend>
<v-icon>mdi-alert-circle-outline</v-icon>
@@ -965,7 +1321,9 @@ onMounted(async () => {
:headers="releasesHeader"
:items="releases"
item-key="name"
:items-per-page="8"
:items-per-page="6"
density="comfortable"
:loading="releasesLoading"
>
<template v-slot:item.tag_name="{ item }: { item: any }">
<div class="d-flex align-center">
@@ -1002,9 +1360,10 @@ onMounted(async () => {
>
<v-btn
@click="switchVersion(item.tag_name)"
rounded="xl"
variant="plain"
variant="tonal"
color="primary"
size="small"
:disabled="installLoading"
>
{{ t("core.header.updateDialog.table.switch") }}
</v-btn>
@@ -1012,45 +1371,66 @@ onMounted(async () => {
</v-data-table>
</div>
<v-divider class="mt-4 mb-4"></v-divider>
<div style="margin-top: 16px">
<h3 class="mb-4">
{{ t("core.header.updateDialog.dashboardUpdate.title") }}
</h3>
<div class="mb-4">
<small
>{{
t("core.header.updateDialog.dashboardUpdate.currentVersion")
}}
{{ dashboardCurrentVersion }}</small
>
<br />
</div>
<div class="mb-4">
<p v-if="dashboardHasNewVersion">
{{
t("core.header.updateDialog.dashboardUpdate.hasNewVersion")
}}
</p>
<p v-else="dashboardHasNewVersion">
{{ t("core.header.updateDialog.dashboardUpdate.isLatest") }}
</p>
</div>
<v-btn
color="primary"
style="border-radius: 10px"
@click="updateDashboard()"
:disabled="!dashboardHasNewVersion"
:loading="updatingDashboardLoading"
<div v-if="!installLoading" class="advanced-update-settings mt-5">
<button
class="advanced-settings-toggle"
type="button"
@click="
showAdvancedUpdateSettings = !showAdvancedUpdateSettings
"
>
{{
t(
"core.header.updateDialog.dashboardUpdate.downloadAndUpdate",
)
}}
</v-btn>
<span>{{
t("core.header.updateDialog.advancedSettings")
}}</span>
<v-icon
:icon="
showAdvancedUpdateSettings
? 'mdi-chevron-down'
: 'mdi-chevron-right'
"
size="20"
></v-icon>
</button>
<div
v-if="showAdvancedUpdateSettings"
class="dashboard-update-banner mt-3"
>
<div>
<div class="font-weight-medium">
{{ t("core.header.updateDialog.dashboardUpdate.title") }}
</div>
<div class="text-caption text-medium-emphasis">
{{
t(
"core.header.updateDialog.dashboardUpdate.currentVersion",
)
}}
{{ dashboardCurrentVersion }}
</div>
<div class="text-caption text-medium-emphasis">
{{
dashboardHasNewVersion
? t(
"core.header.updateDialog.dashboardUpdate.hasNewVersion",
)
: t("core.header.updateDialog.dashboardUpdate.fallback")
}}
</div>
</div>
<v-btn
color="primary"
variant="tonal"
@click="updateDashboard()"
:loading="updatingDashboardLoading"
>
{{
t(
"core.header.updateDialog.dashboardUpdate.downloadAndUpdate",
)
}}
</v-btn>
</div>
</div>
</v-container>
</v-card-text>
@@ -1070,7 +1450,7 @@ onMounted(async () => {
<!-- Release Notes Modal -->
<v-dialog v-model="releaseNotesDialog" max-width="800">
<v-card>
<v-card-title class="text-h5">
<v-card-title class="text-h3 pa-4">
{{ t("core.header.updateDialog.releaseNotes.title") }}:
{{ selectedReleaseTag }}
</v-card-title>
@@ -1463,6 +1843,102 @@ onMounted(async () => {
align-items: center;
}
.update-summary {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
}
.update-progress-panel,
.dashboard-update-banner,
.release-message-preview {
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
border-radius: 8px;
padding: 16px;
}
.release-message-preview {
max-height: 220px;
overflow: hidden;
position: relative;
}
.release-message-preview::after {
content: "";
position: absolute;
right: 0;
bottom: 0;
left: 0;
height: 56px;
background: linear-gradient(
to bottom,
rgba(var(--v-theme-surface), 0),
rgb(var(--v-theme-surface))
);
pointer-events: none;
}
.dashboard-update-banner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.advanced-settings-toggle {
display: inline-flex;
align-items: center;
gap: 4px;
border: 0;
border-radius: 6px;
background: transparent;
color: rgb(var(--v-theme-on-surface));
cursor: pointer;
font: inherit;
font-weight: 500;
padding: 8px 0;
text-align: left;
}
.advanced-settings-toggle:hover {
color: rgb(var(--v-theme-primary));
}
.restart-waiting-panel {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 18px 0 22px;
}
.update-stage-list {
display: flex;
flex-direction: column;
gap: 14px;
}
.update-stage-row {
display: flex;
align-items: flex-start;
gap: 12px;
}
.update-stage-content {
flex: 1;
min-width: 0;
}
.update-stage-meta {
display: flex;
justify-content: space-between;
gap: 12px;
margin-top: 6px;
color: rgba(var(--v-theme-on-surface), 0.62);
font-size: 12px;
}
/* 移动端样式优化 */
@media (max-width: 600px) {
.logo-text {
@@ -1501,5 +1977,11 @@ onMounted(async () => {
.v-btn-toggle .v-icon {
font-size: 16px;
}
.update-summary,
.dashboard-update-banner {
align-items: stretch;
flex-direction: column;
}
}
</style>
+20 -1
View File
@@ -1738,13 +1738,22 @@ async def test_do_update(
# Use a temporary path for the mock update to avoid side effects
temp_release_dir = tmp_path_factory.mktemp("release")
release_path = temp_release_dir / "astrbot"
calls = []
async def mock_update(*args, **kwargs):
"""Mocks the update process by creating a directory in the temp path."""
calls.append("core")
callback = kwargs.get("progress_callback")
if callback:
callback({"downloaded": 10, "total": 10, "percent": 1, "speed": 1})
os.makedirs(release_path, exist_ok=True)
async def mock_download_dashboard(*args, **kwargs):
"""Mocks the dashboard download to prevent network access."""
calls.append("dashboard")
callback = kwargs.get("progress_callback")
if callback:
callback({"downloaded": 10, "total": 10, "percent": 1, "speed": 1})
return
async def mock_pip_install(*args, **kwargs):
@@ -1764,12 +1773,22 @@ async def test_do_update(
response = await test_client.post(
"/api/update/do",
headers=authenticated_header,
json={"version": "v3.4.0", "reboot": False},
json={"version": "v3.4.0", "reboot": False, "progress_id": "test-progress"},
)
assert response.status_code == 200
data = await response.get_json()
assert data["status"] == "ok"
assert os.path.exists(release_path)
assert calls[:2] == ["dashboard", "core"]
progress_response = await test_client.get(
"/api/update/progress?id=test-progress",
headers=authenticated_header,
)
progress_data = await progress_response.get_json()
assert progress_data["status"] == "ok"
assert progress_data["data"]["status"] == "success"
assert progress_data["data"]["overall_percent"] == 100
@pytest.mark.asyncio