feat:启动时检查webui版本并尝试自动更新

This commit is contained in:
SengokuCola
2026-05-07 13:06:05 +08:00
parent 57100797a5
commit 16ece263a6
12 changed files with 372 additions and 118 deletions

View File

@@ -1,7 +1,7 @@
{
"name": "maibot-dashboard",
"private": true,
"version": "1.0.6",
"version": "1.0.7",
"type": "module",
"main": "./out/main/index.js",
"scripts": {

View File

@@ -84,7 +84,7 @@ function DynamicConfigSection({
values: Record<string, unknown>
}) {
return (
<Card>
<Card className="min-w-0">
<CardHeader className="border-b border-border/50 pb-4">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
@@ -296,15 +296,15 @@ export const DynamicConfigForm: React.FC<DynamicConfigFormProps> = ({
row.length > 1 ? (
<div
key={row.map((field) => field.name).join('|')}
className="grid gap-4 py-1 md:grid-cols-[repeat(var(--field-row-count),minmax(0,1fr))]"
className="grid min-w-0 gap-4 py-1 md:grid-cols-[repeat(var(--field-row-count),minmax(0,1fr))]"
style={{ '--field-row-count': row.length } as React.CSSProperties}
>
{row.map((field) => (
<div key={field.name}>{renderField(field)}</div>
<div key={field.name} className="min-w-0">{renderField(field)}</div>
))}
</div>
) : (
<div key={row[0].name} className="py-1">{renderField(row[0])}</div>
<div key={row[0].name} className="min-w-0 py-1">{renderField(row[0])}</div>
)
))}
</>
@@ -322,7 +322,7 @@ export const DynamicConfigForm: React.FC<DynamicConfigFormProps> = ({
)
return (
<div className="space-y-6">
<div className="min-w-0 space-y-6">
{visibleFields.length > 0 && (
<div>
{renderFieldList(visibleFields)}
@@ -353,7 +353,7 @@ export const DynamicConfigForm: React.FC<DynamicConfigFormProps> = ({
const HookComponent = hookEntry.component
if (hookEntry.type === 'replace') {
return (
<div key={key}>
<div key={key} className="min-w-0">
<HookComponent
fieldPath={nestedFieldPath}
value={values[key]}
@@ -368,7 +368,7 @@ export const DynamicConfigForm: React.FC<DynamicConfigFormProps> = ({
}
return (
<div key={key}>
<div key={key} className="min-w-0">
<HookComponent
fieldPath={nestedFieldPath}
value={values[key]}
@@ -416,7 +416,7 @@ export const DynamicConfigForm: React.FC<DynamicConfigFormProps> = ({
}
return (
<Card key={key} className="border-border/70 bg-muted/20 shadow-none">
<Card key={key} className="min-w-0 border-border/70 bg-muted/20 shadow-none">
<CardHeader className="border-b border-border/50 px-4 py-3">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
@@ -449,7 +449,7 @@ export const DynamicConfigForm: React.FC<DynamicConfigFormProps> = ({
if (level === 0 && sectionColumns === 2 && visibleNestedSections.length > 1) {
return (
<div className="grid gap-4 md:grid-cols-2">
<div className="grid min-w-0 gap-4 md:grid-cols-2">
{visibleNestedSections}
</div>
)

View File

@@ -158,7 +158,7 @@ export const DynamicField: React.FC<DynamicFieldProps> = ({
const label = (
<Label
className={cn(
"inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap text-[15px] leading-6",
"inline-flex min-w-0 items-center gap-1.5 text-[15px] leading-6",
descriptionDisplay === 'label-hover' && fieldDescription && "cursor-help",
schema.advanced
? "text-sky-700 dark:text-sky-300"
@@ -166,7 +166,7 @@ export const DynamicField: React.FC<DynamicFieldProps> = ({
)}
>
{renderIcon()}
<span>{fieldLabel}</span>
<span className="break-words">{fieldLabel}</span>
{schema.required && <span className="text-destructive">*</span>}
</Label>
)
@@ -281,8 +281,8 @@ export const DynamicField: React.FC<DynamicFieldProps> = ({
const renderSwitch = () => {
const checked = Boolean(value)
return (
<div className="flex items-center justify-between gap-4 py-2">
<div className="pr-4">
<div className="flex min-w-0 items-center justify-between gap-4 py-2">
<div className="min-w-0 pr-4">
{renderFieldHeader()}
</div>
<Switch
@@ -303,7 +303,7 @@ export const DynamicField: React.FC<DynamicFieldProps> = ({
const step = schema.step ?? 1
return (
<div className="space-y-2">
<div className="min-w-0 space-y-2">
<Slider
value={[numValue]}
onValueChange={(values) => onChange(values[0])}
@@ -466,7 +466,7 @@ export const DynamicField: React.FC<DynamicFieldProps> = ({
className="flex flex-col gap-2 py-2 sm:flex-row sm:items-center"
style={{ '--field-input-width': schema['x-input-width'] ?? '12rem' } as React.CSSProperties}
>
<div className="shrink-0">
<div className="min-w-0 sm:shrink-0">
{renderFieldHeader()}
</div>
<div className="min-w-20 flex-1 sm:ml-auto sm:max-w-[var(--field-input-width)]">
@@ -477,7 +477,7 @@ export const DynamicField: React.FC<DynamicFieldProps> = ({
}
return (
<div className="space-y-2">
<div className="min-w-0 space-y-2">
{renderFieldHeader()}
{/* Input component */}

View File

@@ -87,12 +87,12 @@ export function Header({
return (
<header
className={cn(
'sticky top-0 isolate z-10 flex h-16 items-center justify-between border-b px-4 backdrop-blur-md',
'sticky top-0 isolate z-10 flex h-16 min-w-0 items-center justify-between gap-2 border-b px-3 backdrop-blur-md sm:px-4',
inheritsPageBackground ? 'bg-transparent' : 'bg-card/80'
)}
>
{!inheritsPageBackground && <BackgroundLayer config={headerBg} layerId="header" />}
<div className="relative z-10 flex items-center gap-4">
<div className="relative z-10 flex min-w-0 shrink-0 items-center gap-2 sm:gap-4">
{/* 移动端菜单按钮 */}
<button
onClick={onMobileMenuToggle}
@@ -122,7 +122,7 @@ export function Header({
</button>
</div>
<div className="relative z-10 flex items-center gap-2">
<div className="relative z-10 flex min-w-0 flex-1 items-center justify-end gap-1 sm:gap-2">
{/* 工作区切换:复用 Tabs 组件 + Motion 动画指示器 */}
<LayoutGroup id="workspace-switcher">
<Tabs value={workspaceMode} aria-label={t('workspace.switcherLabel')}>
@@ -165,7 +165,7 @@ export function Header({
</Tabs>
</LayoutGroup>
<div className="bg-border h-6 w-px" />
<div className="bg-border hidden h-6 w-px sm:block" />
{/* 后端切换按钮(仅 Electron */}
{isElectron() && (
<>
@@ -211,7 +211,7 @@ export function Header({
variant="ghost"
size="sm"
onClick={() => window.open('https://docs.mai-mai.org', '_blank')}
className="gap-2"
className="hidden gap-2 sm:inline-flex"
title={t('header.viewDocs')}
>
<BookOpen className="h-4 w-4" />
@@ -221,7 +221,7 @@ export function Header({
{/* 语言切换 */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="gap-2">
<Button variant="ghost" size="sm" className="gap-2 px-2 sm:px-3">
<Globe className="h-4 w-4" />
<span className="hidden text-xs sm:inline">
{LANGUAGE_NAMES[currentLang.split('-')[0] as 'zh' | 'en' | 'ja' | 'ko'] ??
@@ -259,14 +259,14 @@ export function Header({
</button>
{/* 分隔线 */}
<div className="bg-border h-6 w-px" />
<div className="bg-border hidden h-6 w-px sm:block" />
{/* 登出按钮 */}
<Button
variant="ghost"
size="sm"
onClick={handleLogout}
className="gap-2"
className="gap-2 px-2 sm:px-3"
title={t('header.logout')}
>
<LogOut className="h-4 w-4" />

View File

@@ -178,7 +178,7 @@ export function Layout({ children }: LayoutProps) {
)}
</AnimatePresence>
{/* Main content */}
<div className="flex flex-1 flex-col overflow-hidden">
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
{/* HTTP 安全警告横幅 */}
<HttpWarningBanner />
@@ -211,7 +211,7 @@ export function Layout({ children }: LayoutProps) {
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={workspaceMode}
className="relative z-10 h-full"
className="relative z-10 h-full min-w-0"
initial={{ opacity: 0, x: isChatWorkspace ? 32 : -32, filter: 'blur(6px)' }}
animate={{ opacity: 1, x: 0, filter: 'blur(0px)' }}
exit={{ opacity: 0, x: isChatWorkspace ? -32 : 32, filter: 'blur(6px)' }}

View File

@@ -5,7 +5,7 @@
* 修改此处的版本号后,所有展示版本的地方都会自动更新
*/
export const APP_VERSION = '1.0.6'
export const APP_VERSION = '1.0.7'
export const APP_NAME = 'MaiBot Dashboard'
export const APP_FULL_NAME = `${APP_NAME} v${APP_VERSION}`

View File

@@ -766,8 +766,8 @@ function BotConfigPageContent() {
}
return (
<ScrollArea className="h-full">
<div className="space-y-4 sm:space-y-6 p-4 sm:p-6">
<ScrollArea className="h-full min-w-0" scrollbars="vertical">
<div className="max-w-full space-y-4 overflow-x-hidden p-4 sm:space-y-6 sm:p-6">
{/* 页面标题 */}
<div className="flex flex-col gap-3 sm:gap-4">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
@@ -776,11 +776,11 @@ function BotConfigPageContent() {
<p className="text-muted-foreground mt-1 text-xs sm:text-sm"></p>
</div>
{/* 按钮组 - 桌面端靠右 */}
<div className="flex flex-wrap gap-2 flex-shrink-0 sm:justify-end">
<div className="flex w-full min-w-0 flex-wrap gap-2 sm:w-auto sm:flex-shrink-0 sm:justify-end">
<Tabs
value={editMode}
onValueChange={(v) => handleModeChange(v as 'visual' | 'source')}
className="w-full min-w-[13rem] sm:w-[14rem]"
className="w-full min-w-0 sm:w-[14rem]"
>
<TabsList className="grid h-8 w-full grid-cols-2 sm:h-9">
<TabsTrigger value="visual" className="px-2 text-xs">
@@ -798,7 +798,7 @@ function BotConfigPageContent() {
disabled={saving || autoSaving || isRestarting}
size="sm"
variant="outline"
className="w-20 sm:w-24"
className="min-w-0 flex-1 sm:w-24 sm:flex-none"
>
<RefreshCw className="h-3 w-3 sm:h-4 sm:w-4 mr-1" />
@@ -808,7 +808,7 @@ function BotConfigPageContent() {
disabled={saving || autoSaving || !hasUnsavedChanges || isRestarting}
size="sm"
variant="outline"
className="w-20 sm:w-24"
className="min-w-0 flex-1 sm:w-24 sm:flex-none"
>
<Save className="h-4 w-4 flex-shrink-0" strokeWidth={2} fill="none" />
<span className="ml-1 truncate text-xs sm:text-sm">
@@ -820,7 +820,7 @@ function BotConfigPageContent() {
<Button
disabled={saving || autoSaving || isRestarting}
size="sm"
className="w-20 sm:w-28"
className="min-w-0 flex-1 sm:w-28 sm:flex-none"
>
<Power className="h-4 w-4 flex-shrink-0" />
<span className="ml-1 truncate text-xs sm:text-sm">
@@ -1041,7 +1041,7 @@ function DynamicConfigTabs(props: DynamicConfigTabsProps) {
return (
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="flex flex-wrap h-auto gap-1 p-1 transition-all duration-300 ease-out">
<TabsList className="flex h-auto max-w-full justify-start gap-1 overflow-x-auto p-1 transition-all duration-300 ease-out sm:flex-wrap sm:overflow-x-visible">
{visibleTabGroups.map((tab) => {
const isExpandedOnlyTab = !DEFAULT_VISIBLE_TAB_IDS.has(tab.id)
return (
@@ -1052,7 +1052,7 @@ function DynamicConfigTabs(props: DynamicConfigTabsProps) {
<TabsTrigger
value={tab.id}
className={cn(
"px-2 py-1.5 text-sm transition-all duration-200 ease-out sm:px-3 sm:py-2 data-[state=active]:shadow-sm",
"shrink-0 px-2 py-1.5 text-sm transition-all duration-200 ease-out sm:px-3 sm:py-2 data-[state=active]:shadow-sm",
isExpandedOnlyTab &&
"border border-dashed border-border/70 bg-background/45 text-muted-foreground/80 motion-safe:animate-[config-tab-enter_180ms_ease-out_both] hover:bg-background/70 data-[state=active]:border-primary/45 data-[state=active]:bg-primary/10 data-[state=active]:text-primary data-[state=active]:shadow-none"
)}
@@ -1067,7 +1067,7 @@ function DynamicConfigTabs(props: DynamicConfigTabsProps) {
type="button"
variant="ghost"
size="sm"
className="group h-8 px-2 text-xs transition-all duration-200 ease-out sm:h-9 sm:px-3"
className="group h-8 shrink-0 px-2 text-xs transition-all duration-200 ease-out sm:h-9 sm:px-3"
onClick={toggleExpanded}
>
{expanded ? (
@@ -1082,7 +1082,7 @@ function DynamicConfigTabs(props: DynamicConfigTabsProps) {
type="button"
variant={advancedVisible ? 'default' : 'outline'}
size="sm"
className="ml-auto h-8 px-2 text-xs transition-all duration-200 ease-out sm:h-9 sm:px-3"
className="h-8 shrink-0 px-2 text-xs transition-all duration-200 ease-out sm:ml-auto sm:h-9 sm:px-3"
onClick={() => setAdvancedVisible((current) => !current)}
>

View File

@@ -56,7 +56,7 @@ MODEL_CONFIG_PATH: Path = (CONFIG_DIR / "model_config.toml").resolve().absolute(
LEGACY_ENV_PATH: Path = (PROJECT_ROOT / ".env").resolve().absolute()
A_MEMORIX_LEGACY_CONFIG_PATH: Path = (CONFIG_DIR / "a_memorix.toml").resolve().absolute()
MMC_VERSION: str = "1.0.0-pre.13"
CONFIG_VERSION: str = "8.10.9"
CONFIG_VERSION: str = "8.10.10"
MODEL_CONFIG_VERSION: str = "1.15.3"
logger = get_logger("config")

View File

@@ -3150,6 +3150,15 @@ class WebUIConfig(ConfigBase):
)
"""是否启用WebUI"""
auto_update_dashboard: bool = Field(
default=True,
json_schema_extra={
"x-widget": "switch",
"x-icon": "refresh-cw",
},
)
"""启动时是否自动检查并更新 WebUI dashboard"""
host: str = Field(
default="127.0.0.1",
json_schema_extra={

View File

@@ -1,24 +1,26 @@
from rich.traceback import install
from typing import TYPE_CHECKING
from rich.traceback import install
import asyncio
import time
from src.A_memorix.host_service import a_memorix_host_service
from src.learners.expression_auto_check_task import ExpressionAutoCheckTask
from src.emoji_system.emoji_manager import emoji_manager
from src.chat.message_receive.bot import chat_bot
from src.chat.message_receive.chat_manager import chat_manager
from src.chat.message_receive.bot import chat_bot
from src.chat.utils.statistic import OnlineTimeRecordTask, StatisticOutputTask
from src.common.i18n import t
from src.common.logger import get_logger
from src.common.message_server.server import Server, get_global_server
from src.config.config import config_manager, global_config
from src.emoji_system.emoji_manager import emoji_manager
from src.learners.expression_auto_check_task import ExpressionAutoCheckTask
from src.manager.async_task_manager import async_task_manager
from src.maisaka.display.stage_status_board import disable_stage_status_board, enable_stage_status_board
from src.plugin_runtime.integration import get_plugin_runtime_manager
from src.prompt.prompt_manager import prompt_manager
from src.services.memory_flow_service import memory_automation_service
from src.webui.dashboard_update import auto_update_dashboard_if_needed
# from src.api.main import start_api_server
@@ -45,9 +47,6 @@ class MainSystem:
self.server: Server = get_global_server()
self.webui_server: WebUIServer | None = None # 独立的 WebUI 服务器
# 设置独立的 WebUI 服务器
self._setup_webui_server()
def _setup_webui_server(self) -> None:
"""设置独立的 WebUI 服务器"""
from src.config.config import global_config
@@ -70,11 +69,30 @@ class MainSystem:
enable_stage_status_board()
logger.info(t("startup.waking_up", nickname=global_config.bot.nickname))
await self._auto_update_webui_dashboard()
# 设置独立的 WebUI 服务器
self._setup_webui_server()
# 其他初始化任务
await asyncio.gather(self._init_components())
logger.info(t("startup.initialization_completed_banner", nickname=global_config.bot.nickname))
async def _auto_update_webui_dashboard(self) -> None:
"""启动时自动检查并更新 WebUI dashboard。"""
if not global_config.webui.enabled:
return
if not global_config.webui.auto_update_dashboard:
logger.info("WebUI dashboard 自动更新已关闭")
return
result = await auto_update_dashboard_if_needed()
if result.updated:
logger.info(result.message)
elif result.checked:
logger.info(result.message)
async def _init_components(self) -> None:
"""初始化其他组件"""
init_start_time = time.time()

View File

@@ -0,0 +1,283 @@
"""WebUI dashboard 版本检查与自动更新。"""
from __future__ import annotations
from dataclasses import dataclass
from importlib.metadata import PackageNotFoundError, version as get_package_version
from pathlib import Path
from typing import Any, Dict, Literal, Optional
import asyncio
import os
import shutil
import subprocess
import sys
import time
import httpx
from src.common.logger import get_logger
logger = get_logger("webui_dashboard_update")
DASHBOARD_PACKAGE_NAME = "maibot-dashboard"
PYPI_JSON_URL = f"https://pypi.org/pypi/{DASHBOARD_PACKAGE_NAME}/json"
PYPI_PROJECT_URL = f"https://pypi.org/project/{DASHBOARD_PACKAGE_NAME}/"
PYPI_CACHE_TTL_SECONDS = 60 * 60 * 6
PackageRunner = Literal["uv", "pip", "unknown"]
_pypi_version_cache: Dict[str, Any] = {"checked_at": 0.0, "latest_version": None}
@dataclass(frozen=True)
class DashboardVersionInfo:
"""WebUI dashboard 版本检查结果。"""
current_version: str
latest_version: Optional[str]
has_update: bool
package_name: str = DASHBOARD_PACKAGE_NAME
pypi_url: str = PYPI_PROJECT_URL
@dataclass(frozen=True)
class DashboardUpdateResult:
"""WebUI dashboard 自动更新结果。"""
checked: bool
updated: bool
current_version: str
latest_version: Optional[str]
runner: PackageRunner
message: str
def get_installed_dashboard_version() -> str:
try:
return get_package_version(DASHBOARD_PACKAGE_NAME)
except PackageNotFoundError:
return "unknown"
def normalize_version(version: str) -> tuple[int, ...]:
clean_version = version.strip().lower().removeprefix("v")
numeric_part = clean_version.split("-", 1)[0].split("+", 1)[0]
parts = []
for item in numeric_part.split("."):
number = ""
for char in item:
if not char.isdigit():
break
number += char
parts.append(int(number) if number else 0)
return tuple(parts)
def is_newer_version(latest_version: Optional[str], current_version: str) -> bool:
if not latest_version or not current_version or current_version == "unknown":
return False
latest_parts = normalize_version(latest_version)
current_parts = normalize_version(current_version)
width = max(len(latest_parts), len(current_parts))
return latest_parts + (0,) * (width - len(latest_parts)) > current_parts + (0,) * (width - len(current_parts))
async def get_latest_dashboard_version_from_pypi() -> Optional[str]:
now = time.time()
cached_version = _pypi_version_cache.get("latest_version")
checked_at = float(_pypi_version_cache.get("checked_at", 0.0))
if cached_version and now - checked_at < PYPI_CACHE_TTL_SECONDS:
return str(cached_version)
try:
async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.get(PYPI_JSON_URL)
response.raise_for_status()
payload = response.json()
except Exception as e:
logger.debug(f"检查 WebUI PyPI 版本失败: {e}")
return str(cached_version) if cached_version else None
latest_version = payload.get("info", {}).get("version")
if isinstance(latest_version, str) and latest_version.strip():
_pypi_version_cache["checked_at"] = now
_pypi_version_cache["latest_version"] = latest_version.strip()
return latest_version.strip()
return str(cached_version) if cached_version else None
async def get_dashboard_version_info(current_version: Optional[str] = None) -> DashboardVersionInfo:
resolved_current_version = current_version or get_installed_dashboard_version()
latest_version = await get_latest_dashboard_version_from_pypi()
return DashboardVersionInfo(
current_version=resolved_current_version,
latest_version=latest_version,
has_update=is_newer_version(latest_version, resolved_current_version),
)
def _get_parent_command_line() -> str:
parent_pid = _get_parent_pid(os.getpid())
if parent_pid is None:
return ""
return _get_process_command_line(parent_pid)
def _get_parent_pid(pid: int) -> Optional[int]:
if os.name == "nt":
return _get_windows_parent_pid(pid)
stat_path = Path(f"/proc/{pid}/stat")
try:
stat_text = stat_path.read_text(encoding="utf-8")
except OSError:
return None
parts = stat_text.split()
if len(parts) >= 4 and parts[3].isdigit():
return int(parts[3])
return None
def _get_process_command_line(pid: int) -> str:
if os.name == "nt":
return _get_windows_process_command_line(pid)
cmdline_path = Path(f"/proc/{pid}/cmdline")
try:
return cmdline_path.read_text(encoding="utf-8").replace("\x00", " ").strip()
except OSError:
return ""
def _get_windows_parent_pid(pid: int) -> Optional[int]:
output = _run_wmic_query(pid, "ParentProcessId")
parent_pid = output.get("ParentProcessId")
if parent_pid and parent_pid.isdigit():
return int(parent_pid)
return None
def _get_windows_process_command_line(pid: int) -> str:
output = _run_wmic_query(pid, "CommandLine")
return output.get("CommandLine", "")
def _run_wmic_query(pid: int, field: str) -> Dict[str, str]:
wmic_path = shutil.which("wmic")
if not wmic_path:
return {}
try:
result = subprocess.run(
[wmic_path, "process", "where", f"ProcessId={pid}", "get", field, "/format:list"],
check=False,
capture_output=True,
encoding="utf-8",
errors="ignore",
timeout=3,
)
except (OSError, subprocess.SubprocessError):
return {}
values = {}
for line in result.stdout.splitlines():
if "=" not in line:
continue
key, value = line.split("=", 1)
values[key.strip()] = value.strip()
return values
def detect_package_runner() -> PackageRunner:
"""检测当前进程更像是由 uv 还是 pip/普通 python 启动。"""
uv_markers = ["UV", "UV_PROJECT_ENVIRONMENT", "UV_RUN_RECURSION_DEPTH"]
if any(os.getenv(marker) for marker in uv_markers):
return "uv"
parent_command = _get_parent_command_line().lower()
if parent_command:
executable = parent_command.split(maxsplit=1)[0]
if executable.endswith("uv.exe") or executable.endswith("/uv") or executable == "uv":
return "uv"
if " pip " in f" {parent_command} " or executable.endswith("pip.exe") or executable.endswith("/pip"):
return "pip"
return "unknown"
def _build_update_command(runner: PackageRunner) -> list[str]:
if runner == "uv" and shutil.which("uv"):
return ["uv", "pip", "install", "--python", sys.executable, "--upgrade", DASHBOARD_PACKAGE_NAME]
return [sys.executable, "-m", "pip", "install", "--upgrade", DASHBOARD_PACKAGE_NAME]
async def auto_update_dashboard_if_needed() -> DashboardUpdateResult:
version_info = await get_dashboard_version_info()
runner = detect_package_runner()
if not version_info.latest_version:
return DashboardUpdateResult(
checked=True,
updated=False,
current_version=version_info.current_version,
latest_version=None,
runner=runner,
message="无法获取 WebUI 最新版本,跳过自动更新",
)
if not version_info.has_update:
return DashboardUpdateResult(
checked=True,
updated=False,
current_version=version_info.current_version,
latest_version=version_info.latest_version,
runner=runner,
message="WebUI 已是最新版本",
)
update_runner = runner if runner != "unknown" else "pip"
command = _build_update_command(update_runner)
logger.info(
f"检测到 WebUI 新版本: {version_info.current_version} -> {version_info.latest_version}"
f"使用 {update_runner} 自动更新"
)
try:
process = await asyncio.create_subprocess_exec(
*command,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await process.communicate()
except OSError as e:
logger.warning(f"WebUI 自动更新启动失败: {e}")
return DashboardUpdateResult(
checked=True,
updated=False,
current_version=version_info.current_version,
latest_version=version_info.latest_version,
runner=update_runner,
message=f"自动更新启动失败: {e}",
)
if process.returncode != 0:
error_text = stderr.decode(errors="ignore").strip() or stdout.decode(errors="ignore").strip()
logger.warning(f"WebUI 自动更新失败: {error_text}")
return DashboardUpdateResult(
checked=True,
updated=False,
current_version=version_info.current_version,
latest_version=version_info.latest_version,
runner=update_runner,
message=f"自动更新失败: {error_text}",
)
logger.info(f"WebUI 自动更新完成: {version_info.current_version} -> {version_info.latest_version}")
return DashboardUpdateResult(
checked=True,
updated=True,
current_version=version_info.current_version,
latest_version=version_info.latest_version,
runner=update_runner,
message="WebUI 自动更新完成,重启后生效",
)

View File

@@ -5,29 +5,28 @@
"""
from datetime import datetime
from importlib.metadata import PackageNotFoundError, version as get_package_version
from typing import Any, Dict, Optional
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
import httpx
import os
import time
from src.common.logger import get_logger
from src.config.config import MMC_VERSION
from src.webui.dashboard_update import (
DASHBOARD_PACKAGE_NAME,
PYPI_PROJECT_URL,
detect_package_runner,
get_dashboard_version_info,
)
from src.webui.dependencies import require_auth
router = APIRouter(prefix="/system", tags=["system"], dependencies=[Depends(require_auth)])
logger = get_logger("webui_system")
# 记录启动时间
_start_time = time.time()
_DASHBOARD_PACKAGE_NAME = "maibot-dashboard"
_PYPI_JSON_URL = f"https://pypi.org/pypi/{_DASHBOARD_PACKAGE_NAME}/json"
_PYPI_CACHE_TTL_SECONDS = 60 * 60 * 6
_pypi_version_cache: Dict[str, Any] = {"checked_at": 0.0, "latest_version": None}
class RestartResponse(BaseModel):
@@ -52,64 +51,9 @@ class DashboardVersionResponse(BaseModel):
current_version: str
latest_version: Optional[str] = None
has_update: bool = False
package_name: str = _DASHBOARD_PACKAGE_NAME
pypi_url: str = f"https://pypi.org/project/{_DASHBOARD_PACKAGE_NAME}/"
def _get_installed_dashboard_version() -> str:
try:
return get_package_version(_DASHBOARD_PACKAGE_NAME)
except PackageNotFoundError:
return "unknown"
def _normalize_version(version: str) -> tuple[int, ...]:
clean_version = version.strip().lower().removeprefix("v")
numeric_part = clean_version.split("-", 1)[0].split("+", 1)[0]
parts = []
for item in numeric_part.split("."):
number = ""
for char in item:
if not char.isdigit():
break
number += char
parts.append(int(number) if number else 0)
return tuple(parts)
def _is_newer_version(latest_version: Optional[str], current_version: str) -> bool:
if not latest_version or not current_version or current_version == "unknown":
return False
latest_parts = _normalize_version(latest_version)
current_parts = _normalize_version(current_version)
width = max(len(latest_parts), len(current_parts))
return latest_parts + (0,) * (width - len(latest_parts)) > current_parts + (0,) * (width - len(current_parts))
async def _get_latest_dashboard_version_from_pypi() -> Optional[str]:
now = time.time()
cached_version = _pypi_version_cache.get("latest_version")
checked_at = float(_pypi_version_cache.get("checked_at", 0.0))
if cached_version and now - checked_at < _PYPI_CACHE_TTL_SECONDS:
return str(cached_version)
try:
async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.get(_PYPI_JSON_URL)
response.raise_for_status()
payload = response.json()
except Exception as e:
logger.debug(f"检查 WebUI PyPI 版本失败: {e}")
return str(cached_version) if cached_version else None
latest_version = payload.get("info", {}).get("version")
if isinstance(latest_version, str) and latest_version.strip():
_pypi_version_cache["checked_at"] = now
_pypi_version_cache["latest_version"] = latest_version.strip()
return latest_version.strip()
return str(cached_version) if cached_version else None
runner: str = "unknown"
package_name: str = DASHBOARD_PACKAGE_NAME
pypi_url: str = PYPI_PROJECT_URL
@router.post("/restart", response_model=RestartResponse)
@@ -166,13 +110,13 @@ async def get_maibot_status():
@router.get("/dashboard-version", response_model=DashboardVersionResponse)
async def get_dashboard_version(current_version: Optional[str] = None):
"""获取 WebUI 当前版本和 PyPI 最新版本。"""
resolved_current_version = current_version or _get_installed_dashboard_version()
latest_version = await _get_latest_dashboard_version_from_pypi()
version_info = await get_dashboard_version_info(current_version)
return DashboardVersionResponse(
current_version=resolved_current_version,
latest_version=latest_version,
has_update=_is_newer_version(latest_version, resolved_current_version),
current_version=version_info.current_version,
latest_version=version_info.latest_version,
has_update=version_info.has_update,
runner=detect_package_runner(),
)