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 }}
-