diff --git a/.github/workflows/dashboard_ci.yml b/.github/workflows/dashboard_ci.yml index f403da77..5be935eb 100644 --- a/.github/workflows/dashboard_ci.yml +++ b/.github/workflows/dashboard_ci.yml @@ -16,7 +16,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v6 with: - node-version: 'latest' + node-version: '24.13.0' - name: npm install, build run: | @@ -52,4 +52,4 @@ jobs: repo: astrbot-release-harbour body: "Automated release from commit ${{ github.sha }}" token: ${{ secrets.ASTRBOT_HARBOUR_TOKEN }} - artifacts: "dashboard/dist.zip" \ No newline at end of file + artifacts: "dashboard/dist.zip" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f5c0f09a..59c229b0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -57,7 +57,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v6 with: - node-version: 20 + node-version: '24.13.0' cache: "pnpm" cache-dependency-path: dashboard/pnpm-lock.yaml @@ -175,7 +175,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v6 with: - node-version: 20 + node-version: '24.13.0' cache: "pnpm" cache-dependency-path: | dashboard/pnpm-lock.yaml diff --git a/astrbot/core/platform/manager.py b/astrbot/core/platform/manager.py index 9cec8a94..dfe729f5 100644 --- a/astrbot/core/platform/manager.py +++ b/astrbot/core/platform/manager.py @@ -1,6 +1,7 @@ import asyncio import traceback from asyncio import Queue +from dataclasses import dataclass from astrbot.core import logger from astrbot.core.config.astrbot_config import AstrBotConfig @@ -12,12 +13,19 @@ from .register import platform_cls_map from .sources.webchat.webchat_adapter import WebChatAdapter +@dataclass +class PlatformTasks: + run: asyncio.Task + wrapper: asyncio.Task + + class PlatformManager: def __init__(self, config: AstrBotConfig, event_queue: Queue) -> None: self.platform_insts: list[Platform] = [] """加载的 Platform 的实例""" self._inst_map: dict[str, dict] = {} + self._platform_tasks: dict[str, PlatformTasks] = {} self.astrbot_config = config self.platforms_config = config["platform"] @@ -38,6 +46,44 @@ class PlatformManager: sanitized = platform_id.replace(":", "_").replace("!", "_") return sanitized, sanitized != platform_id + def _start_platform_task(self, task_name: str, inst: Platform) -> None: + run_task = asyncio.create_task(inst.run(), name=task_name) + wrapper_task = asyncio.create_task( + self._task_wrapper(run_task, platform=inst), + name=f"{task_name}_wrapper", + ) + self._platform_tasks[inst.client_self_id] = PlatformTasks( + run=run_task, + wrapper=wrapper_task, + ) + + async def _stop_platform_task(self, client_id: str) -> None: + tasks = self._platform_tasks.pop(client_id, None) + if not tasks: + return + for task in (tasks.run, tasks.wrapper): + if not task.done(): + task.cancel() + await asyncio.gather(tasks.run, tasks.wrapper, return_exceptions=True) + + async def _terminate_inst_and_tasks(self, inst: Platform) -> None: + client_id = inst.client_self_id + try: + if getattr(inst, "terminate", None): + try: + await inst.terminate() + except asyncio.CancelledError: + raise + except Exception as e: + logger.error( + "终止平台适配器失败: client_id=%s, error=%s", + client_id, + e, + ) + logger.error(traceback.format_exc()) + finally: + await self._stop_platform_task(client_id) + async def initialize(self) -> None: """初始化所有平台适配器""" for platform in self.platforms_config: @@ -51,12 +97,7 @@ class PlatformManager: # 网页聊天 webchat_inst = WebChatAdapter({}, self.settings, self.event_queue) self.platform_insts.append(webchat_inst) - asyncio.create_task( - self._task_wrapper( - asyncio.create_task(webchat_inst.run(), name="webchat"), - platform=webchat_inst, - ), - ) + self._start_platform_task("webchat", webchat_inst) async def load_platform(self, platform_config: dict) -> None: """实例化一个平台""" @@ -154,15 +195,9 @@ class PlatformManager: "client_id": inst.client_self_id, } self.platform_insts.append(inst) - - asyncio.create_task( - self._task_wrapper( - asyncio.create_task( - inst.run(), - name=f"platform_{platform_config['type']}_{platform_config['id']}", - ), - platform=inst, - ), + self._start_platform_task( + f"platform_{platform_config['type']}_{platform_config['id']}", + inst, ) handlers = star_handlers_registry.get_handlers_by_event_type( EventType.OnPlatformLoadedEvent, @@ -230,13 +265,25 @@ class PlatformManager: except Exception: logger.warning(f"可能未完全移除 {platform_id} 平台适配器") - if getattr(inst, "terminate", None): - await inst.terminate() + await self._terminate_inst_and_tasks(inst) async def terminate(self) -> None: - for inst in self.platform_insts: - if getattr(inst, "terminate", None): - await inst.terminate() + terminated_client_ids: set[str] = set() + for platform_id in list(self._inst_map.keys()): + info = self._inst_map.get(platform_id) + if info: + terminated_client_ids.add(info["client_id"]) + await self.terminate_platform(platform_id) + + for inst in list(self.platform_insts): + client_id = inst.client_self_id + if client_id in terminated_client_ids: + continue + await self._terminate_inst_and_tasks(inst) + + self.platform_insts.clear() + self._inst_map.clear() + self._platform_tasks.clear() def get_insts(self): return self.platform_insts diff --git a/astrbot/core/platform/sources/webchat/webchat_adapter.py b/astrbot/core/platform/sources/webchat/webchat_adapter.py index 5eb62e6b..047417aa 100644 --- a/astrbot/core/platform/sources/webchat/webchat_adapter.py +++ b/astrbot/core/platform/sources/webchat/webchat_adapter.py @@ -26,14 +26,23 @@ from .webchat_queue_mgr import WebChatQueueMgr, webchat_queue_mgr class QueueListener: - def __init__(self, webchat_queue_mgr: WebChatQueueMgr, callback: Callable) -> None: + def __init__( + self, + webchat_queue_mgr: WebChatQueueMgr, + callback: Callable, + stop_event: asyncio.Event, + ) -> None: self.webchat_queue_mgr = webchat_queue_mgr self.callback = callback + self.stop_event = stop_event async def run(self) -> None: """Register callback and keep adapter task alive.""" self.webchat_queue_mgr.set_listener(self.callback) - await asyncio.Event().wait() + try: + await self.stop_event.wait() + finally: + await self.webchat_queue_mgr.clear_listener() @register_platform_adapter("webchat", "webchat") @@ -56,6 +65,8 @@ class WebChatAdapter(Platform): id="webchat", support_proactive_message=False, ) + self._shutdown_event = asyncio.Event() + self._webchat_queue_mgr = webchat_queue_mgr async def send_by_session( self, @@ -184,7 +195,7 @@ class WebChatAdapter(Platform): abm = await self.convert_message(data) await self.handle_msg(abm) - bot = QueueListener(webchat_queue_mgr, callback) + bot = QueueListener(self._webchat_queue_mgr, callback, self._shutdown_event) return bot.run() def meta(self) -> PlatformMetadata: @@ -209,5 +220,4 @@ class WebChatAdapter(Platform): self.commit_event(message_event) async def terminate(self) -> None: - # Do nothing - pass + self._shutdown_event.set() diff --git a/astrbot/core/platform/sources/webchat/webchat_queue_mgr.py b/astrbot/core/platform/sources/webchat/webchat_queue_mgr.py index 688d83e2..fd35e837 100644 --- a/astrbot/core/platform/sources/webchat/webchat_queue_mgr.py +++ b/astrbot/core/platform/sources/webchat/webchat_queue_mgr.py @@ -87,6 +87,19 @@ class WebChatQueueMgr: for conversation_id in list(self.queues.keys()): self._start_listener_if_needed(conversation_id) + async def clear_listener(self) -> None: + self._listener_callback = None + for close_event in list(self._queue_close_events.values()): + close_event.set() + self._queue_close_events.clear() + + listener_tasks = list(self._listener_tasks.values()) + for task in listener_tasks: + task.cancel() + if listener_tasks: + await asyncio.gather(*listener_tasks, return_exceptions=True) + self._listener_tasks.clear() + def _start_listener_if_needed(self, conversation_id: str): if self._listener_callback is None: return diff --git a/astrbot/core/star/star_manager.py b/astrbot/core/star/star_manager.py index 58780895..b2d70e79 100644 --- a/astrbot/core/star/star_manager.py +++ b/astrbot/core/star/star_manager.py @@ -193,6 +193,37 @@ class PluginManager: logger.error(f"更新插件 {p} 的依赖失败。Code: {e!s}") return True + async def _import_plugin_with_dependency_recovery( + self, + path: str, + module_str: str, + root_dir_name: str, + requirements_path: str, + ) -> ModuleType: + try: + return __import__(path, fromlist=[module_str]) + except (ModuleNotFoundError, ImportError) as import_exc: + if os.path.exists(requirements_path): + try: + logger.info( + f"插件 {root_dir_name} 导入失败,尝试从已安装依赖恢复: {import_exc!s}" + ) + pip_installer.prefer_installed_dependencies( + requirements_path=requirements_path + ) + module = __import__(path, fromlist=[module_str]) + logger.info( + f"插件 {root_dir_name} 已从 site-packages 恢复依赖,跳过重新安装。" + ) + return module + except Exception as recover_exc: + logger.info( + f"插件 {root_dir_name} 已安装依赖恢复失败,将重新安装依赖: {recover_exc!s}" + ) + + await self._check_plugin_dept_update(target_plugin=root_dir_name) + return __import__(path, fromlist=[module_str]) + @staticmethod def _load_plugin_metadata(plugin_path: str, plugin_obj=None) -> StarMetadata | None: """先寻找 metadata.yaml 文件,如果不存在,则使用插件对象的 info() 函数获取元数据。 @@ -386,6 +417,12 @@ class PluginManager: "reserved", False, ) # 是否是保留插件。目前在 astrbot/builtin_stars 目录下的都是保留插件。保留插件不可以卸载。 + plugin_dir_path = ( + os.path.join(self.plugin_store_path, root_dir_name) + if not reserved + else os.path.join(self.reserved_plugin_path, root_dir_name) + ) + requirements_path = os.path.join(plugin_dir_path, "requirements.txt") path = "data.plugins." if not reserved else "astrbot.builtin_stars." path += root_dir_name + "." + module_str @@ -400,11 +437,12 @@ class PluginManager: # 尝试导入模块 try: - module = __import__(path, fromlist=[module_str]) - except (ModuleNotFoundError, ImportError): - # 尝试安装依赖 - await self._check_plugin_dept_update(target_plugin=root_dir_name) - module = __import__(path, fromlist=[module_str]) + module = await self._import_plugin_with_dependency_recovery( + path=path, + module_str=module_str, + root_dir_name=root_dir_name, + requirements_path=requirements_path, + ) except Exception as e: logger.error(traceback.format_exc()) logger.error(f"插件 {root_dir_name} 导入失败。原因:{e!s}") @@ -412,11 +450,6 @@ class PluginManager: # 检查 _conf_schema.json plugin_config = None - plugin_dir_path = ( - os.path.join(self.plugin_store_path, root_dir_name) - if not reserved - else os.path.join(self.reserved_plugin_path, root_dir_name) - ) plugin_schema_path = os.path.join( plugin_dir_path, self.conf_schema_fname, diff --git a/astrbot/core/utils/pip_installer.py b/astrbot/core/utils/pip_installer.py index 8d43c11b..1c8da23c 100644 --- a/astrbot/core/utils/pip_installer.py +++ b/astrbot/core/utils/pip_installer.py @@ -580,6 +580,26 @@ class PipInstaller: ) importlib.invalidate_caches() + def prefer_installed_dependencies(self, requirements_path: str) -> None: + """优先使用已安装在插件 site-packages 中的依赖,不执行安装。""" + if not is_packaged_electron_runtime(): + return + + target_site_packages = get_astrbot_site_packages_path() + if not os.path.isdir(target_site_packages): + return + + requested_requirements = _extract_requirement_names(requirements_path) + if not requested_requirements: + return + + _prepend_sys_path(target_site_packages) + _ensure_plugin_dependencies_preferred( + target_site_packages, + requested_requirements, + ) + importlib.invalidate_caches() + async def _run_pip_in_process(self, args: list[str]) -> int: pip_main = _get_pip_main() _patch_distlib_finder_for_frozen_runtime() diff --git a/astrbot/dashboard/server.py b/astrbot/dashboard/server.py index 604866a8..9d8dffa3 100644 --- a/astrbot/dashboard/server.py +++ b/astrbot/dashboard/server.py @@ -201,11 +201,16 @@ class AstrBotDashboard: def run(self): ip_addr = [] - if p := os.environ.get("DASHBOARD_PORT"): - port = p - else: - port = self.core_lifecycle.astrbot_config["dashboard"].get("port", 6185) - host = self.core_lifecycle.astrbot_config["dashboard"].get("host", "0.0.0.0") + port = ( + os.environ.get("DASHBOARD_PORT") + or os.environ.get("ASTRBOT_DASHBOARD_PORT") + or self.core_lifecycle.astrbot_config["dashboard"].get("port", 6185) + ) + host = ( + os.environ.get("DASHBOARD_HOST") + or os.environ.get("ASTRBOT_DASHBOARD_HOST") + or self.core_lifecycle.astrbot_config["dashboard"].get("host", "0.0.0.0") + ) enable = self.core_lifecycle.astrbot_config["dashboard"].get("enable", True) if not enable: @@ -213,7 +218,6 @@ class AstrBotDashboard: return None logger.info(f"正在启动 WebUI, 监听地址: http://{host}:{port}") - if host == "0.0.0.0": logger.info( "提示: WebUI 将监听所有网络接口,请注意安全。(可在 data/cmd_config.json 中配置 dashboard.host 以修改 host)", diff --git a/desktop/lib/backend-manager.js b/desktop/lib/backend-manager.js index 0ca56931..eb8958a4 100644 --- a/desktop/lib/backend-manager.js +++ b/desktop/lib/backend-manager.js @@ -8,6 +8,7 @@ const { BufferedRotatingLogger } = require('./buffered-rotating-logger'); const { delay, ensureDir, + formatLogTimestamp, normalizeUrl, parseLogBackupCount, parseLogMaxBytes, @@ -391,6 +392,18 @@ class BackendManager { }; if (this.app.isPackaged) { env.ASTRBOT_ELECTRON_CLIENT = '1'; + const hasExplicitDashboardHost = Boolean( + process.env.DASHBOARD_HOST || process.env.ASTRBOT_DASHBOARD_HOST, + ); + const hasExplicitDashboardPort = Boolean( + process.env.DASHBOARD_PORT || process.env.ASTRBOT_DASHBOARD_PORT, + ); + if (!hasExplicitDashboardHost) { + env.DASHBOARD_HOST = '127.0.0.1'; + } + if (!hasExplicitDashboardPort) { + env.DASHBOARD_PORT = '6185'; + } } if (backendConfig.webuiDir) { env.ASTRBOT_WEBUI_DIR = backendConfig.webuiDir; @@ -431,7 +444,7 @@ class BackendManager { .map((item) => JSON.stringify(item)) .join(' '); this.backendLogger.log( - `[${new Date().toISOString()}] [Electron] Start backend ${launchLine}\n`, + `[${formatLogTimestamp()}] [Electron] Start backend ${launchLine}\n`, ); } @@ -439,7 +452,7 @@ class BackendManager { this.backendLastExitReason = error instanceof Error ? error.message : String(error); this.backendLogger.log( - `[${new Date().toISOString()}] [Electron] Backend spawn error: ${ + `[${formatLogTimestamp()}] [Electron] Backend spawn error: ${ error instanceof Error ? error.message : String(error) }\n`, ); diff --git a/desktop/lib/common.js b/desktop/lib/common.js index e592aff7..9f39358d 100644 --- a/desktop/lib/common.js +++ b/desktop/lib/common.js @@ -81,11 +81,30 @@ function waitForProcessExit(child, timeoutMs = 5000) { }); } +function formatLogTimestamp(date = new Date()) { + const year = date.getFullYear(); + const month = `${date.getMonth() + 1}`.padStart(2, '0'); + const day = `${date.getDate()}`.padStart(2, '0'); + const hour = `${date.getHours()}`.padStart(2, '0'); + const minute = `${date.getMinutes()}`.padStart(2, '0'); + const second = `${date.getSeconds()}`.padStart(2, '0'); + const millisecond = `${date.getMilliseconds()}`.padStart(3, '0'); + + const offsetMinutes = -date.getTimezoneOffset(); + const offsetSign = offsetMinutes >= 0 ? '+' : '-'; + const absOffsetMinutes = Math.abs(offsetMinutes); + const offsetHour = `${Math.floor(absOffsetMinutes / 60)}`.padStart(2, '0'); + const offsetMinute = `${absOffsetMinutes % 60}`.padStart(2, '0'); + + return `${year}-${month}-${day} ${hour}:${minute}:${second}.${millisecond} ${offsetSign}${offsetHour}${offsetMinute}`; +} + module.exports = { LOG_ROTATION_DEFAULT_BACKUP_COUNT, LOG_ROTATION_DEFAULT_MAX_MB, delay, ensureDir, + formatLogTimestamp, isIgnorableFsError, isLogRotationDebugEnabled, normalizeUrl, diff --git a/desktop/lib/electron-logger.js b/desktop/lib/electron-logger.js index b9328ef4..6a52d1c7 100644 --- a/desktop/lib/electron-logger.js +++ b/desktop/lib/electron-logger.js @@ -2,7 +2,11 @@ const path = require('path'); const { RotatingLogWriter } = require('./rotating-log-writer'); -const { parseLogBackupCount, parseLogMaxBytes } = require('./common'); +const { + formatLogTimestamp, + parseLogBackupCount, + parseLogMaxBytes, +} = require('./common'); function createElectronLogger({ app, getRootDir }) { const electronLogMaxBytes = parseLogMaxBytes( @@ -28,7 +32,7 @@ function createElectronLogger({ app, getRootDir }) { function logElectron(message) { const logPath = getElectronLogPath(); - const line = `[${new Date().toISOString()}] ${message}\n`; + const line = `[${formatLogTimestamp()}] ${message}\n`; void writer.setLogPath(logPath); void writer.append(line); } @@ -47,4 +51,3 @@ function createElectronLogger({ app, getRootDir }) { module.exports = { createElectronLogger, }; - diff --git a/desktop/package.json b/desktop/package.json index c70689f1..59ad3cd5 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -22,7 +22,7 @@ "dist": "pnpm run sync:version && electron-builder" }, "devDependencies": { - "electron": "^30.0.0", + "electron": "^40.3.0", "electron-builder": "^24.13.0" }, "build": { diff --git a/desktop/pnpm-lock.yaml b/desktop/pnpm-lock.yaml index f91a21a8..98411a90 100644 --- a/desktop/pnpm-lock.yaml +++ b/desktop/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: devDependencies: electron: - specifier: ^30.0.0 - version: 30.5.1 + specifier: ^40.3.0 + version: 40.3.0 electron-builder: specifier: ^24.13.0 version: 24.13.3(electron-builder-squirrel-windows@24.13.3) @@ -92,8 +92,8 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} - '@types/node@20.19.33': - resolution: {integrity: sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==} + '@types/node@24.10.13': + resolution: {integrity: sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==} '@types/node@25.2.2': resolution: {integrity: sha512-BkmoP5/FhRYek5izySdkOneRyXYN35I860MFAGupTdebyE66uZaR+bXLHq8k4DirE5DwQi3NuhvRU1jqTVwUrQ==} @@ -397,8 +397,8 @@ packages: electron-publish@24.13.1: resolution: {integrity: sha512-2ZgdEqJ8e9D17Hwp5LEq5mLQPjqU3lv/IALvgp+4W8VeNhryfGhYEQC/PgDPMrnWUp+l60Ou5SJLsu+k4mhQ8A==} - electron@30.5.1: - resolution: {integrity: sha512-AhL7+mZ8Lg14iaNfoYTkXQ2qee8mmsQyllKdqxlpv/zrKgfxz6jNVtcRRbQtLxtF8yzcImWdfTQROpYiPumdbw==} + electron@40.3.0: + resolution: {integrity: sha512-ZaDkTZpNHr863tyZHieoqbaiLI0e3RVCXoEC5y1Ld70/Q5H1mPV9d5TK0h1dWtaSFVOW0w8iDvtdLwAXtasXpg==} engines: {node: '>= 12.20.55'} hasBin: true @@ -992,9 +992,6 @@ packages: engines: {node: '>=14.17'} hasBin: true - undici-types@6.21.0: - resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} - undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} @@ -1158,7 +1155,7 @@ snapshots: dependencies: '@types/http-cache-semantics': 4.2.0 '@types/keyv': 3.1.4 - '@types/node': 20.19.33 + '@types/node': 25.2.2 '@types/responselike': 1.0.3 '@types/debug@4.1.12': @@ -1173,13 +1170,13 @@ snapshots: '@types/keyv@3.1.4': dependencies: - '@types/node': 20.19.33 + '@types/node': 25.2.2 '@types/ms@2.1.0': {} - '@types/node@20.19.33': + '@types/node@24.10.13': dependencies: - undici-types: 6.21.0 + undici-types: 7.16.0 '@types/node@25.2.2': dependencies: @@ -1193,14 +1190,14 @@ snapshots: '@types/responselike@1.0.3': dependencies: - '@types/node': 20.19.33 + '@types/node': 25.2.2 '@types/verror@1.10.11': optional: true '@types/yauzl@2.10.3': dependencies: - '@types/node': 20.19.33 + '@types/node': 25.2.2 optional: true '@xmldom/xmldom@0.8.11': {} @@ -1597,10 +1594,10 @@ snapshots: transitivePeerDependencies: - supports-color - electron@30.5.1: + electron@40.3.0: dependencies: '@electron/get': 2.0.3 - '@types/node': 20.19.33 + '@types/node': 24.10.13 extract-zip: 2.0.1 transitivePeerDependencies: - supports-color @@ -2211,8 +2208,6 @@ snapshots: typescript@5.9.3: {} - undici-types@6.21.0: {} - undici-types@7.16.0: {} universalify@0.1.2: {}