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", "name": "maibot-dashboard",
"private": true, "private": true,
"version": "1.0.6", "version": "1.0.7",
"type": "module", "type": "module",
"main": "./out/main/index.js", "main": "./out/main/index.js",
"scripts": { "scripts": {

View File

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

View File

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

View File

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

View File

@@ -178,7 +178,7 @@ export function Layout({ children }: LayoutProps) {
)} )}
</AnimatePresence> </AnimatePresence>
{/* Main content */} {/* Main content */}
<div className="flex flex-1 flex-col overflow-hidden"> <div className="flex min-w-0 flex-1 flex-col overflow-hidden">
{/* HTTP 安全警告横幅 */} {/* HTTP 安全警告横幅 */}
<HttpWarningBanner /> <HttpWarningBanner />
@@ -211,7 +211,7 @@ export function Layout({ children }: LayoutProps) {
<AnimatePresence mode="wait" initial={false}> <AnimatePresence mode="wait" initial={false}>
<motion.div <motion.div
key={workspaceMode} 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)' }} initial={{ opacity: 0, x: isChatWorkspace ? 32 : -32, filter: 'blur(6px)' }}
animate={{ opacity: 1, x: 0, filter: 'blur(0px)' }} animate={{ opacity: 1, x: 0, filter: 'blur(0px)' }}
exit={{ opacity: 0, x: isChatWorkspace ? -32 : 32, filter: 'blur(6px)' }} 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_NAME = 'MaiBot Dashboard'
export const APP_FULL_NAME = `${APP_NAME} v${APP_VERSION}` export const APP_FULL_NAME = `${APP_NAME} v${APP_VERSION}`

View File

