Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions astrbot/core/star/star_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import asyncio
import contextlib
import copy
import functools
import inspect
import json
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Comment on lines +1430 to +1434

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid restoring stale failed entry after reinstall cleanup

When reinstall_failed_plugin catches an exception, this block restores failed_plugin_dict[dir_name] from the snapshot without verifying the plugin directory still exists. In the failure path where remove_dir(plugin_path) has already run and install_plugin fails before creating a new checkout (for example, a network failure in updator.install), the manager now advertises a failed plugin that has no on-disk directory; a later reload_failed_plugin call will report success and drop the record even though nothing was reinstalled. This leaves plugin state inconsistent and gives a false-success recovery flow.

Useful? React with 👍 / 👎.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

我已经在 24bc8ed 中修复:当失败插件目录已经不存在时,reload_failed_plugin() 会直接返回错误,不再走可能误判为成功的重载流程;同时补充了对应的回归测试。

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:
"""解绑并移除一个插件。

Expand Down
38 changes: 38 additions & 0 deletions astrbot/dashboard/routes/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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 (
Expand Down
5 changes: 4 additions & 1 deletion dashboard/src/i18n/locales/en-US/features/extension.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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!",
Expand Down
5 changes: 4 additions & 1 deletion dashboard/src/i18n/locales/zh-CN/features/extension.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
},
"failedPlugins": {
"title": "加载失败插件({count})",
"hint": "这些插件加载失败,仍可尝试重载或直接卸载。",
"hint": "这些插件加载失败,仍可尝试重载、重新安装或直接卸载。",
"columns": {
"plugin": "插件",
"error": "错误"
Expand Down Expand Up @@ -197,6 +197,9 @@
"operationFailed": "操作失败",
"reloadSuccess": "重载成功",
"reloadFailed": "重载失败",
"reinstalling": "正在重新安装",
"reinstallSuccess": "重新安装成功",
"reinstallFailed": "重新安装失败",
"updateSuccess": "更新成功!",
"addSuccess": "添加成功!",
"saveSuccess": "保存成功!",
Expand Down
32 changes: 31 additions & 1 deletion dashboard/src/views/extension/InstalledPluginsTab.vue
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ const {
selectedPlugin,
curr_namespace,
updatingAll,
reinstallingFailedPluginDirName,
readmeDialog,
forceUpdateDialog,
updateAllConfirmDialog,
Expand Down Expand Up @@ -109,6 +110,7 @@ const {
failedPluginItems,
getExtensions,
reloadFailedPlugin,
reinstallFailedPlugin,
checkUpdate,
uninstallExtension,
requestUninstallFailedPlugin,
Expand Down Expand Up @@ -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") }}
</v-btn>
<v-btn
v-if="plugin.repo"
size="small"
variant="tonal"
color="warning"
class="mr-2"
prepend-icon="mdi-package-variant-closed-sync"
: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") }}
</v-btn>
<v-btn
size="small"
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") }}
Expand Down
33 changes: 33 additions & 0 deletions dashboard/src/views/extension/useExtensionPage.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -1580,6 +1611,7 @@ export const useExtensionPage = () => {
selectedPlugin,
curr_namespace,
updatingAll,
reinstallingFailedPluginDirName,
readmeDialog,
forceUpdateDialog,
updateAllConfirmDialog,
Expand Down Expand Up @@ -1651,6 +1683,7 @@ export const useExtensionPage = () => {
getExtensions,
handleReloadAllFailed,
reloadFailedPlugin,
reinstallFailedPlugin,
checkUpdate,
uninstallExtension,
requestUninstallPlugin,
Expand Down
Loading