feat:启动时检查webui版本并尝试自动更新
This commit is contained in:
@@ -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": {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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)' }}
|
||||
|
||||
@@ -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}`
|
||||
|
||||
|
||||
@@ -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)}
|
||||
>
|
||||
高级设置
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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={
|
||||
|
||||
32
src/main.py
32
src/main.py
@@ -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()
|
||||
|
||||
283
src/webui/dashboard_update.py
Normal file
283
src/webui/dashboard_update.py
Normal 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 自动更新完成,重启后生效",
|
||||
)
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user