@@ -766,8 +766,8 @@ function BotConfigPageContent() {
} }
return ( return (
<ScrollArea className="h-full"> <ScrollArea className="h-full min-w-0" scrollbars="vertical">
<div className="space-y-4 sm:space-y-6 p-4 sm:p-6"> <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 gap-3 sm:gap-4">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3"> <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> <p className="text-muted-foreground mt-1 text-xs sm:text-sm"></p>
</div> </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 <Tabs
value={editMode} value={editMode}
onValueChange={(v) => handleModeChange(v as 'visual' | 'source')} 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"> <TabsList className="grid h-8 w-full grid-cols-2 sm:h-9">
<TabsTrigger value="visual" className="px-2 text-xs"> <TabsTrigger value="visual" className="px-2 text-xs">
@@ -798,7 +798,7 @@ function BotConfigPageContent() {
disabled={saving || autoSaving || isRestarting} disabled={saving || autoSaving || isRestarting}
size="sm" size="sm"
variant="outline" 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" /> <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} disabled={saving || autoSaving || !hasUnsavedChanges || isRestarting}
size="sm" size="sm"
variant="outline" 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" /> <Save className="h-4 w-4 flex-shrink-0" strokeWidth={2} fill="none" />
<span className="ml-1 truncate text-xs sm:text-sm"> <span className="ml-1 truncate text-xs sm:text-sm">
@@ -820,7 +820,7 @@ function BotConfigPageContent() {
<Button <Button
disabled={saving || autoSaving || isRestarting} disabled={saving || autoSaving || isRestarting}
size="sm" 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" /> <Power className="h-4 w-4 flex-shrink-0" />
<span className="ml-1 truncate text-xs sm:text-sm"> <span className="ml-1 truncate text-xs sm:text-sm">
@@ -1041,7 +1041,7 @@ function DynamicConfigTabs(props: DynamicConfigTabsProps) {
return ( return (
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full"> <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) => { {visibleTabGroups.map((tab) => {
const isExpandedOnlyTab = !DEFAULT_VISIBLE_TAB_IDS.has(tab.id) const isExpandedOnlyTab = !DEFAULT_VISIBLE_TAB_IDS.has(tab.id)
return ( return (
@@ -1052,7 +1052,7 @@ function DynamicConfigTabs(props: DynamicConfigTabsProps) {
<TabsTrigger <TabsTrigger
value={tab.id} value={tab.id}
className={cn( 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 && 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" "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" type="button"
variant="ghost" variant="ghost"
size="sm" 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} onClick={toggleExpanded}
> >
{expanded ? ( {expanded ? (
@@ -1082,7 +1082,7 @@ function DynamicConfigTabs(props: DynamicConfigTabsProps) {
type="button" type="button"
variant={advancedVisible ? 'default' : 'outline'} variant={advancedVisible ? 'default' : 'outline'}
size="sm" 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)} 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() LEGACY_ENV_PATH: Path = (PROJECT_ROOT / ".env").resolve().absolute()
A_MEMORIX_LEGACY_CONFIG_PATH: Path = (CONFIG_DIR / "a_memorix.toml").resolve().absolute() A_MEMORIX_LEGACY_CONFIG_PATH: Path = (CONFIG_DIR / "a_memorix.toml").resolve().absolute()
MMC_VERSION: str = "1.0.0-pre.13" 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" MODEL_CONFIG_VERSION: str = "1.15.3"
logger = get_logger("config") logger = get_logger("config")

View File

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

View File

@@ -1,24 +1,26 @@
from rich.traceback import install
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from rich.traceback import install
import asyncio import asyncio
import time import time
from src.A_memorix.host_service import a_memorix_host_service 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.chat_manager import chat_manager
from src.chat.message_receive.bot import chat_bot
from src.chat.utils.statistic import OnlineTimeRecordTask, StatisticOutputTask from src.chat.utils.statistic import OnlineTimeRecordTask, StatisticOutputTask
from src.common.i18n import t from src.common.i18n import t
from src.common.logger import get_logger from src.common.logger import get_logger
from src.common.message_server.server import Server, get_global_server from src.common.message_server.server import Server, get_global_server
from src.config.config import config_manager, global_config 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.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.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.plugin_runtime.integration import get_plugin_runtime_manager
from src.prompt.prompt_manager import prompt_manager from src.prompt.prompt_manager import prompt_manager
from src.services.memory_flow_service import memory_automation_service 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 # from src.api.main import start_api_server
@@ -45,9 +47,6 @@ class MainSystem:
self.server: Server = get_global_server() self.server: Server = get_global_server()
self.webui_server: WebUIServer | None = None # 独立的 WebUI 服务器 self.webui_server: WebUIServer | None = None # 独立的 WebUI 服务器
# 设置独立的 WebUI 服务器
self._setup_webui_server()
def _setup_webui_server(self) -> None: def _setup_webui_server(self) -> None:
"""设置独立的 WebUI 服务器""" """设置独立的 WebUI 服务器"""
from src.config.config import global_config from src.config.config import global_config
@@ -70,11 +69,30 @@ class MainSystem:
enable_stage_status_board() enable_stage_status_board()
logger.info(t("startup.waking_up", nickname=global_config.bot.nickname)) 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()) await asyncio.gather(self._init_components())
logger.info(t("startup.initialization_completed_banner", nickname=global_config.bot.nickname)) 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: async def _init_components(self) -> None:
"""初始化其他组件""" """初始化其他组件"""
init_start_time = time.time() 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 datetime import datetime
from importlib.metadata import PackageNotFoundError, version as get_package_version from typing import Optional
from typing import Any, Dict, Optional
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
import httpx
import os import os
import time import time
from src.common.logger import get_logger from src.common.logger import get_logger
from src.config.config import MMC_VERSION 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 from src.webui.dependencies import require_auth
router = APIRouter(prefix="/system", tags=["system"], dependencies=[Depends(require_auth)]) router = APIRouter(prefix="/system", tags=["system"], dependencies=[Depends(require_auth)])
logger = get_logger("webui_system") logger = get_logger("webui_system")
# 记录启动时间
_start_time = time.time() _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): class RestartResponse(BaseModel):
@@ -52,64 +51,9 @@ class DashboardVersionResponse(BaseModel):
current_version: str current_version: str
latest_version: Optional[str] = None latest_version: Optional[str] = None
has_update: bool = False has_update: bool = False
package_name: str = _DASHBOARD_PACKAGE_NAME runner: str = "unknown"
pypi_url: str = f"https://pypi.org/project/{_DASHBOARD_PACKAGE_NAME}/" package_name: str = DASHBOARD_PACKAGE_NAME
pypi_url: str = PYPI_PROJECT_URL
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
@router.post("/restart", response_model=RestartResponse) @router.post("/restart", response_model=RestartResponse)
@@ -166,13 +110,13 @@ async def get_maibot_status():
@router.get("/dashboard-version", response_model=DashboardVersionResponse) @router.get("/dashboard-version", response_model=DashboardVersionResponse)
async def get_dashboard_version(current_version: Optional[str] = None): async def get_dashboard_version(current_version: Optional[str] = None):
"""获取 WebUI 当前版本和 PyPI 最新版本。""" """获取 WebUI 当前版本和 PyPI 最新版本。"""
resolved_current_version = current_version or _get_installed_dashboard_version() version_info = await get_dashboard_version_info(current_version)
latest_version = await _get_latest_dashboard_version_from_pypi()
return DashboardVersionResponse( return DashboardVersionResponse(
current_version=resolved_current_version, current_version=version_info.current_version,
latest_version=latest_version, latest_version=version_info.latest_version,
has_update=_is_newer_version(latest_version, resolved_current_version), has_update=version_info.has_update,
runner=detect_package_runner(),
) )