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 . 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 @@ 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", "未知错误") + 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 @@ 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 @@ -524,7 +586,7 @@ async def load( if plugin_modules is None: return False, "未找到任何插件模块" - fail_rec = "" + has_load_error = False # 导入插件模块,并尝试实例化插件类 for plugin_module in plugin_modules: @@ -566,11 +628,16 @@ async def load( 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 @@ async def load( 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 @@ async def load( 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 @@ async def _cleanup_failed_plugin_install( 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 @@ 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) @@ -984,11 +1121,15 @@ 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, + 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 @@ async def uninstall_plugin( 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", + self._cleanup_plugin_optional_artifacts( + root_dir_name=root_dir_name, + plugin_label=plugin_name, + delete_config=delete_config, + delete_data=delete_data, + ) + + 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 isinstance(failed_info, dict) and failed_info.get("reserved"): + raise Exception( + format_plugin_error("reserved_plugin_cannot_uninstall"), ) - 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}") - # 删除插件持久化数据 - # 注意:需要检查两个可能的目录名(plugin_data 和 plugins_data) - # data/temp 目录可能被多个插件共享,不自动删除以防误删 - if delete_data and root_dir_name: - data_base_dir = os.path.dirname(ppath) # data/ + self._cleanup_plugin_state(dir_name) - # 删除 data/plugin_data 下的插件持久化数据(单数形式,新版本) - plugin_data_dir = os.path.join( - data_base_dir, "plugin_data", root_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, ) - 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 + plugin_label = dir_name + if isinstance(failed_info, dict): + plugin_label = ( + failed_info.get("display_name") + or failed_info.get("name") + or dir_name ) - 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_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 @@ 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 +1447,6 @@ async def install_plugin_from_file( try: self.updator.unzip_file(zip_file_path, desti_dir) - cleanup_required = True # 第二步:解压后,读取新插件的 metadata.yaml,检查是否存在同名但不同目录的插件 try: @@ -1368,10 +1525,13 @@ 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, - ) + 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 @@ 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 +461,7 @@ const {