diff --git a/astrbot/core/star/star_manager.py b/astrbot/core/star/star_manager.py index 57be1e9a9..371afe1e5 100644 --- a/astrbot/core/star/star_manager.py +++ b/astrbot/core/star/star_manager.py @@ -2,6 +2,7 @@ import asyncio import contextlib +import copy import functools import inspect import json @@ -620,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) @@ -1376,6 +1379,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 d151bbe6f..d1b138b99 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 07affcd62..a6d1c9761 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,9 @@ "operationFailed": "Operation failed", "reloadSuccess": "Reload successful", "reloadFailed": "Reload failed", + "reinstalling": "Reinstalling", + "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 f42173ffa..97da39e20 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,9 @@ "operationFailed": "操作失败", "reloadSuccess": "重载成功", "reloadFailed": "重载失败", + "reinstalling": "正在重新安装", + "reinstallSuccess": "重新安装成功", + "reinstallFailed": "重新安装失败", "updateSuccess": "更新成功!", "addSuccess": "添加成功!", "saveSuccess": "保存成功!", diff --git a/dashboard/src/views/extension/InstalledPluginsTab.vue b/dashboard/src/views/extension/InstalledPluginsTab.vue index 82d0d75e5..e667db963 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, @@ -109,6 +110,7 @@ const { failedPluginItems, getExtensions, reloadFailedPlugin, + reinstallFailedPlugin, checkUpdate, uninstallExtension, requestUninstallFailedPlugin, @@ -308,16 +310,44 @@ const { color="primary" class="mr-2" prepend-icon="mdi-refresh" + :disabled=" + reinstallingFailedPluginDirName === plugin.dir_name + " @click="reloadFailedPlugin(plugin.dir_name)" > {{ tm("buttons.reload") }} + + {{ tm("card.actions.reinstall") }} + {{ tm("buttons.uninstall") }} diff --git a/dashboard/src/views/extension/useExtensionPage.js b/dashboard/src/views/extension/useExtensionPage.js index 6680eb8cd..1540e8252 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,6 +635,36 @@ export const useExtensionPage = () => { } }; + 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", { + 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"); + } finally { + if (reinstallingFailedPluginDirName.value === dirName) { + reinstallingFailedPluginDirName.value = ""; + } + } + }; + const requestUninstall = (target) => { if (!target?.id || !target?.kind) return; uninstallTarget.value = target; @@ -1580,6 +1611,7 @@ export const useExtensionPage = () => { selectedPlugin, curr_namespace, updatingAll, + reinstallingFailedPluginDirName, readmeDialog, forceUpdateDialog, updateAllConfirmDialog, @@ -1651,6 +1683,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 b1dafc87e..5f50f3a0b 100644 --- a/tests/test_plugin_manager.py +++ b/tests/test_plugin_manager.py @@ -336,6 +336,131 @@ def mock_load_and_register(*args, **kwargs): assert events[1] == ("load", TEST_PLUGIN_DIR) +@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_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