From 881b409ebcf8741b8b7514450a7ae43e3ac2befe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=A8=E3=82=A4=E3=82=AB=E3=82=AF?= <62183434+zouyonghe@users.noreply.github.com> Date: Sat, 28 Feb 2026 01:06:47 +0900 Subject: [PATCH] feat: improve plugin failure handling and extension list UX (#5535) * feat: improve plugin failure handling and extension list UX * fix: address plugin review comments * fix: clear stale reload feedback on failed plugin reload * fix: refine extension i18n and uninstall flow * fix: refresh extension list after install failure * feat: add random plugin visibility controls in market * refactor: extract extension helpers and simplify uninstall flow * refactor: improve failed plugin diagnostics and uninstall flow * refactor: streamline extension uninstall flow * fix: harden failed plugin install tracking and cleanup * refactor: simplify extension flows and remove unused timed message * fix: improve failed uninstall idempotency and extension error handling * refactor: unify extension install-uninstall orchestration --- astrbot/core/star/error_messages.py | 18 + astrbot/core/star/star_manager.py | 304 +++++++++--- astrbot/dashboard/routes/plugin.py | 29 ++ .../src/components/shared/ExtensionCard.vue | 19 +- .../locales/en-US/features/extension.json | 12 + .../locales/zh-CN/features/extension.json | 12 + dashboard/src/utils/errorUtils.js | 44 ++ dashboard/src/views/ExtensionPage.vue | 2 +- .../views/extension/InstalledPluginsTab.vue | 182 +++++--- .../src/views/extension/MarketPluginsTab.vue | 137 +++--- .../src/views/extension/useExtensionPage.js | 437 ++++++++++++------ 11 files changed, 847 insertions(+), 349 deletions(-) create mode 100644 astrbot/core/star/error_messages.py create mode 100644 dashboard/src/utils/errorUtils.js diff --git a/astrbot/core/star/error_messages.py b/astrbot/core/star/error_messages.py new file mode 100644 index 000000000..99de4d19b --- /dev/null +++ b/astrbot/core/star/error_messages.py @@ -0,0 +1,18 @@ +"""Shared plugin error message templates for star manager flows.""" + +PLUGIN_ERROR_TEMPLATES = { + "not_found_in_failed_list": "插件不存在于失败列表中。", + "reserved_plugin_cannot_uninstall": "该插件是 AstrBot 保留插件,无法卸载。", + "failed_plugin_dir_remove_error": ( + "移除失败插件成功,但是删除插件文件夹失败: {error}。" + "您可以手动删除该文件夹,位于 addons/plugins/ 下。" + ), +} + + +def format_plugin_error(key: str, **kwargs) -> str: + template = PLUGIN_ERROR_TEMPLATES.get(key, key) + try: + return template.format(**kwargs) + except Exception: + return template diff --git a/astrbot/core/star/star_manager.py b/astrbot/core/star/star_manager.py index 13251d2ba..68c58fdae 100644 --- a/astrbot/core/star/star_manager.py +++ b/astrbot/core/star/star_manager.py @@ -31,6 +31,7 @@ from astrbot.core.utils.metrics import Metric from . import StarMetadata from .command_management import sync_command_configs from .context import Context +from .error_messages import format_plugin_error from .filter.permission import PermissionType, PermissionTypeFilter from .star import star_map, star_registry from .star_handler import EventType, star_handlers_registry @@ -415,6 +416,68 @@ class PluginManager: llm_tools.func_list.remove(tool) logger.info(f"清理工具: {tool.name}") + def _build_failed_plugin_record( + self, + *, + root_dir_name: str, + plugin_dir_path: str, + reserved: bool, + error: Exception | str, + error_trace: str, + ) -> dict: + record: dict = { + "name": root_dir_name, + "error": str(error), + "traceback": error_trace, + "reserved": reserved, + } + try: + metadata = self._load_plugin_metadata(plugin_path=plugin_dir_path) + if metadata: + record.update( + { + "name": metadata.name, + "author": metadata.author, + "desc": metadata.desc, + "version": metadata.version, + "repo": metadata.repo, + "display_name": metadata.display_name, + "support_platforms": metadata.support_platforms, + "astrbot_version": metadata.astrbot_version, + } + ) + except Exception as metadata_error: + logger.debug( + f"读取失败插件 {root_dir_name} 元数据失败: {metadata_error!s}", + ) + + return record + + def _rebuild_failed_plugin_info(self) -> None: + if not self.failed_plugin_dict: + self.failed_plugin_info = "" + return + + lines = [] + for dir_name, info in self.failed_plugin_dict.items(): + if isinstance(info, dict): + error = info.get("error", "未知错误") + display_name = info.get("display_name") or info.get("name") or dir_name + version = info.get("version") or info.get("astrbot_version") + if version: + lines.append( + f"加载插件「{display_name}」(目录: {dir_name}, 版本: {version}) 时出现问题,原因:{error}。", + ) + else: + lines.append( + f"加载插件「{display_name}」(目录: {dir_name}) 时出现问题,原因:{error}。", + ) + else: + error = str(info) + lines.append(f"加载插件目录 {dir_name} 时出现问题,原因:{error}。") + + self.failed_plugin_info = "\n".join(lines) + "\n" + async def reload_failed_plugin(self, dir_name): """ 重新加载未注册(加载失败)的插件 @@ -435,8 +498,7 @@ class PluginManager: success, error = await self.load(specified_dir_name=dir_name) if success: self.failed_plugin_dict.pop(dir_name, None) - if not self.failed_plugin_dict: - self.failed_plugin_info = "" + self._rebuild_failed_plugin_info() return success, None else: return False, error @@ -524,7 +586,7 @@ class PluginManager: if plugin_modules is None: return False, "未找到任何插件模块" - fail_rec = "" + has_load_error = False # 导入插件模块,并尝试实例化插件类 for plugin_module in plugin_modules: @@ -566,11 +628,16 @@ class PluginManager: error_trace = traceback.format_exc() logger.error(error_trace) logger.error(f"插件 {root_dir_name} 导入失败。原因:{e!s}") - fail_rec += f"加载 {root_dir_name} 插件时出现问题,原因 {e!s}。\n" - self.failed_plugin_dict[root_dir_name] = { - "error": str(e), - "traceback": error_trace, - } + has_load_error = True + self.failed_plugin_dict[root_dir_name] = ( + self._build_failed_plugin_record( + root_dir_name=root_dir_name, + plugin_dir_path=plugin_dir_path, + reserved=reserved, + error=e, + error_trace=error_trace, + ) + ) if path in star_map: logger.info("失败插件依旧在插件列表中,正在清理...") metadata = star_map.pop(path) @@ -836,11 +903,16 @@ class PluginManager: for line in errors.split("\n"): logger.error(f"| {line}") logger.error("----------------------------------") - fail_rec += f"加载 {root_dir_name} 插件时出现问题,原因 {e!s}。\n" - self.failed_plugin_dict[root_dir_name] = { - "error": str(e), - "traceback": errors, - } + has_load_error = True + self.failed_plugin_dict[root_dir_name] = ( + self._build_failed_plugin_record( + root_dir_name=root_dir_name, + plugin_dir_path=plugin_dir_path, + reserved=reserved, + error=e, + error_trace=errors, + ) + ) # 记录注册失败的插件名称,以便后续重载插件 if path in star_map: logger.info("失败插件依旧在插件列表中,正在清理...") @@ -857,10 +929,10 @@ class PluginManager: logger.error(f"同步指令配置失败: {e!s}") logger.error(traceback.format_exc()) - if not fail_rec: - return True, None - self.failed_plugin_info = fail_rec - return False, fail_rec + self._rebuild_failed_plugin_info() + if has_load_error: + return False, self.failed_plugin_info + return True, None async def _cleanup_failed_plugin_install( self, @@ -905,6 +977,73 @@ class PluginManager: f"清理安装失败插件配置失败: {plugin_config_path},原因: {e!s}", ) + def _cleanup_plugin_optional_artifacts( + self, + *, + root_dir_name: str, + plugin_label: str, + delete_config: bool, + delete_data: bool, + ) -> None: + if delete_config: + config_file = os.path.join( + self.plugin_config_path, + f"{root_dir_name}_config.json", + ) + if os.path.exists(config_file): + try: + os.remove(config_file) + logger.info(f"已删除插件 {plugin_label} 的配置文件") + except Exception as e: + logger.warning(f"删除插件配置文件失败 ({plugin_label}): {e!s}") + + if delete_data: + data_base_dir = os.path.dirname(self.plugin_store_path) + for data_dir_name in ("plugin_data", "plugins_data"): + plugin_data_dir = os.path.join( + data_base_dir, + data_dir_name, + root_dir_name, + ) + if os.path.exists(plugin_data_dir): + try: + remove_dir(plugin_data_dir) + logger.info( + f"已删除插件 {plugin_label} 的持久化数据 ({data_dir_name})", + ) + except Exception as e: + logger.warning( + f"删除插件持久化数据失败 ({data_dir_name}, {plugin_label}): {e!s}", + ) + + def _track_failed_install_dir( + self, + *, + dir_name: str, + plugin_path: str, + error: Exception, + ) -> None: + if ( + not dir_name + or not plugin_path + or not os.path.isdir(plugin_path) + or dir_name in self.failed_plugin_dict + ): + return + + for star in self.context.get_all_stars(): + if star.root_dir_name == dir_name: + return + + self.failed_plugin_dict[dir_name] = self._build_failed_plugin_record( + root_dir_name=dir_name, + plugin_dir_path=plugin_path, + reserved=False, + error=error, + error_trace=traceback.format_exc(), + ) + self._rebuild_failed_plugin_info() + async def install_plugin( self, repo_url: str, proxy: str = "", ignore_version_check: bool = False ): @@ -934,10 +1073,8 @@ class PluginManager: async with self._pm_lock: plugin_path = "" dir_name = "" - cleanup_required = False try: plugin_path = await self.updator.install(repo_url, proxy) - cleanup_required = True # reload the plugin dir_name = os.path.basename(plugin_path) @@ -984,11 +1121,15 @@ class PluginManager: } return plugin_info - except Exception: - if cleanup_required and dir_name and plugin_path: - await self._cleanup_failed_plugin_install( - dir_name=dir_name, - plugin_path=plugin_path, + except Exception as e: + self._track_failed_install_dir( + dir_name=dir_name, + plugin_path=plugin_path, + error=e, + ) + if dir_name and plugin_path: + logger.warning( + f"安装插件 {dir_name} 失败,插件安装目录:{plugin_path}", ) raise @@ -1041,50 +1182,68 @@ class PluginManager: f"移除插件成功,但是删除插件文件夹失败: {e!s}。您可以手动删除该文件夹,位于 addons/plugins/ 下。", ) - # 删除插件配置文件 - if delete_config and root_dir_name: - config_file = os.path.join( - self.plugin_config_path, - f"{root_dir_name}_config.json", - ) - if os.path.exists(config_file): - try: - os.remove(config_file) - logger.info(f"已删除插件 {plugin_name} 的配置文件") - except Exception as e: - logger.warning(f"删除插件配置文件失败: {e!s}") + self._cleanup_plugin_optional_artifacts( + root_dir_name=root_dir_name, + plugin_label=plugin_name, + delete_config=delete_config, + delete_data=delete_data, + ) - # 删除插件持久化数据 - # 注意:需要检查两个可能的目录名(plugin_data 和 plugins_data) - # data/temp 目录可能被多个插件共享,不自动删除以防误删 - if delete_data and root_dir_name: - data_base_dir = os.path.dirname(ppath) # data/ - - # 删除 data/plugin_data 下的插件持久化数据(单数形式,新版本) - plugin_data_dir = os.path.join( - data_base_dir, "plugin_data", root_dir_name + async def uninstall_failed_plugin( + self, + dir_name: str, + delete_config: bool = False, + delete_data: bool = False, + ) -> None: + """卸载加载失败的插件(按目录名)。""" + async with self._pm_lock: + failed_info = self.failed_plugin_dict.get(dir_name) + if not failed_info: + raise Exception( + format_plugin_error("not_found_in_failed_list"), ) - if os.path.exists(plugin_data_dir): - try: - remove_dir(plugin_data_dir) - logger.info( - f"已删除插件 {plugin_name} 的持久化数据 (plugin_data)" - ) - except Exception as e: - logger.warning(f"删除插件持久化数据失败 (plugin_data): {e!s}") - # 删除 data/plugins_data 下的插件持久化数据(复数形式,旧版本兼容) - plugins_data_dir = os.path.join( - data_base_dir, "plugins_data", root_dir_name + if isinstance(failed_info, dict) and failed_info.get("reserved"): + raise Exception( + format_plugin_error("reserved_plugin_cannot_uninstall"), ) - if os.path.exists(plugins_data_dir): - try: - remove_dir(plugins_data_dir) - logger.info( - f"已删除插件 {plugin_name} 的持久化数据 (plugins_data)" - ) - except Exception as e: - logger.warning(f"删除插件持久化数据失败 (plugins_data): {e!s}") + + self._cleanup_plugin_state(dir_name) + + plugin_path = os.path.join(self.plugin_store_path, dir_name) + if os.path.exists(plugin_path): + try: + remove_dir(plugin_path) + except Exception as e: + raise Exception( + format_plugin_error( + "failed_plugin_dir_remove_error", + error=f"{e!s}", + ), + ) + else: + logger.debug( + "插件目录不存在,视为已部分卸载状态,继续清理失败插件记录和可选产物: %s", + plugin_path, + ) + + plugin_label = dir_name + if isinstance(failed_info, dict): + plugin_label = ( + failed_info.get("display_name") + or failed_info.get("name") + or dir_name + ) + + self._cleanup_plugin_optional_artifacts( + root_dir_name=dir_name, + plugin_label=plugin_label, + delete_config=delete_config, + delete_data=delete_data, + ) + + self.failed_plugin_dict.pop(dir_name, None) + self._rebuild_failed_plugin_info() async def _unbind_plugin(self, plugin_name: str, plugin_module_path: str) -> None: """解绑并移除一个插件。 @@ -1267,7 +1426,6 @@ class PluginManager: dir_name = os.path.basename(zip_file_path).replace(".zip", "") dir_name = dir_name.removesuffix("-master").removesuffix("-main").lower() desti_dir = os.path.join(self.plugin_store_path, dir_name) - cleanup_required = False # 第一步:检查是否已安装同目录名的插件,先终止旧插件 existing_plugin = None @@ -1289,7 +1447,6 @@ class PluginManager: try: self.updator.unzip_file(zip_file_path, desti_dir) - cleanup_required = True # 第二步:解压后,读取新插件的 metadata.yaml,检查是否存在同名但不同目录的插件 try: @@ -1368,10 +1525,13 @@ class PluginManager: ) return plugin_info - except Exception: - if cleanup_required: - await self._cleanup_failed_plugin_install( - dir_name=dir_name, - plugin_path=desti_dir, - ) + except Exception as e: + self._track_failed_install_dir( + dir_name=dir_name, + plugin_path=desti_dir, + error=e, + ) + logger.warning( + f"安装插件 {dir_name} 失败,插件安装目录:{desti_dir}", + ) raise diff --git a/astrbot/dashboard/routes/plugin.py b/astrbot/dashboard/routes/plugin.py index a679cf8dc..bb7769926 100644 --- a/astrbot/dashboard/routes/plugin.py +++ b/astrbot/dashboard/routes/plugin.py @@ -58,6 +58,7 @@ class PluginRoute(Route): "/plugin/update": ("POST", self.update_plugin), "/plugin/update-all": ("POST", self.update_all_plugins), "/plugin/uninstall": ("POST", self.uninstall_plugin), + "/plugin/uninstall-failed": ("POST", self.uninstall_failed_plugin), "/plugin/market_list": ("GET", self.get_online_plugins), "/plugin/off": ("POST", self.off_plugin), "/plugin/on": ("POST", self.on_plugin), @@ -565,6 +566,34 @@ class PluginRoute(Route): logger.error(traceback.format_exc()) return Response().error(str(e)).__dict__ + async def uninstall_failed_plugin(self): + if DEMO_MODE: + return ( + Response() + .error("You are not permitted to do this operation in demo mode") + .__dict__ + ) + + post_data = await request.get_json() + dir_name = post_data.get("dir_name", "") + delete_config = post_data.get("delete_config", False) + delete_data = post_data.get("delete_data", False) + if not dir_name: + return Response().error("缺少失败插件目录名").__dict__ + + try: + logger.info(f"正在卸载失败插件 {dir_name}") + await self.plugin_manager.uninstall_failed_plugin( + dir_name, + delete_config=delete_config, + delete_data=delete_data, + ) + logger.info(f"卸载失败插件 {dir_name} 成功") + return Response().ok(None, "卸载成功").__dict__ + except Exception as e: + logger.error(traceback.format_exc()) + return Response().error(str(e)).__dict__ + async def update_plugin(self): if DEMO_MODE: return ( diff --git a/dashboard/src/components/shared/ExtensionCard.vue b/dashboard/src/components/shared/ExtensionCard.vue index 765d8a77b..c32454578 100644 --- a/dashboard/src/components/shared/ExtensionCard.vue +++ b/dashboard/src/components/shared/ExtensionCard.vue @@ -189,6 +189,8 @@ const viewChangelog = () => { class="ml-2" icon="mdi-update" size="small" + style="cursor: pointer" + @click.stop="updateExtension" > { {{ extension.online_version }} - - - {{ tm("card.status.disabled") }} -

@@ -416,7 +461,7 @@ const {