From 53c3f8d6dc978cc8bd156a487f8552e6a65a3bf9 Mon Sep 17 00:00:00 2001
From: Gargantua <22532097@zju.edu.cn>
Date: Wed, 11 Mar 2026 22:16:53 +0800
Subject: [PATCH 1/3] feat: add failed plugin reinstall action
---
astrbot/core/star/star_manager.py | 60 ++++++++++
astrbot/dashboard/routes/plugin.py | 38 ++++++
.../locales/en-US/features/extension.json | 4 +-
.../locales/zh-CN/features/extension.json | 4 +-
.../views/extension/InstalledPluginsTab.vue | 12 ++
.../src/views/extension/useExtensionPage.js | 24 ++++
tests/test_plugin_manager.py | 113 +++++++++++++++++-
7 files changed, 252 insertions(+), 3 deletions(-)
diff --git a/astrbot/core/star/star_manager.py b/astrbot/core/star/star_manager.py
index cf000c5a48..0f414bc682 100644
--- a/astrbot/core/star/star_manager.py
+++ b/astrbot/core/star/star_manager.py
@@ -1,6 +1,7 @@
"""插件的重载、启停、安装、卸载等操作。"""
import asyncio
+import copy
import functools
import inspect
import json
@@ -1326,6 +1327,65 @@ async def uninstall_failed_plugin(
self.failed_plugin_dict.pop(dir_name, None)
self._rebuild_failed_plugin_info()
+ async def reinstall_failed_plugin(self, dir_name: str, proxy: str = ""):
+ """重新安装加载失败的插件(按目录名)。
+
+ 仅支持包含仓库地址的失败插件。该操作会移除当前失败安装目录,
+ 但保留已有的配置和插件数据,然后按原仓库地址重新安装。
+ """
+
+ repo_url = ""
+ failed_info_snapshot = 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 isinstance(failed_info, dict):
+ repo_url = str(failed_info.get("repo") or "").strip()
+ if not repo_url:
+ raise Exception("失败插件缺少仓库地址,无法重新安装。")
+
+ failed_info_snapshot = copy.deepcopy(failed_info)
+ 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}",
+ ),
+ )
+
+ self.failed_plugin_dict.pop(dir_name, None)
+ self._rebuild_failed_plugin_info()
+
+ try:
+ return await self.install_plugin(repo_url, proxy=proxy)
+ except Exception as e:
+ async with self._pm_lock:
+ if dir_name not in self.failed_plugin_dict:
+ restored_info = failed_info_snapshot
+ if isinstance(restored_info, dict):
+ restored_info["error"] = str(e)
+ restored_info["traceback"] = traceback.format_exc()
+ else:
+ restored_info = str(e)
+ self.failed_plugin_dict[dir_name] = restored_info
+ self._rebuild_failed_plugin_info()
+ raise
+
async def _unbind_plugin(self, plugin_name: str, plugin_module_path: str) -> None:
"""解绑并移除一个插件。
diff --git a/astrbot/dashboard/routes/plugin.py b/astrbot/dashboard/routes/plugin.py
index d151bbe6f6..d1b138b999 100644
--- a/astrbot/dashboard/routes/plugin.py
+++ b/astrbot/dashboard/routes/plugin.py
@@ -60,6 +60,7 @@ def __init__(
"/plugin/update-all": ("POST", self.update_all_plugins),
"/plugin/uninstall": ("POST", self.uninstall_plugin),
"/plugin/uninstall-failed": ("POST", self.uninstall_failed_plugin),
+ "/plugin/reinstall-failed": ("POST", self.reinstall_failed_plugin),
"/plugin/market_list": ("GET", self.get_online_plugins),
"/plugin/off": ("POST", self.off_plugin),
"/plugin/on": ("POST", self.on_plugin),
@@ -624,6 +625,43 @@ async def uninstall_failed_plugin(self):
logger.error(traceback.format_exc())
return Response().error(str(e)).__dict__
+ async def reinstall_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", "")
+ proxy: str = post_data.get("proxy", None)
+ if proxy:
+ proxy = proxy.removesuffix("/")
+ if not dir_name:
+ return Response().error("缺少失败插件目录名").__dict__
+
+ try:
+ logger.info(f"正在重新安装失败插件 {dir_name}")
+ plugin_info = await self.plugin_manager.reinstall_failed_plugin(
+ dir_name,
+ proxy=proxy or "",
+ )
+ logger.info(f"重新安装失败插件 {dir_name} 成功")
+ return Response().ok(plugin_info, "重新安装成功。").__dict__
+ except PluginVersionIncompatibleError as e:
+ return {
+ "status": "warning",
+ "message": str(e),
+ "data": {
+ "warning_type": "astrbot_version_incompatible",
+ "can_ignore": True,
+ },
+ }
+ 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/i18n/locales/en-US/features/extension.json b/dashboard/src/i18n/locales/en-US/features/extension.json
index 07affcd62a..8d0cc36b2c 100644
--- a/dashboard/src/i18n/locales/en-US/features/extension.json
+++ b/dashboard/src/i18n/locales/en-US/features/extension.json
@@ -13,7 +13,7 @@
},
"failedPlugins": {
"title": "Failed to Load Plugins ({count})",
- "hint": "These plugins failed to load. You can try reload or uninstall them directly.",
+ "hint": "These plugins failed to load. You can try reload, reinstall, or uninstall them directly.",
"columns": {
"plugin": "Plugin",
"error": "Error"
@@ -197,6 +197,8 @@
"operationFailed": "Operation failed",
"reloadSuccess": "Reload successful",
"reloadFailed": "Reload failed",
+ "reinstallSuccess": "Reinstall successful",
+ "reinstallFailed": "Reinstall failed",
"updateSuccess": "Update successful!",
"addSuccess": "Add successful!",
"saveSuccess": "Save successful!",
diff --git a/dashboard/src/i18n/locales/zh-CN/features/extension.json b/dashboard/src/i18n/locales/zh-CN/features/extension.json
index f42173ffa0..339ae0607b 100644
--- a/dashboard/src/i18n/locales/zh-CN/features/extension.json
+++ b/dashboard/src/i18n/locales/zh-CN/features/extension.json
@@ -13,7 +13,7 @@
},
"failedPlugins": {
"title": "加载失败插件({count})",
- "hint": "这些插件加载失败,仍可尝试重载或直接卸载。",
+ "hint": "这些插件加载失败,仍可尝试重载、重新安装或直接卸载。",
"columns": {
"plugin": "插件",
"error": "错误"
@@ -197,6 +197,8 @@
"operationFailed": "操作失败",
"reloadSuccess": "重载成功",
"reloadFailed": "重载失败",
+ "reinstallSuccess": "重新安装成功",
+ "reinstallFailed": "重新安装失败",
"updateSuccess": "更新成功!",
"addSuccess": "添加成功!",
"saveSuccess": "保存成功!",
diff --git a/dashboard/src/views/extension/InstalledPluginsTab.vue b/dashboard/src/views/extension/InstalledPluginsTab.vue
index 82d0d75e5c..b486e44520 100644
--- a/dashboard/src/views/extension/InstalledPluginsTab.vue
+++ b/dashboard/src/views/extension/InstalledPluginsTab.vue
@@ -109,6 +109,7 @@ const {
failedPluginItems,
getExtensions,
reloadFailedPlugin,
+ reinstallFailedPlugin,
checkUpdate,
uninstallExtension,
requestUninstallFailedPlugin,
@@ -312,6 +313,17 @@ const {
>
{{ tm("buttons.reload") }}
+
+ {{ tm("card.actions.reinstall") }}
+
{
}
};
+ const reinstallFailedPlugin = async (dirName) => {
+ if (!dirName) return;
+
+ try {
+ const res = await axios.post("/api/plugin/reinstall-failed", {
+ dir_name: dirName,
+ proxy: getSelectedGitHubProxy(),
+ });
+ if (res.data.status === "error") {
+ toast(res.data.message || tm("messages.reinstallFailed"), "error");
+ return;
+ }
+ if (res.data.status === "warning") {
+ toast(res.data.message || tm("messages.reinstallFailed"), "warning");
+ return;
+ }
+ toast(res.data.message || tm("messages.reinstallSuccess"), "success");
+ await getExtensions();
+ } catch (err) {
+ toast(resolveErrorMessage(err, tm("messages.reinstallFailed")), "error");
+ }
+ };
+
const requestUninstall = (target) => {
if (!target?.id || !target?.kind) return;
uninstallTarget.value = target;
@@ -1651,6 +1674,7 @@ export const useExtensionPage = () => {
getExtensions,
handleReloadAllFailed,
reloadFailedPlugin,
+ reinstallFailedPlugin,
checkUpdate,
uninstallExtension,
requestUninstallPlugin,
diff --git a/tests/test_plugin_manager.py b/tests/test_plugin_manager.py
index 1b52990a58..239d188a31 100644
--- a/tests/test_plugin_manager.py
+++ b/tests/test_plugin_manager.py
@@ -274,6 +274,115 @@ def mock_load_and_register(*args, **kwargs):
]
+@pytest.mark.asyncio
+async def test_reinstall_failed_plugin_reuses_repo_install_flow(
+ plugin_manager_pm: PluginManager,
+ local_updator: Path,
+ monkeypatch,
+):
+ plugin_manager_pm.failed_plugin_dict[TEST_PLUGIN_DIR] = {
+ "error": "init fail",
+ "repo": TEST_PLUGIN_REPO,
+ }
+ events = []
+
+ async def mock_install(repo_url: str, proxy=""):
+ events.append(("install", repo_url, proxy))
+ return {"repo": repo_url, "name": TEST_PLUGIN_NAME}
+
+ monkeypatch.setattr(plugin_manager_pm, "install_plugin", mock_install)
+
+ result = await plugin_manager_pm.reinstall_failed_plugin(
+ TEST_PLUGIN_DIR,
+ proxy="https://ghproxy.example",
+ )
+
+ assert result == {"repo": TEST_PLUGIN_REPO, "name": TEST_PLUGIN_NAME}
+ assert events == [
+ ("install", TEST_PLUGIN_REPO, "https://ghproxy.example"),
+ ]
+ assert TEST_PLUGIN_DIR not in plugin_manager_pm.failed_plugin_dict
+ assert not local_updator.exists()
+
+
+@pytest.mark.asyncio
+async def test_reinstall_failed_plugin_requires_repo_source(
+ plugin_manager_pm: PluginManager,
+ local_updator: Path,
+):
+ plugin_manager_pm.failed_plugin_dict[TEST_PLUGIN_DIR] = {
+ "error": "init fail",
+ }
+
+ with pytest.raises(Exception, match="缺少仓库地址"):
+ await plugin_manager_pm.reinstall_failed_plugin(TEST_PLUGIN_DIR)
+
+ assert TEST_PLUGIN_DIR in plugin_manager_pm.failed_plugin_dict
+ assert local_updator.exists()
+
+
+@pytest.mark.asyncio
+async def test_reinstall_failed_plugin_restores_failed_record_when_install_fails(
+ plugin_manager_pm: PluginManager,
+ local_updator: Path,
+ monkeypatch,
+):
+ plugin_manager_pm.failed_plugin_dict[TEST_PLUGIN_DIR] = {
+ "error": "init fail",
+ "repo": TEST_PLUGIN_REPO,
+ "display_name": "Hello World",
+ }
+
+ async def mock_install(repo_url: str, proxy=""):
+ del proxy
+ assert repo_url == TEST_PLUGIN_REPO
+ raise ValueError("network down")
+
+ monkeypatch.setattr(plugin_manager_pm, "install_plugin", mock_install)
+
+ with pytest.raises(ValueError, match="network down"):
+ await plugin_manager_pm.reinstall_failed_plugin(TEST_PLUGIN_DIR)
+
+ restored_info = plugin_manager_pm.failed_plugin_dict[TEST_PLUGIN_DIR]
+ assert restored_info["repo"] == TEST_PLUGIN_REPO
+ assert restored_info["display_name"] == "Hello World"
+ assert restored_info["error"] == "network down"
+ assert "ValueError: network down" in restored_info["traceback"]
+ assert not local_updator.exists()
+
+
+@pytest.mark.asyncio
+async def test_reinstall_failed_plugin_keeps_new_failed_record_from_install_flow(
+ plugin_manager_pm: PluginManager,
+ local_updator: Path,
+ monkeypatch,
+):
+ plugin_manager_pm.failed_plugin_dict[TEST_PLUGIN_DIR] = {
+ "error": "init fail",
+ "repo": TEST_PLUGIN_REPO,
+ }
+
+ async def mock_install(repo_url: str, proxy=""):
+ del repo_url, proxy
+ plugin_manager_pm.failed_plugin_dict[TEST_PLUGIN_DIR] = {
+ "error": "new fail",
+ "repo": TEST_PLUGIN_REPO,
+ "traceback": "tracked",
+ }
+ plugin_manager_pm._rebuild_failed_plugin_info()
+ raise RuntimeError("new fail")
+
+ monkeypatch.setattr(plugin_manager_pm, "install_plugin", mock_install)
+
+ with pytest.raises(RuntimeError, match="new fail"):
+ await plugin_manager_pm.reinstall_failed_plugin(TEST_PLUGIN_DIR)
+
+ restored_info = plugin_manager_pm.failed_plugin_dict[TEST_PLUGIN_DIR]
+ assert restored_info["error"] == "new fail"
+ assert restored_info["traceback"] == "tracked"
+ assert not local_updator.exists()
+
+
@pytest.mark.asyncio
async def test_ensure_plugin_requirements_reraises_cancelled_error(
plugin_manager_pm: PluginManager, local_updator: Path, monkeypatch
@@ -337,7 +446,9 @@ async def mock_install_requirements(*args, **kwargs):
mock_install_requirements,
)
- with pytest.raises(PluginDependencyInstallError, match="install failed") as exc_info:
+ with pytest.raises(
+ PluginDependencyInstallError, match="install failed"
+ ) as exc_info:
await plugin_manager_pm._ensure_plugin_requirements(
str(local_updator),
TEST_PLUGIN_DIR,
From 6f806d7ef91197a57c6b91dccc0fadca5bce25bb Mon Sep 17 00:00:00 2001
From: Gargantua <22532097@zju.edu.cn>
Date: Thu, 12 Mar 2026 11:10:46 +0800
Subject: [PATCH 2/3] feat: add reinstall progress feedback
---
.../locales/en-US/features/extension.json | 1 +
.../locales/zh-CN/features/extension.json | 1 +
.../views/extension/InstalledPluginsTab.vue | 22 +++++++++++++++++--
.../src/views/extension/useExtensionPage.js | 13 +++++++++--
4 files changed, 33 insertions(+), 4 deletions(-)
diff --git a/dashboard/src/i18n/locales/en-US/features/extension.json b/dashboard/src/i18n/locales/en-US/features/extension.json
index 8d0cc36b2c..a6d1c97611 100644
--- a/dashboard/src/i18n/locales/en-US/features/extension.json
+++ b/dashboard/src/i18n/locales/en-US/features/extension.json
@@ -197,6 +197,7 @@
"operationFailed": "Operation failed",
"reloadSuccess": "Reload successful",
"reloadFailed": "Reload failed",
+ "reinstalling": "Reinstalling",
"reinstallSuccess": "Reinstall successful",
"reinstallFailed": "Reinstall failed",
"updateSuccess": "Update successful!",
diff --git a/dashboard/src/i18n/locales/zh-CN/features/extension.json b/dashboard/src/i18n/locales/zh-CN/features/extension.json
index 339ae0607b..97da39e209 100644
--- a/dashboard/src/i18n/locales/zh-CN/features/extension.json
+++ b/dashboard/src/i18n/locales/zh-CN/features/extension.json
@@ -197,6 +197,7 @@
"operationFailed": "操作失败",
"reloadSuccess": "重载成功",
"reloadFailed": "重载失败",
+ "reinstalling": "正在重新安装",
"reinstallSuccess": "重新安装成功",
"reinstallFailed": "重新安装失败",
"updateSuccess": "更新成功!",
diff --git a/dashboard/src/views/extension/InstalledPluginsTab.vue b/dashboard/src/views/extension/InstalledPluginsTab.vue
index b486e44520..e667db9639 100644
--- a/dashboard/src/views/extension/InstalledPluginsTab.vue
+++ b/dashboard/src/views/extension/InstalledPluginsTab.vue
@@ -43,6 +43,7 @@ const {
selectedPlugin,
curr_namespace,
updatingAll,
+ reinstallingFailedPluginDirName,
readmeDialog,
forceUpdateDialog,
updateAllConfirmDialog,
@@ -309,6 +310,9 @@ const {
color="primary"
class="mr-2"
prepend-icon="mdi-refresh"
+ :disabled="
+ reinstallingFailedPluginDirName === plugin.dir_name
+ "
@click="reloadFailedPlugin(plugin.dir_name)"
>
{{ tm("buttons.reload") }}
@@ -320,7 +324,18 @@ const {
color="warning"
class="mr-2"
prepend-icon="mdi-package-variant-closed-sync"
- @click="reinstallFailedPlugin(plugin.dir_name)"
+ :loading="
+ reinstallingFailedPluginDirName === plugin.dir_name
+ "
+ :disabled="
+ reinstallingFailedPluginDirName === plugin.dir_name
+ "
+ @click="
+ reinstallFailedPlugin(
+ plugin.dir_name,
+ plugin.display_name || plugin.name || plugin.dir_name,
+ )
+ "
>
{{ tm("card.actions.reinstall") }}
@@ -329,7 +344,10 @@ const {
variant="tonal"
color="error"
prepend-icon="mdi-delete"
- :disabled="plugin.reserved"
+ :disabled="
+ plugin.reserved ||
+ reinstallingFailedPluginDirName === plugin.dir_name
+ "
@click="requestUninstallFailedPlugin(plugin.dir_name)"
>
{{ tm("buttons.uninstall") }}
diff --git a/dashboard/src/views/extension/useExtensionPage.js b/dashboard/src/views/extension/useExtensionPage.js
index 49a69222a5..1540e82526 100644
--- a/dashboard/src/views/extension/useExtensionPage.js
+++ b/dashboard/src/views/extension/useExtensionPage.js
@@ -151,6 +151,7 @@ export const useExtensionPage = () => {
const selectedPlugin = ref({});
const curr_namespace = ref("");
const updatingAll = ref(false);
+ const reinstallingFailedPluginDirName = ref("");
const readmeDialog = reactive({
show: false,
@@ -634,8 +635,11 @@ export const useExtensionPage = () => {
}
};
- const reinstallFailedPlugin = async (dirName) => {
- if (!dirName) return;
+ const reinstallFailedPlugin = async (dirName, pluginLabel = dirName) => {
+ if (!dirName || reinstallingFailedPluginDirName.value === dirName) return;
+
+ reinstallingFailedPluginDirName.value = dirName;
+ toast(`${tm("messages.reinstalling")} ${pluginLabel}`, "primary");
try {
const res = await axios.post("/api/plugin/reinstall-failed", {
@@ -654,6 +658,10 @@ export const useExtensionPage = () => {
await getExtensions();
} catch (err) {
toast(resolveErrorMessage(err, tm("messages.reinstallFailed")), "error");
+ } finally {
+ if (reinstallingFailedPluginDirName.value === dirName) {
+ reinstallingFailedPluginDirName.value = "";
+ }
}
};
@@ -1603,6 +1611,7 @@ export const useExtensionPage = () => {
selectedPlugin,
curr_namespace,
updatingAll,
+ reinstallingFailedPluginDirName,
readmeDialog,
forceUpdateDialog,
updateAllConfirmDialog,
From 24bc8ed864fb77a862810fe62c82e8b353d049ea Mon Sep 17 00:00:00 2001
From: Gargantua <22532097@zju.edu.cn>
Date: Fri, 13 Mar 2026 21:24:49 +0800
Subject: [PATCH 3/3] fix: guard reload when failed plugin directory is missing
---
astrbot/core/star/star_manager.py | 2 ++
tests/test_plugin_manager.py | 16 ++++++++++++++++
2 files changed, 18 insertions(+)
diff --git a/astrbot/core/star/star_manager.py b/astrbot/core/star/star_manager.py
index 76b3cf0d92..371afe1e5d 100644
--- a/astrbot/core/star/star_manager.py
+++ b/astrbot/core/star/star_manager.py
@@ -621,6 +621,8 @@ async def reload_failed_plugin(self, dir_name):
self._cleanup_plugin_state(dir_name)
plugin_path = os.path.join(self.plugin_store_path, dir_name)
+ if not os.path.isdir(plugin_path):
+ return False, "插件目录不存在,无法重载,请重新安装。"
await self._ensure_plugin_requirements(plugin_path, dir_name)
success, error = await self.load(specified_dir_name=dir_name)
diff --git a/tests/test_plugin_manager.py b/tests/test_plugin_manager.py
index ebab8e0bdf..5f50f3a0ba 100644
--- a/tests/test_plugin_manager.py
+++ b/tests/test_plugin_manager.py
@@ -445,6 +445,22 @@ async def mock_install(repo_url: str, proxy=""):
assert not local_updator.exists()
+@pytest.mark.asyncio
+async def test_reload_failed_plugin_returns_error_when_plugin_dir_missing(
+ plugin_manager_pm: PluginManager,
+):
+ plugin_manager_pm.failed_plugin_dict[TEST_PLUGIN_DIR] = {
+ "error": "network down",
+ "repo": TEST_PLUGIN_REPO,
+ }
+
+ success, error = await plugin_manager_pm.reload_failed_plugin(TEST_PLUGIN_DIR)
+
+ assert success is False
+ assert error == "插件目录不存在,无法重载,请重新安装。"
+ assert TEST_PLUGIN_DIR in plugin_manager_pm.failed_plugin_dict
+
+
@pytest.mark.asyncio
async def test_ensure_plugin_requirements_reraises_cancelled_error(
plugin_manager_pm: PluginManager, local_updator: Path, monkeypatch