From b1eb88a86386fcde57c7af0bd6552e64aafff9bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=B0=B8=E8=B5=AB?= <1259085392@qq.com> Date: Fri, 27 Feb 2026 22:54:48 +0900 Subject: [PATCH 01/13] feat: improve plugin failure handling and extension list UX --- astrbot/core/star/star_manager.py | 178 ++++++++++++++--- astrbot/dashboard/routes/plugin.py | 29 +++ .../src/components/shared/ExtensionCard.vue | 19 +- .../locales/en-US/features/extension.json | 8 + .../locales/zh-CN/features/extension.json | 8 + .../views/extension/InstalledPluginsTab.vue | 188 ++++++++++++------ .../src/views/extension/useExtensionPage.js | 134 ++++++++++++- 7 files changed, 458 insertions(+), 106 deletions(-) diff --git a/astrbot/core/star/star_manager.py b/astrbot/core/star/star_manager.py index 13251d2ba..395d106ef 100644 --- a/astrbot/core/star/star_manager.py +++ b/astrbot/core/star/star_manager.py @@ -415,6 +415,58 @@ def _cleanup_plugin_state(self, dir_name: str) -> None: 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", "未知错误") + 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 +487,7 @@ async def reload_failed_plugin(self, dir_name): 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 @@ -567,10 +618,15 @@ async def load( 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, - } + 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) @@ -837,10 +893,15 @@ async def load( 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, - } + 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 +918,10 @@ async def load( logger.error(f"同步指令配置失败: {e!s}") logger.error(traceback.format_exc()) + self._rebuild_failed_plugin_info() if not fail_rec: return True, None - self.failed_plugin_info = fail_rec - return False, fail_rec + return False, self.failed_plugin_info async def _cleanup_failed_plugin_install( self, @@ -934,10 +995,8 @@ async def install_plugin( 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) @@ -985,10 +1044,9 @@ async def install_plugin( 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, + if dir_name and plugin_path: + logger.warning( + f"安装插件 {dir_name} 失败,插件安装目录:{plugin_path}", ) raise @@ -1086,6 +1144,80 @@ async def uninstall_plugin( except Exception as e: logger.warning(f"删除插件持久化数据失败 (plugins_data): {e!s}") + 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("插件不存在于失败列表中。") + + if isinstance(failed_info, dict) and failed_info.get("reserved"): + raise Exception("该插件是 AstrBot 保留插件,无法卸载。") + + plugin_path = os.path.join(self.plugin_store_path, dir_name) + if not os.path.exists(plugin_path): + raise Exception("插件目录不存在。") + + self._cleanup_plugin_state(dir_name) + + try: + remove_dir(plugin_path) + except Exception as e: + raise Exception( + f"移除失败插件成功,但是删除插件文件夹失败: {e!s}。您可以手动删除该文件夹,位于 addons/plugins/ 下。", + ) + + if delete_config: + config_file = os.path.join( + self.plugin_config_path, + f"{dir_name}_config.json", + ) + if os.path.exists(config_file): + try: + os.remove(config_file) + logger.info(f"已删除失败插件 {dir_name} 的配置文件") + except Exception as e: + logger.warning(f"删除失败插件配置文件失败: {e!s}") + + if delete_data: + data_base_dir = os.path.dirname(self.plugin_store_path) + + plugin_data_dir = os.path.join(data_base_dir, "plugin_data", dir_name) + if os.path.exists(plugin_data_dir): + try: + remove_dir(plugin_data_dir) + logger.info( + f"已删除失败插件 {dir_name} 的持久化数据 (plugin_data)", + ) + except Exception as e: + logger.warning( + f"删除失败插件持久化数据失败 (plugin_data): {e!s}", + ) + + plugins_data_dir = os.path.join( + data_base_dir, + "plugins_data", + dir_name, + ) + if os.path.exists(plugins_data_dir): + try: + remove_dir(plugins_data_dir) + logger.info( + f"已删除失败插件 {dir_name} 的持久化数据 (plugins_data)", + ) + except Exception as e: + logger.warning( + f"删除失败插件持久化数据失败 (plugins_data): {e!s}", + ) + + 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 +1399,6 @@ async def install_plugin_from_file( 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 +1420,6 @@ async def install_plugin_from_file( try: self.updator.unzip_file(zip_file_path, desti_dir) - cleanup_required = True # 第二步:解压后,读取新插件的 metadata.yaml,检查是否存在同名但不同目录的插件 try: @@ -1369,9 +1499,7 @@ async def install_plugin_from_file( return plugin_info except Exception: - if cleanup_required: - await self._cleanup_failed_plugin_install( - dir_name=dir_name, - plugin_path=desti_dir, - ) + 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 @@ def __init__( "/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 @@ async def uninstall_plugin(self): 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 +470,7 @@ const {