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