feat: plugin changelogs and update system

This commit is contained in:
Soulter
2026-05-04 16:34:47 +08:00
parent ca1a6c8c7f
commit 319f50be2a
9 changed files with 398 additions and 28 deletions
+4 -2
View File
@@ -1657,7 +1657,9 @@ class PluginManager:
is_reserved=plugin.reserved,
)
async def update_plugin(self, plugin_name: str, proxy="") -> None:
async def update_plugin(
self, plugin_name: str, proxy="", download_url: str = ""
) -> None:
"""升级一个插件"""
plugin = self.context.get_registered_star(plugin_name)
if not plugin:
@@ -1665,7 +1667,7 @@ class PluginManager:
if plugin.reserved:
raise Exception("该插件是 AstrBot 保留插件,无法更新。")
await self.updator.update(plugin, proxy=proxy)
await self.updator.update(plugin, proxy=proxy, download_url=download_url)
if plugin.root_dir_name:
plugin_dir_path = os.path.join(self.plugin_store_path, plugin.root_dir_name)
await self._ensure_plugin_requirements(
+14 -4
View File
@@ -31,11 +31,15 @@ class PluginUpdator(RepoZipUpdator):
return plugin_path
async def update(self, plugin: StarMetadata, proxy="") -> str:
async def update(
self, plugin: StarMetadata, proxy="", download_url: str = ""
) -> str:
repo_url = plugin.repo
if not repo_url:
raise Exception(f"Plugin {plugin.name} does not specify a repository URL.")
if not repo_url and not download_url:
raise Exception(
f"Plugin {plugin.name} does not specify a repository URL or download URL."
)
if not plugin.root_dir_name:
raise Exception(
@@ -47,7 +51,13 @@ class PluginUpdator(RepoZipUpdator):
logger.info(
f"Updating plugin at path: {plugin_path}, repository URL: {repo_url}",
)
await self.download_from_repo_url(plugin_path, repo_url, proxy=proxy)
if download_url:
logger.info(
f"Downloading plugin update archive for {plugin.name}: {download_url}"
)
await self._download_file(download_url, plugin_path + ".zip")
else:
await self.download_from_repo_url(plugin_path, repo_url, proxy=proxy)
try:
remove_dir(plugin_path)
+11 -2
View File
@@ -1708,9 +1708,12 @@ class PluginRoute(Route):
post_data = await request.get_json()
plugin_name = post_data["name"]
proxy: str = post_data.get("proxy", None)
download_url = str(post_data.get("download_url") or "").strip()
try:
logger.info(f"正在更新插件 {plugin_name}")
await self.plugin_manager.update_plugin(plugin_name, proxy)
await self.plugin_manager.update_plugin(
plugin_name, proxy, download_url=download_url
)
# self.core_lifecycle.restart()
await self.plugin_manager.reload(plugin_name)
await self._sync_skills_after_plugin_change()
@@ -1731,9 +1734,12 @@ class PluginRoute(Route):
post_data = await request.get_json()
plugin_names: list[str] = post_data.get("names") or []
proxy: str = post_data.get("proxy", "")
download_urls: dict[str, str] = post_data.get("download_urls") or {}
if not isinstance(plugin_names, list) or not plugin_names:
return Response().error("插件列表不能为空").__dict__
if not isinstance(download_urls, dict):
download_urls = {}
results = []
sem = asyncio.Semaphore(PLUGIN_UPDATE_CONCURRENCY)
@@ -1742,7 +1748,10 @@ class PluginRoute(Route):
async with sem:
try:
logger.info(f"批量更新插件 {name}")
await self.plugin_manager.update_plugin(name, proxy)
download_url = str(download_urls.get(name) or "").strip()
await self.plugin_manager.update_plugin(
name, proxy, download_url=download_url
)
return {"name": name, "status": "ok", "message": "更新成功"}
except Exception as e:
logger.error(
@@ -104,6 +104,8 @@
"notFound": "Plugin not found",
"docsTitle": "Documentation",
"docsEmpty": "No documentation",
"changelogTitle": "Changelog",
"changelogEmpty": "No changelog",
"handlerGroups": {
"page": "Pages",
"skill": "Skills",
@@ -213,6 +215,12 @@
"downloadSource": "The plugin package will be installed from:",
"githubSecurityWarning": "AstrBot cannot guarantee the safety of plugins downloaded from GitHub. Install only if you trust the plugin source."
},
"update": {
"title": "Update Extension",
"sectionTitle": "Update",
"downloadSource": "The update package will be installed from:",
"confirm": "Confirm Update"
},
"danger_warning": {
"title": "Dangerous Plugin Warning",
"message": "This plugin has been flagged as containing security risks, including unsafe code or functionalities that may cause system malfunctions or data loss. Do you wish to proceed with the installation?",
@@ -104,6 +104,8 @@
"notFound": "Плагин не найден",
"docsTitle": "Документация",
"docsEmpty": "Документация отсутствует",
"changelogTitle": "Журнал изменений",
"changelogEmpty": "Журнал изменений отсутствует",
"handlerGroups": {
"page": "Pages",
"skill": "Skills",
@@ -212,6 +214,12 @@
"downloadSource": "Пакет плагина будет установлен из:",
"githubSecurityWarning": "AstrBot не может гарантировать безопасность плагинов, загруженных с GitHub. Устанавливайте только если доверяете источнику плагина."
},
"update": {
"title": "Обновление плагина",
"sectionTitle": "Обновление",
"downloadSource": "Пакет обновления будет установлен из:",
"confirm": "Подтвердить обновление"
},
"danger_warning": {
"title": "Внимание!",
"message": "Этот плагин может содержать небезопасный код или функции, которые могут привести к нестабильности системы или потере данных. Вы уверены, что хотите продолжить установку?",
@@ -104,6 +104,8 @@
"notFound": "未找到该插件",
"docsTitle": "文档",
"docsEmpty": "暂无文档",
"changelogTitle": "更新日志",
"changelogEmpty": "暂无更新日志",
"handlerGroups": {
"page": "页面",
"skill": "技能",
@@ -213,6 +215,12 @@
"downloadSource": "将从以下位置安装插件包体:",
"githubSecurityWarning": "AstrBot 不能保证从 GitHub 上下载的插件的安全性。请确认你信任该插件来源后再安装。"
},
"update": {
"title": "更新插件",
"sectionTitle": "更新",
"downloadSource": "将从以下位置安装更新包体:",
"confirm": "确认更新"
},
"danger_warning": {
"title": "警告",
"message": "该插件可能包含不安全的代码或功能,可能导致系统异常或数据损失等。请确认是否继续安装?",
+108
View File
@@ -49,6 +49,7 @@ const {
updatingAll,
readmeDialog,
forceUpdateDialog,
updateConfirmDialog,
updateAllConfirmDialog,
changelogDialog,
getInitialListViewMode,
@@ -111,6 +112,8 @@ const {
uninstallExtension,
handleUninstallConfirm,
updateExtension,
closeUpdateConfirmDialog,
confirmUpdatePlugin,
showUpdateAllConfirm,
confirmUpdateAll,
cancelUpdateAll,
@@ -152,6 +155,11 @@ const {
selectedInstallDownloadUrl,
selectedInstallSourceUrl,
installUsesGithubSource,
selectedUpdateExtension,
selectedUpdateMarketPlugin,
selectedUpdateDownloadUrl,
selectedUpdateSourceUrl,
updateUsesGithubSource,
checkInstallCompatibility,
refreshPluginMarket,
handleLocaleChange,
@@ -221,6 +229,28 @@ const installDialogPluginLogo = computed(() => {
const logo = selectedInstallPlugin.value?.logo;
return typeof logo === "string" && logo.trim() ? logo : defaultPluginIcon;
});
const updateDialogPlugin = computed(
() => selectedUpdateMarketPlugin.value || selectedUpdateExtension.value,
);
const updateDialogPluginName = computed(() =>
updateDialogPlugin.value ? pluginName(updateDialogPlugin.value) : "",
);
const updateDialogCurrentVersion = computed(() =>
String(selectedUpdateExtension.value?.version || "").trim(),
);
const updateDialogTargetVersion = computed(() =>
String(selectedUpdateMarketPlugin.value?.version || "").trim(),
);
const updateDialogPluginLogo = computed(() => {
const logo =
selectedUpdateMarketPlugin.value?.logo || selectedUpdateExtension.value?.logo;
return typeof logo === "string" && logo.trim() ? logo : defaultPluginIcon;
});
</script>
<template>
@@ -1052,6 +1082,84 @@ const installDialogPluginLogo = computed(() => {
</v-card>
</v-dialog>
<!-- Update plugin confirmation dialog -->
<v-dialog v-model="updateConfirmDialog.show" width="500">
<v-card class="rounded-lg">
<v-card-title class="text-h3 pa-4 pb-0 pl-6">
{{ tm("dialogs.update.title") }}
</v-card-title>
<v-card-text>
<div class="market-install-confirm">
<div class="market-install-confirm__header">
<img
:src="updateDialogPluginLogo"
:alt="updateDialogPluginName"
class="market-install-confirm__logo"
/>
<div class="market-install-confirm__meta">
<div class="market-install-confirm__name">
{{ updateDialogPluginName }}
</div>
<div
v-if="updateDialogCurrentVersion || updateDialogTargetVersion"
class="market-install-confirm__author"
>
<template v-if="updateDialogTargetVersion">
{{ updateDialogCurrentVersion || tm("status.unknown") }}
<v-icon icon="mdi-arrow-right" size="14" class="mx-1" />
{{ updateDialogTargetVersion }}
</template>
<template v-else>
{{ tm("detail.info.version") }}:
{{ updateDialogCurrentVersion || tm("status.unknown") }}
</template>
</div>
</div>
</div>
<v-divider class="my-4" />
<div
v-if="selectedUpdateSourceUrl"
class="market-install-confirm__section-title"
>
{{ tm("dialogs.update.sectionTitle") }}
</div>
<div
v-if="selectedUpdateSourceUrl"
class="market-install-source text-caption text-medium-emphasis mb-3"
>
<div>{{ tm("dialogs.update.downloadSource") }}</div>
<div class="market-install-source__url">
{{ selectedUpdateSourceUrl }}
</div>
</div>
<v-alert
v-if="updateUsesGithubSource"
type="warning"
variant="tonal"
density="comfortable"
class="market-install-alert mt-4 mb-4"
>
{{ tm("dialogs.install.githubSecurityWarning") }}
</v-alert>
<ProxySelector v-if="!selectedUpdateDownloadUrl" class="mt-4" />
</div>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="grey" variant="text" @click="closeUpdateConfirmDialog">
{{ tm("buttons.cancel") }}
</v-btn>
<v-btn color="primary" variant="flat" @click="confirmUpdatePlugin">
{{ tm("dialogs.update.confirm") }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 强制更新确认对话框 -->
<v-dialog v-model="forceUpdateDialog.show" max-width="420">
<v-card class="rounded-lg">
@@ -84,6 +84,10 @@ const readmeLoading = ref(false);
const readmeError = ref("");
const readmeEmpty = ref(false);
const renderedReadme = ref("");
const changelogLoading = ref(false);
const changelogError = ref("");
const changelogEmpty = ref(false);
const renderedChangelog = ref("");
const expandedCommandGroups = ref(new Set());
const logoLoadFailed = ref(false);
const detailPageRef = ref(null);
@@ -562,6 +566,20 @@ const fetchPluginDetail = async () => {
}
};
const getDocumentUrl = (fieldName) => {
const plugin = pluginData.value || {};
const marketPlugin = props.marketPlugin || {};
return String(plugin[fieldName] || marketPlugin[fieldName] || "").trim();
};
const fetchRemoteMarkdown = async (url) => {
const res = await axios.get(url, {
responseType: "text",
transformResponse: [(data) => data],
});
return typeof res.data === "string" ? res.data : String(res.data || "");
};
const fetchReadme = async () => {
const plugin = pluginData.value || {};
if (!plugin?.name) return;
@@ -572,7 +590,25 @@ const fetchReadme = async () => {
renderedReadme.value = "";
if (isMarketDetail.value) {
readmeLoading.value = false;
const readmeUrl = getDocumentUrl("readme_url");
if (!readmeUrl) {
readmeEmpty.value = true;
readmeLoading.value = false;
return;
}
try {
const content = await fetchRemoteMarkdown(readmeUrl);
if (!content.trim()) {
readmeEmpty.value = true;
return;
}
renderedReadme.value = renderMarkdown(content);
} catch (err) {
readmeError.value = err?.message || String(err);
} finally {
readmeLoading.value = false;
}
return;
}
@@ -617,14 +653,83 @@ const fetchReadme = async () => {
}
};
const showDocsSection = computed(() => !isMarketDetail.value);
const fetchChangelog = async () => {
const plugin = pluginData.value || {};
if (!plugin?.name) return;
changelogLoading.value = true;
changelogError.value = "";
changelogEmpty.value = false;
renderedChangelog.value = "";
if (isMarketDetail.value) {
const changelogUrl = getDocumentUrl("changelog_url");
if (!changelogUrl) {
changelogEmpty.value = true;
changelogLoading.value = false;
return;
}
try {
const content = await fetchRemoteMarkdown(changelogUrl);
if (!content.trim()) {
changelogEmpty.value = true;
return;
}
renderedChangelog.value = renderMarkdown(content);
} catch (err) {
changelogError.value = err?.message || String(err);
} finally {
changelogLoading.value = false;
}
return;
}
try {
const res = await axios.get("/api/plugin/changelog", {
params: { name: plugin.name },
});
if (res.data.status !== "ok") {
changelogError.value =
res.data.message || tm("messages.operationFailed");
return;
}
const content = res.data.data?.content || "";
if (!content) {
changelogEmpty.value = true;
return;
}
renderedChangelog.value = renderMarkdown(content);
} catch (err) {
changelogError.value = err?.message || String(err);
} finally {
changelogLoading.value = false;
}
};
const showDocsSection = computed(
() => !isMarketDetail.value || !!getDocumentUrl("readme_url"),
);
const showChangelogSection = computed(
() => !isMarketDetail.value || !!getDocumentUrl("changelog_url"),
);
watch(
() => [props.plugin?.name, props.sourceTab],
() => [
props.plugin?.name,
props.sourceTab,
props.marketPlugin?.readme_url,
props.marketPlugin?.changelog_url,
],
async () => {
logoLoadFailed.value = false;
await fetchPluginDetail();
fetchReadme();
fetchChangelog();
scrollToHashTarget();
},
{ immediate: true },
@@ -889,6 +994,26 @@ onBeforeUnmount(() => {
</v-card-text>
</v-card>
</section>
<section v-if="showChangelogSection" class="detail-section">
<h3 class="detail-section__title">
{{ tm("detail.changelogTitle") }}
</h3>
<v-card class="rounded-lg docs-card" variant="outlined">
<v-card-text>
<div v-if="changelogLoading" class="docs-state">
<v-progress-circular indeterminate color="primary" />
</div>
<v-alert v-else-if="changelogError" type="error" variant="tonal">
{{ changelogError }}
</v-alert>
<div v-else-if="changelogEmpty" class="text-medium-emphasis">
{{ tm("detail.changelogEmpty") }}
</div>
<div v-else class="docs-markdown" v-html="renderedChangelog"></div>
</v-card-text>
</v-card>
</section>
</div>
</template>
+109 -17
View File
@@ -117,6 +117,12 @@ export const useExtensionPage = () => {
extensionName: "",
});
const updateConfirmDialog = reactive({
show: false,
extensionName: "",
forceUpdate: false,
});
// 更新全部插件确认对话框
const updateAllConfirmDialog = reactive({
show: false,
@@ -596,6 +602,38 @@ export const useExtensionPage = () => {
uninstall({ kind: "failed", id: dirName }, { skipConfirm: false });
};
const normalizeInstallUrl = (value) =>
String(value || "")
.trim()
.replace(/\/+$/, "");
const isGithubRepoUrl = (value) =>
/^https:\/\/github\.com\/[^/\s]+\/[^/\s]+(?:\.git)?(?:\/tree\/[^/\s]+)?$/i.test(
normalizeInstallUrl(value),
);
const getInstalledExtensionByName = (extensionName) => {
const data = Array.isArray(extension_data?.data) ? extension_data.data : [];
return data.find((extension) => extension.name === extensionName) || null;
};
const findMarketPluginForExtension = (extension) => {
if (!extension) return null;
const repo = normalizeInstallUrl(extension.repo).toLowerCase();
return (
pluginMarketData.value.find(
(plugin) =>
repo &&
normalizeInstallUrl(plugin?.repo).toLowerCase() === repo,
) ||
pluginMarketData.value.find((plugin) => plugin.name === extension.name) ||
null
);
};
const getUpdateDownloadUrl = (extension) =>
String(findMarketPluginForExtension(extension)?.download_url || "").trim();
const checkUpdate = () => {
const onlinePluginsMap = new Map();
const onlinePluginsNameMap = new Map();
@@ -657,10 +695,20 @@ export const useExtensionPage = () => {
}
};
const openUpdateConfirmDialog = (extensionName, forceUpdate = false) => {
updateConfirmDialog.extensionName = extensionName;
updateConfirmDialog.forceUpdate = forceUpdate;
updateConfirmDialog.show = true;
};
const closeUpdateConfirmDialog = () => {
updateConfirmDialog.show = false;
updateConfirmDialog.extensionName = "";
updateConfirmDialog.forceUpdate = false;
};
const updateExtension = async (extension_name, forceUpdate = false) => {
// 查找插件信息
const data = Array.isArray(extension_data?.data) ? extension_data.data : [];
const ext = data.find((e) => e.name === extension_name);
const ext = getInstalledExtensionByName(extension_name);
// 如果没有检测到更新且不是强制更新,则弹窗确认
if (!ext?.has_update && !forceUpdate) {
@@ -669,12 +717,28 @@ export const useExtensionPage = () => {
return;
}
openUpdateConfirmDialog(extension_name, forceUpdate);
};
const confirmUpdatePlugin = async () => {
const extensionName = updateConfirmDialog.extensionName;
const ext = getInstalledExtensionByName(extensionName);
if (!extensionName || !ext) {
closeUpdateConfirmDialog();
return;
}
const downloadUrl = getUpdateDownloadUrl(ext);
closeUpdateConfirmDialog();
loadingDialog.title = tm("status.loading");
loadingDialog.statusCode = 0;
loadingDialog.result = "";
loadingDialog.show = true;
try {
const res = await axios.post("/api/plugin/update", {
name: extension_name,
proxy: getSelectedGitHubProxy(),
name: extensionName,
download_url: downloadUrl,
proxy: downloadUrl ? "" : getSelectedGitHubProxy(),
});
if (res.data.status === "error") {
@@ -692,7 +756,7 @@ export const useExtensionPage = () => {
// 更新完成后弹出更新日志
viewChangelog({
name: extension_name,
name: extensionName,
repo: ext?.repo || null,
});
} catch (error) {
@@ -731,7 +795,7 @@ export const useExtensionPage = () => {
const name = forceUpdateDialog.extensionName;
forceUpdateDialog.show = false;
forceUpdateDialog.extensionName = "";
updateExtension(name, true);
openUpdateConfirmDialog(name, true);
};
const updateAllExtensions = async () => {
@@ -747,9 +811,15 @@ export const useExtensionPage = () => {
loadingDialog.show = true;
const targets = updatableExtensions.value.map((ext) => ext.name);
const downloadUrls = Object.fromEntries(
updatableExtensions.value
.map((ext) => [ext.name, getUpdateDownloadUrl(ext)])
.filter(([, downloadUrl]) => downloadUrl),
);
try {
const res = await axios.post("/api/plugin/update-all", {
names: targets,
download_urls: downloadUrls,
proxy: getSelectedGitHubProxy(),
});
@@ -921,16 +991,6 @@ export const useExtensionPage = () => {
resetInstallDialogState();
};
const normalizeInstallUrl = (value) =>
String(value || "")
.trim()
.replace(/\/+$/, "");
const isGithubRepoUrl = (value) =>
/^https:\/\/github\.com\/[^/\s]+\/[^/\s]+(?:\.git)?(?:\/tree\/[^/\s]+)?$/i.test(
normalizeInstallUrl(value),
);
const selectedInstallDownloadUrl = computed(() => {
const plugin = selectedInstallPlugin.value;
const downloadUrl = String(plugin?.download_url || "").trim();
@@ -1482,6 +1542,30 @@ export const useExtensionPage = () => {
const selectedInstallPlugin = computed(() => resolveSelectedInstallPlugin());
const selectedUpdateExtension = computed(() =>
getInstalledExtensionByName(updateConfirmDialog.extensionName),
);
const selectedUpdateMarketPlugin = computed(() =>
findMarketPluginForExtension(selectedUpdateExtension.value),
);
const selectedUpdateDownloadUrl = computed(() =>
String(selectedUpdateMarketPlugin.value?.download_url || "").trim(),
);
const selectedUpdateSourceUrl = computed(
() =>
selectedUpdateDownloadUrl.value ||
String(selectedUpdateExtension.value?.repo || "").trim(),
);
const updateUsesGithubSource = computed(
() =>
!selectedUpdateDownloadUrl.value &&
isGithubRepoUrl(selectedUpdateSourceUrl.value),
);
const checkInstallCompatibility = async () => {
installCompat.checked = false;
installCompat.compatible = true;
@@ -1677,6 +1761,7 @@ export const useExtensionPage = () => {
updatingAll,
readmeDialog,
forceUpdateDialog,
updateConfirmDialog,
updateAllConfirmDialog,
changelogDialog,
pluginSearch,
@@ -1742,6 +1827,8 @@ export const useExtensionPage = () => {
requestUninstallFailedPlugin,
handleUninstallConfirm,
updateExtension,
closeUpdateConfirmDialog,
confirmUpdatePlugin,
showUpdateAllConfirm,
confirmUpdateAll,
cancelUpdateAll,
@@ -1783,6 +1870,11 @@ export const useExtensionPage = () => {
selectedInstallDownloadUrl,
selectedInstallSourceUrl,
installUsesGithubSource,
selectedUpdateExtension,
selectedUpdateMarketPlugin,
selectedUpdateDownloadUrl,
selectedUpdateSourceUrl,
updateUsesGithubSource,
checkInstallCompatibility,
refreshPluginMarket,
handleLocaleChange,