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