Files
AstrBot/dashboard/src/views/extension/useExtensionPage.js
2026-06-28 13:45:59 +08:00

2506 lines
72 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { commandApi, pluginApi } from "@/api/v1";
import { pluginSidebarState } from "@/composables/usePluginSidebarItems";
import { useI18n, useModuleI18n } from "@/i18n/composables";
import { useCommonStore } from "@/stores/common";
import { resolveErrorMessage } from "@/utils/errorUtils";
import { getValidHashTab, replaceTabRoute } from "@/utils/hashRouteTabs.mjs";
import { getPlatformDisplayName } from "@/utils/platformUtils";
import {
buildSearchQuery,
matchesPluginSearch,
normalizeStr,
toInitials,
toPinyinText,
} from "@/utils/pluginSearch";
import { computed, onMounted, onUnmounted, reactive, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
const buildFailedPluginItems = (raw) => {
return Object.entries(raw || {}).map(([dirName, info]) => {
const detail = info && typeof info === "object" ? info : {};
return {
...detail,
dir_name: dirName,
name: detail.name || dirName,
display_name: detail.display_name || detail.name || dirName,
error: detail.error || "",
traceback: detail.traceback || "",
reserved: !!detail.reserved,
};
});
};
export const useExtensionPage = () => {
const commonStore = useCommonStore();
const { t } = useI18n();
const { tm } = useModuleI18n("features/extension");
const router = useRouter();
const route = useRoute();
const getSelectedGitHubProxy = () => {
if (typeof window === "undefined" || !window.localStorage) return "";
return localStorage.getItem("githubProxyRadioValue") === "1"
? localStorage.getItem("selectedGitHubProxy") || ""
: "";
};
// 检查指令冲突并提示
const conflictDialog = reactive({
show: false,
count: 0,
});
const checkAndPromptConflicts = async () => {
try {
const res = await commandApi.list();
if (res.data.status === "ok") {
const conflicts = res.data.data.summary?.conflicts || 0;
if (conflicts > 0) {
conflictDialog.count = conflicts;
conflictDialog.show = true;
}
}
} catch (err) {
console.debug("Failed to check command conflicts:", err);
}
};
const handleConflictConfirm = () => {
activeTab.value = "commands";
};
const fileInput = ref(null);
const activeTab = ref("installed");
const validTabs = ["installed", "market", "mcp", "skills", "components"];
const isValidTab = (tab) => validTabs.includes(tab);
const getLocationHash = () => route.hash || "";
const extractTabFromHash = (hash) => getValidHashTab(hash, validTabs);
const syncTabFromHash = (hash) => {
const tab = extractTabFromHash(hash);
if (tab) {
activeTab.value = tab;
return true;
}
return false;
};
const extension_data = reactive({
data: [],
message: "",
});
const snack_message = ref("");
const snack_show = ref(false);
const snack_success = ref("success");
const configDialog = ref(false);
const extension_config = reactive({
metadata: {},
config: {},
i18n: {},
});
const pluginMarketData = ref([]);
const loadingDialog = reactive({
show: false,
title: "",
statusCode: 0, // 0: loading, 1: success, 2: error,
result: "",
});
const curr_namespace = ref("");
const currentConfigPlugin = ref("");
const updatingAll = ref(false);
const readmeDialog = reactive({
show: false,
pluginName: "",
repoUrl: null,
});
// 强制更新确认对话框
const forceUpdateDialog = reactive({
show: false,
extensionName: "",
});
const updateConfirmDialog = reactive({
show: false,
extensionName: "",
forceUpdate: false,
});
// 更新全部插件确认对话框
const updateAllConfirmDialog = reactive({
show: false,
});
// 插件更新日志对话框(复用 ReadmeDialog
const changelogDialog = reactive({
show: false,
pluginName: "",
repoUrl: null,
});
const pluginSearch = ref("");
const loading_ = ref(false);
// 分页相关
const currentPage = ref(1);
// 危险插件确认对话框
const dangerConfirmDialog = ref(false);
const selectedDangerPlugin = ref(null);
const selectedMarketInstallPlugin = ref(null);
const installSupport = reactive({
checked: false,
supported: true,
message: "",
});
const installUrlValidation = reactive({
validating: false,
status: "idle",
message: "",
version: "",
metadata: null,
});
// AstrBot 版本范围不兼容警告对话框
const versionSupportDialog = reactive({
show: false,
message: "",
});
// 卸载插件确认对话框(列表模式用)
const showUninstallDialog = ref(false);
const uninstallTarget = ref(null);
// 自定义插件源相关
const showSourceDialog = ref(false);
const showSourceManagerDialog = ref(false);
const sourceName = ref("");
const sourceUrl = ref("");
const sourceResolving = ref(false);
const sourceResolvedUrl = ref("");
const sourceResolveVisible = ref(false);
const sourceMarketMeta = ref(null);
const customSources = ref([]);
const selectedSource = ref(null);
const showRemoveSourceDialog = ref(false);
const sourceToRemove = ref(null);
const editingSource = ref(false);
const originalSourceUrl = ref("");
const sourceBindingDialog = reactive({
show: false,
loading: false,
saving: false,
extension: null,
candidates: [],
selectedKey: "",
pendingUpdate: null,
validationSerial: 0,
});
// 插件市场相关
const extension_url = ref("");
const dialog = ref(false);
const upload_file = ref(null);
const uploadTab = ref("file");
const showPluginFullName = ref(false);
const marketSearch = ref("");
const debouncedMarketSearch = ref("");
const refreshingMarket = ref(false);
const sortBy = ref("default"); // default, stars, author, updated
const sortOrder = ref("desc"); // desc (降序) or asc (升序)
const randomPluginNames = ref([]);
const marketCategoryFilter = ref("all");
// 插件市场拼音搜索
const normalizeMarketCategory = (rawCategory) => {
const normalized = String(rawCategory || "")
.trim()
.toLowerCase();
if (!normalized) {
return "other";
}
return normalized.replace(/[\s-]+/g, "_");
};
const getMarketCategoryLabel = (key, rawCategory = "") => {
const fallbackMap = {
all: "All",
ai_tools: "AI Tools",
entertainment: "Entertainment",
productivity: "Productivity",
integrations: "Integrations",
utilities: "Utilities",
other: "Other",
};
const i18nKey = `market.categories.${key}`;
const translated = tm(i18nKey);
if (translated && !translated.includes("[MISSING:")) {
return translated;
}
if (fallbackMap[key]) {
return fallbackMap[key];
}
const normalizedRaw = String(rawCategory || "").trim();
if (normalizedRaw) {
return normalizedRaw;
}
return key
.split(/[_-]+/)
.filter(Boolean)
.map((part) => part[0].toUpperCase() + part.slice(1))
.join(" ");
};
const marketCategoryMeta = computed(() => {
const categories = new Map();
for (const plugin of pluginMarketData.value) {
const categoryKey = normalizeMarketCategory(plugin?.category);
const categoryData = categories.get(categoryKey);
if (categoryData) {
categoryData.count += 1;
continue;
}
categories.set(categoryKey, {
count: 1,
rawLabel: String(plugin?.category || "").trim(),
});
}
return categories;
});
const marketCategoryCounts = computed(() => {
const counts = { all: pluginMarketData.value.length };
for (const [
categoryKey,
categoryData,
] of marketCategoryMeta.value.entries()) {
counts[categoryKey] = categoryData.count;
}
return counts;
});
const marketCategoryItems = computed(() => {
const items = [
{
value: "all",
label: getMarketCategoryLabel("all"),
count: marketCategoryCounts.value.all || 0,
},
];
for (const [
categoryKey,
categoryData,
] of marketCategoryMeta.value.entries()) {
items.push({
value: categoryKey,
label: getMarketCategoryLabel(categoryKey, categoryData.rawLabel),
count: categoryData.count,
});
}
return items;
});
// 过滤要显示的插件
const filteredExtensions = computed(() => {
const data = Array.isArray(extension_data?.data) ? extension_data.data : [];
return data;
});
const compareInstalledPluginNames = (left, right) =>
normalizeStr(left?.name ?? "").localeCompare(
normalizeStr(right?.name ?? ""),
undefined,
{
sensitivity: "base",
},
);
const compareInstalledFallback = (left, right) => {
const reservedDiff =
Number(!!left.plugin?.reserved) - Number(!!right.plugin?.reserved);
if (reservedDiff !== 0) {
return reservedDiff;
}
const nameCompare = compareInstalledPluginNames(left.plugin, right.plugin);
return nameCompare !== 0 ? nameCompare : left.index - right.index;
};
const sortInstalledPlugins = (plugins) => {
return plugins
.map((plugin, index) => ({
plugin,
index,
}))
.sort(compareInstalledFallback)
.map((item) => item.plugin);
};
// 通过搜索过滤插件
const filteredPlugins = computed(() => {
const query = buildSearchQuery(pluginSearch.value);
const filtered = query
? filteredExtensions.value.filter((plugin) =>
matchesPluginSearch(plugin, query),
)
: filteredExtensions.value;
return sortInstalledPlugins(filtered);
});
// 过滤后的插件市场数据(带搜索)
const filteredMarketPlugins = computed(() => {
const query = buildSearchQuery(debouncedMarketSearch.value);
const targetCategory = normalizeMarketCategory(marketCategoryFilter.value);
const shouldFilterByCategory = marketCategoryFilter.value !== "all";
if (!query) {
if (!shouldFilterByCategory) {
return pluginMarketData.value;
}
return pluginMarketData.value.filter(
(plugin) =>
normalizeMarketCategory(plugin?.category) === targetCategory,
);
}
return pluginMarketData.value.filter((plugin) => {
const matchesSearch = matchesPluginSearch(plugin, query);
const matchesCategory = shouldFilterByCategory
? normalizeMarketCategory(plugin?.category) === targetCategory
: true;
return matchesSearch && matchesCategory;
});
});
// 所有插件列表,推荐插件排在前面
const sortedPlugins = computed(() => {
let plugins = [...filteredMarketPlugins.value];
// 根据排序选项排序
if (sortBy.value === "stars") {
// 按 star 数排序
plugins.sort((a, b) => {
const starsA = a.stars ?? 0;
const starsB = b.stars ?? 0;
return sortOrder.value === "desc" ? starsB - starsA : starsA - starsB;
});
} else if (sortBy.value === "author") {
// 按作者名字典序排序
plugins.sort((a, b) => {
const authorA = (a.author ?? "").toLowerCase();
const authorB = (b.author ?? "").toLowerCase();
const result = authorA.localeCompare(authorB);
return sortOrder.value === "desc" ? -result : result;
});
} else if (sortBy.value === "updated") {
// 按更新时间排序
plugins.sort((a, b) => {
const dateA = a.updated_at ? new Date(a.updated_at).getTime() : 0;
const dateB = b.updated_at ? new Date(b.updated_at).getTime() : 0;
return sortOrder.value === "desc" ? dateB - dateA : dateA - dateB;
});
} else {
// default: 推荐插件排在前面
const pinned = plugins.filter((plugin) => plugin?.pinned);
const notPinned = plugins.filter((plugin) => !plugin?.pinned);
return [...pinned, ...notPinned];
}
return plugins;
});
const RANDOM_PLUGINS_COUNT = 3;
const randomPlugins = computed(() => {
const allPlugins = pluginMarketData.value;
if (allPlugins.length === 0) return [];
const pluginsByName = new Map(
allPlugins.map((plugin) => [plugin.name, plugin]),
);
const selected = randomPluginNames.value
.map((name) => pluginsByName.get(name))
.filter(Boolean);
if (selected.length > 0) {
return selected;
}
return allPlugins.slice(
0,
Math.min(RANDOM_PLUGINS_COUNT, allPlugins.length),
);
});
const shufflePlugins = (plugins) => {
const shuffled = [...plugins];
for (let i = shuffled.length - 1; i > 0; i -= 1) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
return shuffled;
};
const refreshRandomPlugins = () => {
const shuffled = shufflePlugins(pluginMarketData.value);
randomPluginNames.value = shuffled
.slice(0, Math.min(RANDOM_PLUGINS_COUNT, shuffled.length))
.map((plugin) => plugin.name);
};
// 分页计算属性
const displayItemsPerPage = 9; // 固定每页显示9个卡片3行
const totalPages = computed(() => {
return Math.ceil(sortedPlugins.value.length / displayItemsPerPage);
});
const paginatedPlugins = computed(() => {
const start = (currentPage.value - 1) * displayItemsPerPage;
const end = start + displayItemsPerPage;
return sortedPlugins.value.slice(start, end);
});
const updatableExtensions = computed(() => {
const data = Array.isArray(extension_data?.data) ? extension_data.data : [];
return data.filter((ext) => ext.has_update);
});
// 方法
const toast = (message, success) => {
snack_message.value = message;
snack_show.value = true;
snack_success.value = success;
};
const resetLoadingDialog = () => {
loadingDialog.show = false;
loadingDialog.title = tm("dialogs.loading.title");
loadingDialog.statusCode = 0;
loadingDialog.result = "";
};
const onLoadingDialogResult = (statusCode, result, timeToClose = 2000) => {
loadingDialog.statusCode = statusCode;
loadingDialog.result = result;
if (timeToClose === -1) return;
setTimeout(resetLoadingDialog, timeToClose);
};
const failedPluginsDict = ref({});
const failedPluginItems = computed(() =>
buildFailedPluginItems(failedPluginsDict.value),
);
const getExtensions = async ({ withLoading = true } = {}) => {
if (withLoading) {
loading_.value = true;
}
try {
const res = await pluginApi.list();
Object.assign(extension_data, res.data);
// 同步插件数据到侧边栏共享状态
pluginSidebarState.plugins = res.data?.data || [];
const failRes = await pluginApi.failed();
failedPluginsDict.value = failRes.data.data || {};
if (pluginMarketData.value.length > 0) {
await checkUpdate();
}
} catch (err) {
toast(err, "error");
} finally {
if (withLoading) {
loading_.value = false;
}
}
};
const handleReloadAllFailed = async () => {
const dirNames = Object.keys(failedPluginsDict.value);
if (dirNames.length === 0) {
toast("没有需要重载的失败插件", "info");
return;
}
loading_.value = true;
try {
const promises = dirNames.map((dir) => pluginApi.reloadFailed(dir));
await Promise.all(promises);
toast("已尝试重载所有失败插件", "success");
// 清空 message 关闭对话框
extension_data.message = "";
// 刷新列表
await getExtensions();
} catch (e) {
console.error("重载失败:", e);
toast("批量重载过程中出现错误", "error");
} finally {
loading_.value = false;
}
};
const reloadFailedPlugin = async (dirName) => {
if (!dirName) return;
try {
const res = await pluginApi.reloadFailed(dirName);
if (res.data.status === "error") {
toast(res.data.message || tm("messages.reloadFailed"), "error");
return;
}
toast(res.data.message || tm("messages.reloadSuccess"), "success");
await getExtensions();
} catch (err) {
toast(resolveErrorMessage(err, tm("messages.reloadFailed")), "error");
}
};
const requestUninstall = (target) => {
if (!target?.id || !target?.kind) return;
uninstallTarget.value = target;
showUninstallDialog.value = true;
};
const uninstall = async (
target,
{ deleteConfig = false, deleteData = false, skipConfirm = false } = {},
) => {
if (!target?.id || !target?.kind) return;
if (!skipConfirm) {
requestUninstall(target);
return;
}
const isFailed = target.kind === "failed";
const options = {
delete_config: deleteConfig,
delete_data: deleteData,
};
toast(`${tm("messages.uninstalling")} ${target.id}`, "primary");
try {
const res = isFailed
? await pluginApi.uninstallFailed(target.id, options)
: await pluginApi.uninstall(target.id, options);
if (res.data.status === "error") {
toast(res.data.message, "error");
return;
}
if (!isFailed) {
Object.assign(extension_data, res.data);
}
toast(res.data.message, "success");
await getExtensions();
} catch (err) {
toast(resolveErrorMessage(err, tm("messages.operationFailed")), "error");
}
};
const requestUninstallPlugin = (name) => {
if (!name) return;
uninstall({ kind: "normal", id: name }, { skipConfirm: false });
};
const requestUninstallFailedPlugin = (dirName) => {
if (!dirName) return;
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 normalizeRegistryUrl = (value) =>
String(value || "")
.trim()
.replace(/\/+$/, "");
const getMarketPluginId = (plugin) => {
return String(plugin?.market_plugin_id || "").trim();
};
const getMarketInstallSourcePayload = () => {
const plugin = selectedMarketInstallPlugin.value;
if (
!plugin ||
normalizeInstallUrl(plugin.repo) !==
normalizeInstallUrl(extension_url.value)
) {
return {};
}
return {
install_method: "market",
registry_url: selectedSource.value || null,
market_plugin_id: getMarketPluginId(plugin),
};
};
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 source = extension.install_source || {};
if (source.implicit === true || source.install_method !== "market") {
return null;
}
if (extension.update_market_plugin) {
return extension.update_market_plugin;
}
const repo = normalizeInstallUrl(extension.repo).toLowerCase();
if (repo) {
return (
pluginMarketData.value.find(
(plugin) => normalizeInstallUrl(plugin?.repo).toLowerCase() === repo,
) || null
);
}
return (
pluginMarketData.value.find((plugin) => plugin.name === extension.name) ||
null
);
};
const getUpdateDownloadUrl = (extension) =>
String(findMarketPluginForExtension(extension)?.download_url || "").trim();
const buildMarketPluginLookup = (plugins) => {
const byIdentifier = new Map();
const byRepo = new Map();
const byName = new Map();
plugins.forEach((plugin) => {
const identifier = getMarketPluginId(plugin);
if (identifier) {
byIdentifier.set(identifier, plugin);
}
if (plugin.repo) {
byRepo.set(normalizeInstallUrl(plugin.repo).toLowerCase(), plugin);
}
byName.set(normalizeStr(plugin.name), plugin);
});
return { byIdentifier, byRepo, byName };
};
const checkUpdate = async () => {
const data = Array.isArray(extension_data?.data) ? extension_data.data : [];
const sourcePlugins = new Map();
data.forEach((extension) => {
extension.online_version = "";
extension.has_update = false;
extension.update_market_plugin = null;
const source = extension.install_source;
if (
!extension.updates_enabled ||
!source ||
source.implicit === true ||
source.install_method !== "market"
) {
return;
}
const registryUrl = normalizeRegistryUrl(source.registry_url);
if (!sourcePlugins.has(registryUrl)) {
sourcePlugins.set(registryUrl, []);
}
sourcePlugins.get(registryUrl).push(extension);
});
await Promise.all(
[...sourcePlugins.entries()].map(async ([registryUrl, extensions]) => {
let marketPlugins = [];
try {
marketPlugins =
registryUrl === normalizeRegistryUrl(selectedSource.value)
? pluginMarketData.value
: await commonStore.getPluginCollections(
false,
registryUrl || null,
);
} catch (error) {
console.warn("Failed to load plugin source for update check:", error);
return;
}
const lookup = buildMarketPluginLookup(marketPlugins);
extensions.forEach((extension) => {
const source = extension.install_source || {};
const sourceIdentifier = String(source.market_plugin_id || "").trim();
const sourceRepo = normalizeInstallUrl(source.repo).toLowerCase();
const normalizedExtensionName = normalizeStr(
extension.marketplace_name,
);
const matchedPlugin =
(sourceIdentifier && lookup.byIdentifier.get(sourceIdentifier)) ||
(sourceRepo && lookup.byRepo.get(sourceRepo)) ||
lookup.byName.get(normalizedExtensionName);
if (!matchedPlugin) {
return;
}
extension.update_market_plugin = matchedPlugin;
const localVersion = String(extension.version || "").trim();
const onlineVersion = String(matchedPlugin.version || "").trim();
const isKnownVersion =
/^v?\d+/.test(localVersion) &&
/^v?\d+/.test(onlineVersion) &&
onlineVersion !== tm("status.unknown");
const versionCompare = isKnownVersion
? compareVersions(localVersion, onlineVersion)
: 0;
extension.online_version = onlineVersion;
extension.has_update =
isKnownVersion &&
(versionCompare < 0 ||
(versionCompare === 0 &&
localVersion.includes("-") &&
!onlineVersion.includes("-")));
});
}),
);
};
const uninstallExtension = async (
extensionName,
optionsOrSkipConfirm = false,
) => {
if (!extensionName) return;
if (typeof optionsOrSkipConfirm === "boolean") {
return uninstall(
{ kind: "normal", id: extensionName },
{ skipConfirm: optionsOrSkipConfirm },
);
}
return uninstall(
{ kind: "normal", id: extensionName },
{ ...(optionsOrSkipConfirm || {}), skipConfirm: true },
);
};
// 处理卸载确认对话框的确认事件
const handleUninstallConfirm = async (options) => {
const target = uninstallTarget.value;
if (!target) return;
try {
await uninstall(target, { ...(options || {}), skipConfirm: true });
} finally {
uninstallTarget.value = null;
showUninstallDialog.value = false;
}
};
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 buildUpdateContext = (ext) => {
const source = ext?.install_source || null;
const installMethod = String(source?.install_method || "")
.trim()
.toLowerCase();
const repoUrl = normalizeInstallUrl(ext?.repo || source?.repo);
const needsSourceSelection =
Boolean(ext) &&
(!source ||
source.implicit === true ||
!["market", "github"].includes(installMethod));
return { source, installMethod, repoUrl, needsSourceSelection };
};
const performUpdateWithoutSourceSelection = (
extensionName,
ext,
forceUpdate,
repoUrl,
) => {
if (ext && ext.updates_enabled === false && !repoUrl) {
toast(
ext.update_disabled_reason || tm("messages.updateDisabled"),
"info",
);
return;
}
// 如果没有检测到更新且不是强制更新,则弹窗确认
if (!ext?.has_update && !forceUpdate) {
forceUpdateDialog.extensionName = extensionName;
forceUpdateDialog.show = true;
return;
}
openUpdateConfirmDialog(extensionName, forceUpdate);
};
const updateExtension = async (
extension_name,
forceUpdate = false,
options = {},
) => {
const ext = getInstalledExtensionByName(extension_name);
const { repoUrl, needsSourceSelection } = buildUpdateContext(ext);
if (
needsSourceSelection &&
!options.skipSourceCheck &&
!repoUrl
) {
toast(tm("messages.updateDisabled"), "info");
return;
}
if (needsSourceSelection && !options.skipSourceCheck) {
await openPluginSourceBindingDialog(ext, {
pendingUpdate: { extensionName: extension_name, forceUpdate },
});
return;
}
performUpdateWithoutSourceSelection(
extension_name,
ext,
forceUpdate,
repoUrl,
);
};
const confirmUpdatePlugin = async () => {
const extensionName = updateConfirmDialog.extensionName;
const ext = getInstalledExtensionByName(extensionName);
if (!extensionName || !ext) {
closeUpdateConfirmDialog();
return;
}
const hasDownloadUrl = Boolean(getUpdateDownloadUrl(ext));
closeUpdateConfirmDialog();
loadingDialog.title = tm("status.loading");
loadingDialog.statusCode = 0;
loadingDialog.result = "";
loadingDialog.show = true;
try {
const res = await pluginApi.update(extensionName, {
proxy: hasDownloadUrl ? "" : getSelectedGitHubProxy(),
});
if (res.data.status === "error") {
onLoadingDialogResult(2, res.data.message, -1);
return;
}
Object.assign(extension_data, res.data);
onLoadingDialogResult(1, res.data.message);
setTimeout(async () => {
toast(tm("messages.refreshing"), "info", 2000);
try {
await getExtensions();
toast(tm("messages.refreshSuccess"), "success");
// 更新完成后弹出更新日志
viewChangelog({
name: extensionName,
repo: ext?.repo || null,
});
} catch (error) {
const errorMsg =
error.response?.data?.message || error.message || String(error);
toast(`${tm("messages.refreshFailed")}: ${errorMsg}`, "error");
}
}, 1000);
} catch (err) {
toast(err, "error");
}
};
// 确认强制更新
// 显示更新全部插件确认对话框
const showUpdateAllConfirm = () => {
if (updatableExtensions.value.length === 0) {
toast(tm("messages.noUpdatesAvailable"), "info");
return;
}
updateAllConfirmDialog.show = true;
};
// 确认更新全部插件
const confirmUpdateAll = () => {
updateAllConfirmDialog.show = false;
updateAllExtensions();
};
// 取消更新全部插件
const cancelUpdateAll = () => {
updateAllConfirmDialog.show = false;
};
const confirmForceUpdate = async () => {
const name = forceUpdateDialog.extensionName;
forceUpdateDialog.show = false;
forceUpdateDialog.extensionName = "";
await updateExtension(name, true);
};
const updateAllExtensions = async () => {
if (updatingAll.value) return;
if (updatableExtensions.value.length === 0) {
toast(tm("messages.noUpdatesAvailable"), "info");
return;
}
updatingAll.value = true;
loadingDialog.title = tm("status.loading");
loadingDialog.statusCode = 0;
loadingDialog.result = "";
loadingDialog.show = true;
const targets = updatableExtensions.value.map((ext) => ext.name);
try {
const res = await pluginApi.updateMany({
names: targets,
proxy: getSelectedGitHubProxy(),
});
if (res.data.status === "error") {
onLoadingDialogResult(
2,
res.data.message ||
tm("messages.updateAllFailed", {
failed: targets.length,
total: targets.length,
}),
-1,
);
return;
}
const results = res.data.data?.results || [];
const failures = results.filter((r) => r.status !== "ok");
try {
await getExtensions();
} catch (err) {
const errorMsg =
err.response?.data?.message || err.message || String(err);
failures.push({ name: "refresh", status: "error", message: errorMsg });
}
if (failures.length === 0) {
onLoadingDialogResult(1, tm("messages.updateAllSuccess"));
} else {
const failureText = tm("messages.updateAllFailed", {
failed: failures.length,
total: targets.length,
});
const detail = failures
.map((f) => `${f.name}: ${f.message}`)
.join("\n");
onLoadingDialogResult(2, `${failureText}\n${detail}`, -1);
}
} catch (err) {
const errorMsg =
err.response?.data?.message || err.message || String(err);
onLoadingDialogResult(2, errorMsg, -1);
} finally {
updatingAll.value = false;
}
};
const pluginOn = async (extension) => {
try {
const res = await pluginApi.setEnabled(extension.name, true);
if (res.data.status === "error") {
toast(res.data.message, "error");
return;
}
toast(res.data.message, "success");
await getExtensions();
await checkAndPromptConflicts();
} catch (err) {
toast(err, "error");
}
};
const pluginOff = async (extension) => {
try {
const res = await pluginApi.setEnabled(extension.name, false);
if (res.data.status === "error") {
toast(res.data.message, "error");
return;
}
toast(res.data.message, "success");
getExtensions();
} catch (err) {
toast(err, "error");
}
};
const openExtensionConfig = async (extension_name) => {
curr_namespace.value = extension_name;
currentConfigPlugin.value = extension_name;
configDialog.value = true;
try {
const res = await pluginApi.config(extension_name);
extension_config.metadata = res.data.data.metadata;
extension_config.config = res.data.data.config;
extension_config.i18n = res.data.data.i18n || {};
} catch (err) {
toast(err, "error");
}
};
const updateConfig = async () => {
try {
const res = await pluginApi.updateConfig(
curr_namespace.value,
extension_config.config,
);
if (res.data.status === "ok") {
toast(res.data.message, "success");
} else {
toast(res.data.message, "error");
}
configDialog.value = false;
currentConfigPlugin.value = "";
extension_config.metadata = {};
extension_config.config = {};
extension_config.i18n = {};
getExtensions();
} catch (err) {
toast(err, "error");
}
};
const showPluginInfo = (plugin) => {
if (!plugin?.name) return;
router.push({
name: "ExtensionDetails",
params: { pluginId: plugin.name },
hash: "#plugin-components",
});
};
const reloadPlugin = async (plugin_name) => {
try {
const res = await pluginApi.reload(plugin_name);
if (res.data.status === "error") {
toast(res.data.message || tm("messages.reloadFailed"), "error");
return;
}
toast(tm("messages.reloadSuccess"), "success");
await getExtensions();
} catch (err) {
toast(resolveErrorMessage(err, tm("messages.reloadFailed")), "error");
}
};
const viewReadme = (plugin) => {
readmeDialog.pluginName = plugin.name;
readmeDialog.repoUrl = plugin.repo;
readmeDialog.show = true;
};
// 查看更新日志
const viewChangelog = (plugin) => {
changelogDialog.pluginName = plugin.name;
changelogDialog.repoUrl = plugin.repo;
changelogDialog.show = true;
};
const resetInstallDialogState = () => {
selectedMarketInstallPlugin.value = null;
extension_url.value = "";
upload_file.value = null;
uploadTab.value = "file";
installSupport.checked = false;
installSupport.supported = true;
installSupport.message = "";
};
const openInstallDialog = () => {
resetInstallDialogState();
dialog.value = true;
};
const closeInstallDialog = () => {
dialog.value = false;
resetInstallDialogState();
};
const selectedInstallDownloadUrl = computed(() => {
const plugin = selectedMarketInstallPlugin.value;
const downloadUrl = String(plugin?.download_url || "").trim();
if (!downloadUrl) return "";
if (
normalizeInstallUrl(plugin?.repo) !==
normalizeInstallUrl(extension_url.value)
) {
return "";
}
return downloadUrl;
});
const selectedInstallSourceUrl = computed(
() =>
selectedInstallDownloadUrl.value ||
String(extension_url.value || "").trim(),
);
const installUsesGithubSource = computed(
() =>
!selectedInstallDownloadUrl.value && isGithubRepoUrl(extension_url.value),
);
const resetInstallUrlValidation = () => {
installUrlValidation.validating = false;
installUrlValidation.status = "idle";
installUrlValidation.message = "";
installUrlValidation.version = "";
installUrlValidation.metadata = null;
};
const validatePluginRepo = async (payload) => {
const res = await pluginApi.validateRepo(payload);
if (res.data.status === "error") {
throw new Error(res.data.message || tm("messages.pluginValidateFailed"));
}
return {
data: res.data.data || {},
message: res.data.message || tm("messages.pluginValidateSuccess"),
};
};
const buildInstallRepoValidationPayload = () => {
const url = String(extension_url.value || "").trim();
if (!url) {
return null;
}
return {
url,
proxy: getSelectedGitHubProxy(),
};
};
// 为表格视图创建一个处理安装插件的函数
const handleInstallPlugin = async (plugin) => {
if (plugin.tags && plugin.tags.includes("danger")) {
selectedDangerPlugin.value = plugin;
dangerConfirmDialog.value = true;
} else {
selectedMarketInstallPlugin.value = plugin;
extension_url.value = plugin.repo;
upload_file.value = null;
dialog.value = true;
uploadTab.value = "url";
}
};
// 确认安装危险插件
const confirmDangerInstall = () => {
if (selectedDangerPlugin.value) {
selectedMarketInstallPlugin.value = selectedDangerPlugin.value;
extension_url.value = selectedDangerPlugin.value.repo;
upload_file.value = null;
dialog.value = true;
uploadTab.value = "url";
}
dangerConfirmDialog.value = false;
selectedDangerPlugin.value = null;
};
// 取消安装危险插件
const cancelDangerInstall = () => {
dangerConfirmDialog.value = false;
selectedDangerPlugin.value = null;
};
// 自定义插件源管理方法
const loadCustomSources = async () => {
try {
const res = await pluginApi.sources();
if (res.data.status === "ok") {
customSources.value = res.data.data?.sources || res.data.data || [];
} else {
toast(res.data.message, "error");
}
} catch (e) {
console.warn("Failed to load custom sources:", e);
customSources.value = [];
}
// 加载当前选中的插件源
const currentSource = localStorage.getItem("selectedPluginSource");
if (currentSource) {
selectedSource.value = currentSource;
}
};
const saveCustomSources = async () => {
try {
const res = await pluginApi.replaceSources(customSources.value);
if (res.data.status !== "ok") {
toast(res.data.message, "error");
}
} catch (e) {
toast(e, "error");
}
};
const addCustomSource = () => {
showSourceManagerDialog.value = false;
editingSource.value = false;
originalSourceUrl.value = "";
sourceName.value = "";
sourceUrl.value = "";
sourceResolvedUrl.value = "";
sourceResolveVisible.value = false;
sourceMarketMeta.value = null;
sourceResolving.value = false;
showSourceDialog.value = true;
};
const openSourceManagerDialog = async () => {
await loadCustomSources();
showSourceManagerDialog.value = true;
};
const selectPluginSource = (sourceUrl) => {
selectedSource.value = sourceUrl;
if (sourceUrl) {
localStorage.setItem("selectedPluginSource", sourceUrl);
} else {
localStorage.removeItem("selectedPluginSource");
}
// 重新加载插件市场数据
refreshPluginMarket();
};
const sourceResolveCurrent = computed(
() =>
Boolean(sourceResolvedUrl.value) &&
normalizeRegistryUrl(sourceResolvedUrl.value) ===
normalizeRegistryUrl(sourceUrl.value),
);
const validateSourceBindingRepoCandidate = async (candidate) => {
if (!candidate || candidate.install_method !== "github") {
return;
}
if (candidate.validation_status === "loading") {
return;
}
const serial = ++sourceBindingDialog.validationSerial;
candidate.validation_status = "loading";
candidate.validation_message = "";
candidate.version = "";
try {
const { data, message } = await validatePluginRepo({
url: candidate.repo,
proxy: getSelectedGitHubProxy(),
});
if (serial !== sourceBindingDialog.validationSerial) {
return;
}
candidate.version = data.version || tm("status.unknown");
candidate.validation_status = "valid";
candidate.validation_message = message;
candidate.metadata = data;
} catch (error) {
if (serial !== sourceBindingDialog.validationSerial) {
return;
}
candidate.version = tm("status.unknown");
candidate.validation_status = "error";
candidate.validation_message = resolveErrorMessage(
error,
tm("messages.pluginValidateFailed"),
);
}
};
const resolvePluginSourceCandidates = async (extension) => {
const extensionRepo = normalizeInstallUrl(
extension.repo || extension.install_source?.repo,
).toLowerCase();
if (!extensionRepo) {
return { candidates: [], selectedKey: "" };
}
await loadCustomSources();
const sources = [
{ name: tm("market.defaultSource"), url: null },
...customSources.value.map((source) => ({
name: source.name,
url: source.url,
})),
];
const currentMarketPluginId = String(
extension.install_source?.market_plugin_id || "",
).trim();
const currentRegistryUrl = normalizeRegistryUrl(
extension.install_source?.registry_url,
);
const currentInstallMethod = String(
extension.install_source?.install_method || "",
)
.trim()
.toLowerCase();
const seen = new Set();
const candidates = [];
for (const source of sources) {
let marketPlugins = [];
try {
marketPlugins = await commonStore.getPluginCollections(
false,
source.url || null,
);
} catch (error) {
console.warn("Failed to load plugin source for binding:", error);
continue;
}
marketPlugins.forEach((plugin) => {
const marketPluginId = getMarketPluginId(plugin);
const pluginRepo = normalizeInstallUrl(plugin.repo).toLowerCase();
if (!marketPluginId || !pluginRepo || pluginRepo !== extensionRepo) {
return;
}
if (
currentMarketPluginId &&
marketPluginId !== currentMarketPluginId
) {
return;
}
const registryUrl = normalizeRegistryUrl(source.url);
const key = `${registryUrl}|${marketPluginId}|${pluginRepo}`;
if (seen.has(key)) {
return;
}
seen.add(key);
candidates.push({
key,
install_method: "market",
registry_url: registryUrl || null,
registry_name: source.name,
market_plugin_id: marketPluginId,
repo: plugin.repo,
download_url: plugin.download_url || "",
version: plugin.version || tm("status.unknown"),
validation_status: "valid",
validation_message: "",
});
});
}
candidates.push({
key: `github||${extensionRepo}`,
install_method: "github",
registry_url: null,
registry_name: tm("dialogs.sourceBinding.repoOption"),
market_plugin_id: "",
repo: extension.repo || extension.install_source?.repo,
download_url: "",
version: "",
validation_status: "idle",
validation_message: "",
});
const currentCandidate =
currentInstallMethod === "github"
? candidates.find(
(candidate) =>
candidate.install_method === "github" &&
normalizeInstallUrl(candidate.repo).toLowerCase() === extensionRepo,
)
: candidates.find((candidate) => {
return (
candidate.install_method === "market" &&
normalizeRegistryUrl(candidate.registry_url) ===
currentRegistryUrl &&
(!currentMarketPluginId ||
candidate.market_plugin_id === currentMarketPluginId) &&
normalizeInstallUrl(candidate.repo).toLowerCase() === extensionRepo
);
});
return {
candidates,
selectedKey: currentCandidate?.key || candidates[0]?.key || "",
};
};
const openPluginSourceBindingDialog = async (extension, options = {}) => {
if (!extension) return;
sourceBindingDialog.show = true;
sourceBindingDialog.loading = true;
sourceBindingDialog.saving = false;
sourceBindingDialog.extension = extension;
sourceBindingDialog.candidates = [];
sourceBindingDialog.selectedKey = "";
sourceBindingDialog.pendingUpdate = options.pendingUpdate || null;
try {
const { candidates, selectedKey } =
await resolvePluginSourceCandidates(extension);
sourceBindingDialog.candidates = candidates;
sourceBindingDialog.selectedKey = selectedKey;
} finally {
sourceBindingDialog.loading = false;
}
const selectedCandidate = sourceBindingDialog.candidates.find(
(item) => item.key === sourceBindingDialog.selectedKey,
);
if (selectedCandidate?.install_method === "github") {
void validateSourceBindingRepoCandidate(selectedCandidate);
}
};
const closePluginSourceBindingDialog = () => {
sourceBindingDialog.validationSerial += 1;
sourceBindingDialog.show = false;
sourceBindingDialog.loading = false;
sourceBindingDialog.saving = false;
sourceBindingDialog.extension = null;
sourceBindingDialog.candidates = [];
sourceBindingDialog.selectedKey = "";
sourceBindingDialog.pendingUpdate = null;
};
const continuePendingUpdateAfterBinding = async (pendingUpdate) => {
if (!pendingUpdate) {
return;
}
await updateExtension(pendingUpdate.extensionName, pendingUpdate.forceUpdate, {
skipSourceCheck: true,
});
};
const confirmPluginSourceBinding = async () => {
const extension = sourceBindingDialog.extension;
const candidate = sourceBindingDialog.candidates.find(
(item) => item.key === sourceBindingDialog.selectedKey,
);
if (!extension || !candidate || sourceBindingDialog.saving) {
return;
}
sourceBindingDialog.saving = true;
try {
const pendingUpdate = sourceBindingDialog.pendingUpdate;
const payload =
candidate.install_method === "github"
? { install_method: "github" }
: {
install_method: "market",
registry_url: candidate.registry_url,
market_plugin_id: candidate.market_plugin_id,
};
const res = await pluginApi.bindSource(extension.name, payload);
if (res.data.status === "error") {
toast(res.data.message, "error");
return;
}
toast(res.data.message || tm("messages.sourceBindSuccess"), "success");
closePluginSourceBindingDialog();
await getExtensions();
checkAlreadyInstalled();
await checkUpdate();
await continuePendingUpdateAfterBinding(pendingUpdate);
} catch (error) {
const errorMsg =
error.response?.data?.message || error.message || String(error);
toast(errorMsg, "error");
} finally {
sourceBindingDialog.saving = false;
}
};
const editCustomSource = (source) => {
if (!source) return;
showSourceManagerDialog.value = false;
editingSource.value = true;
originalSourceUrl.value = source.url;
sourceName.value = source.name;
sourceUrl.value = source.url;
sourceResolvedUrl.value = source.url;
sourceResolveVisible.value = false;
sourceMarketMeta.value = null;
sourceResolving.value = false;
showSourceDialog.value = true;
};
const removeCustomSource = (source) => {
if (!source) return;
showSourceManagerDialog.value = false;
sourceToRemove.value = source;
showRemoveSourceDialog.value = true;
};
const confirmRemoveSource = () => {
if (sourceToRemove.value) {
customSources.value = customSources.value.filter(
(s) => s.url !== sourceToRemove.value.url,
);
saveCustomSources();
// 如果删除的是当前选中的源,切换到默认源
if (selectedSource.value === sourceToRemove.value.url) {
selectedSource.value = null;
localStorage.removeItem("selectedPluginSource");
// 重新加载插件市场数据
refreshPluginMarket();
}
toast(tm("market.sourceRemoved"), "success");
showRemoveSourceDialog.value = false;
sourceToRemove.value = null;
}
};
const resolveCustomSource = async () => {
const normalizedUrl = sourceUrl.value.trim();
if (!normalizedUrl) {
toast(tm("messages.fillSourceUrl"), "error");
return;
}
try {
new URL(normalizedUrl);
} catch (e) {
toast(tm("messages.invalidUrl"), "error");
return;
}
const normalizedRegistryUrl = normalizeRegistryUrl(normalizedUrl);
const originalRegistryUrl = normalizeRegistryUrl(originalSourceUrl.value);
if (
(!editingSource.value || normalizedRegistryUrl !== originalRegistryUrl) &&
customSources.value.some(
(source) => normalizeRegistryUrl(source.url) === normalizedRegistryUrl,
)
) {
toast(tm("market.sourceExists"), "error");
return;
}
sourceResolving.value = true;
try {
const res = await pluginApi.market({
custom_registry: normalizedRegistryUrl,
force_refresh: true,
});
if (res.data.status !== "ok") {
toast(res.data.message || tm("messages.sourceResolveFailed"), "error");
return;
}
const meta = res.data.data?.$meta;
sourceMarketMeta.value =
meta && typeof meta === "object" && !Array.isArray(meta) ? meta : null;
const metaName = String(sourceMarketMeta.value?.name || "").trim();
if (metaName && !sourceName.value.trim()) {
sourceName.value = metaName;
}
sourceResolvedUrl.value = normalizedRegistryUrl;
sourceResolveVisible.value = true;
toast(tm("market.sourceResolved"), "success");
} catch (error) {
toast(
resolveErrorMessage(error, tm("messages.sourceResolveFailed")),
"error",
);
} finally {
sourceResolving.value = false;
}
};
const saveCustomSource = () => {
const normalizedUrl = sourceUrl.value.trim();
if (!normalizedUrl) {
toast(tm("messages.fillSourceUrl"), "error");
return;
}
try {
new URL(normalizedUrl);
} catch (e) {
toast(tm("messages.invalidUrl"), "error");
return;
}
if (!sourceResolveCurrent.value) {
toast(tm("messages.resolveSourceFirst"), "error");
return;
}
if (!sourceName.value.trim()) {
toast(tm("messages.fillSourceName"), "error");
return;
}
const normalizedRegistryUrl = normalizeRegistryUrl(normalizedUrl);
const originalRegistryUrl = normalizeRegistryUrl(originalSourceUrl.value);
if (editingSource.value) {
const index = customSources.value.findIndex(
(s) => normalizeRegistryUrl(s.url) === originalRegistryUrl,
);
if (index !== -1) {
customSources.value[index] = {
name: sourceName.value.trim(),
url: normalizedRegistryUrl,
};
if (
normalizeRegistryUrl(selectedSource.value) === originalRegistryUrl
) {
selectedSource.value = normalizedRegistryUrl;
localStorage.setItem("selectedPluginSource", selectedSource.value);
refreshPluginMarket();
}
}
} else {
if (
customSources.value.some(
(source) =>
normalizeRegistryUrl(source.url) === normalizedRegistryUrl,
)
) {
toast(tm("market.sourceExists"), "error");
return;
}
customSources.value.push({
name: sourceName.value.trim(),
url: normalizedRegistryUrl,
});
}
saveCustomSources();
toast(
editingSource.value
? tm("market.sourceUpdated")
: tm("market.sourceAdded"),
"success",
);
// 重置表单
sourceName.value = "";
sourceUrl.value = "";
sourceResolvedUrl.value = "";
sourceResolveVisible.value = false;
sourceMarketMeta.value = null;
sourceResolving.value = false;
editingSource.value = false;
originalSourceUrl.value = "";
showSourceDialog.value = false;
};
// 插件市场显示完整插件名称
const trimExtensionName = () => {
pluginMarketData.value.forEach((plugin) => {
if (plugin.name) {
let name = plugin.name.trim().toLowerCase();
if (name.startsWith("astrbot_plugin_")) {
plugin.trimmedName = name.substring(15);
} else if (name.startsWith("astrbot_") || name.startsWith("astrbot-")) {
plugin.trimmedName = name.substring(8);
} else plugin.trimmedName = plugin.name;
}
});
};
const checkAlreadyInstalled = () => {
const data = Array.isArray(extension_data?.data) ? extension_data.data : [];
const currentRegistryUrl = normalizeRegistryUrl(selectedSource.value);
const installedByIdentifier = new Map();
const installedByRepo = new Map();
const installedByName = new Map();
data.forEach((ext) => {
const source = ext.install_source || {};
const isCurrentMarketInstall =
source.install_method === "market" &&
normalizeRegistryUrl(source.registry_url) === currentRegistryUrl;
const identifier = String(source.market_plugin_id || "").trim();
if (isCurrentMarketInstall && identifier) {
installedByIdentifier.set(identifier, ext);
}
const repo = normalizeInstallUrl(ext.repo || source.repo).toLowerCase();
if (repo) {
installedByRepo.set(repo, ext);
} else {
installedByName.set(
normalizeStr(ext.marketplace_name || ext.name),
ext,
);
}
});
for (let i = 0; i < pluginMarketData.value.length; i++) {
const plugin = pluginMarketData.value[i];
const pluginIdentifier = getMarketPluginId(plugin);
const pluginRepo = normalizeInstallUrl(plugin.repo).toLowerCase();
const matchedInstalled =
(pluginIdentifier && installedByIdentifier.get(pluginIdentifier)) ||
(pluginRepo && installedByRepo.get(pluginRepo)) ||
installedByName.get(normalizeStr(plugin.name));
// 兜底:市场源未提供字段时,回填本地已安装插件中的元数据,便于在市场页直接展示
if (matchedInstalled) {
if (
(!Array.isArray(plugin.support_platforms) ||
plugin.support_platforms.length === 0) &&
Array.isArray(matchedInstalled.support_platforms)
) {
plugin.support_platforms = matchedInstalled.support_platforms;
}
if (!plugin.astrbot_version && matchedInstalled.astrbot_version) {
plugin.astrbot_version = matchedInstalled.astrbot_version;
}
}
plugin.installed = !!matchedInstalled;
}
let installed = [];
let notInstalled = [];
for (let i = 0; i < pluginMarketData.value.length; i++) {
if (pluginMarketData.value[i].installed) {
installed.push(pluginMarketData.value[i]);
} else {
notInstalled.push(pluginMarketData.value[i]);
}
}
pluginMarketData.value = notInstalled.concat(installed);
};
const normalizeAstrBotVersionSpec = (value) => String(value || "").trim();
const normalizeVersionParts = (value) => {
const version = String(value || "")
.trim()
.replace(/^v/i, "")
.split(/[+-]/)[0];
const parts = version.split(".").map((part) => {
const match = part.match(/^\d+/);
return match ? Number.parseInt(match[0], 10) : 0;
});
return parts.length ? parts : [0];
};
const compareVersions = (left, right) => {
const leftParts = normalizeVersionParts(left);
const rightParts = normalizeVersionParts(right);
const length = Math.max(leftParts.length, rightParts.length, 3);
for (let i = 0; i < length; i += 1) {
const leftPart = leftParts[i] || 0;
const rightPart = rightParts[i] || 0;
if (leftPart > rightPart) return 1;
if (leftPart < rightPart) return -1;
}
return 0;
};
const getSupportedReleaseUpperBound = (version) => {
const parts = normalizeVersionParts(version);
if (parts.length <= 2) {
return `${(parts[0] || 0) + 1}.0`;
}
return `${parts[0] || 0}.${(parts[1] || 0) + 1}.0`;
};
const checkVersionConstraint = (currentVersion, constraint) => {
const match = constraint.match(/^(<=|>=|==|!=|~=|<|>|=)\s*(.+)$/);
if (!match) return null;
const [, operator, targetVersion] = match;
const normalizedTarget = targetVersion.trim();
if (!normalizedTarget) return null;
if (!/^v?\d+/.test(normalizedTarget)) return null;
if (operator === "~=") {
return (
compareVersions(currentVersion, normalizedTarget) >= 0 &&
compareVersions(
currentVersion,
getSupportedReleaseUpperBound(normalizedTarget),
) < 0
);
}
const comparison = compareVersions(currentVersion, normalizedTarget);
if (operator === ">" || operator === ">=") {
return operator === ">" ? comparison > 0 : comparison >= 0;
}
if (operator === "<" || operator === "<=") {
return operator === "<" ? comparison < 0 : comparison <= 0;
}
if (operator === "!=") return comparison !== 0;
return comparison === 0;
};
const checkAstrBotVersionSupport = (versionSpec, currentVersion) => {
const normalizedSpec = normalizeAstrBotVersionSpec(versionSpec);
if (!normalizedSpec) {
return { checked: false, supported: true, message: "" };
}
if (!currentVersion) {
return { checked: false, supported: true, message: "" };
}
const constraints = normalizedSpec
.split(",")
.map((item) => item.trim())
.filter(Boolean);
if (!constraints.length) {
return { checked: false, supported: true, message: "" };
}
for (const constraint of constraints) {
const supported = checkVersionConstraint(currentVersion, constraint);
if (supported === null) {
return {
checked: true,
supported: false,
message:
"Invalid astrbot_version. Use a PEP 440 range, e.g. >=4.16,<5.",
};
}
if (!supported) {
return {
checked: true,
supported: false,
message: `AstrBot ${currentVersion} does not satisfy plugin astrbot_version: ${normalizedSpec}`,
};
}
}
return { checked: true, supported: true, message: "" };
};
const annotateMarketVersionSupport = async () => {
const currentVersion =
commonStore.astrbotVersion ||
(await commonStore.fetchAstrBotVersion().catch(() => ""));
pluginMarketData.value.forEach((plugin) => {
const result = checkAstrBotVersionSupport(
plugin?.astrbot_version,
currentVersion,
);
plugin.astrbot_support_checked = result.checked;
plugin.astrbot_version_supported = result.supported;
plugin.astrbot_support_message = result.message;
});
};
const showVersionSupportWarning = (message) => {
versionSupportDialog.message = message;
versionSupportDialog.show = true;
};
const refreshExtensionsAfterInstallFailure = async () => {
try {
await getExtensions();
} catch (error) {
console.debug(
"Failed to refresh extensions after install failure:",
error,
);
}
};
const continueInstallIgnoringVersionWarning = async () => {
versionSupportDialog.show = false;
await newExtension(true);
};
const cancelInstallOnVersionWarning = () => {
versionSupportDialog.show = false;
};
const handleInstallResponse = async (resData) => {
if (
resData.status === "warning" &&
resData.data?.warning_type === "astrbot_version_unsupported"
) {
toast(resData.message, "warning");
showVersionSupportWarning(resData.message);
await refreshExtensionsAfterInstallFailure();
return false;
}
if (resData.status === "error") {
toast(resData.message, "error");
await refreshExtensionsAfterInstallFailure();
return false;
}
return true;
};
const performInstallRequest = async ({ source, ignoreVersionCheck }) => {
const shouldIgnoreVersionCheck = ignoreVersionCheck === true;
if (source === "file") {
const formData = new FormData();
formData.append("file", upload_file.value);
formData.append("ignore_version_check", String(shouldIgnoreVersionCheck));
return pluginApi.installUpload(formData);
}
const payload = {
url: extension_url.value,
download_url: selectedInstallDownloadUrl.value,
proxy: selectedInstallDownloadUrl.value ? "" : getSelectedGitHubProxy(),
ignore_version_check: shouldIgnoreVersionCheck,
...getMarketInstallSourcePayload(),
};
return installUsesGithubSource.value
? pluginApi.installGithub(payload)
: pluginApi.installUrl(payload);
};
const finalizeSuccessfulInstall = async (resData, source) => {
if (source === "file") {
upload_file.value = null;
} else {
extension_url.value = "";
}
toast(resData.message, "success");
dialog.value = false;
selectedMarketInstallPlugin.value = null;
await getExtensions();
checkAlreadyInstalled();
await checkUpdate();
viewReadme({
name: resData.data.name,
repo: resData.data.repo || null,
});
await checkAndPromptConflicts();
};
const newExtension = async (ignoreVersionCheck = false) => {
const shouldIgnoreVersionCheck = ignoreVersionCheck === true;
if (extension_url.value === "" && upload_file.value === null) {
toast(tm("messages.fillUrlOrFile"), "error");
return;
}
if (extension_url.value !== "" && upload_file.value !== null) {
toast(tm("messages.dontFillBoth"), "error");
return;
}
const source = upload_file.value !== null ? "file" : "url";
loading_.value = true;
try {
if (source === "url" && !selectedInstallDownloadUrl.value) {
if (!installUsesGithubSource.value) {
toast(tm("messages.invalidGithubRepo"), "error");
loading_.value = false;
return;
}
const validationPayload = buildInstallRepoValidationPayload();
if (!validationPayload) {
toast(tm("messages.fillUrlOrFile"), "error");
loading_.value = false;
return;
}
installUrlValidation.validating = true;
installUrlValidation.status = "loading";
installUrlValidation.message = tm("messages.validatingPlugin");
installUrlValidation.version = "";
installUrlValidation.metadata = null;
try {
const { data, message } = await validatePluginRepo(validationPayload);
installUrlValidation.status = "valid";
installUrlValidation.message = message;
installUrlValidation.version = data.version || "";
installUrlValidation.metadata = data;
} catch (error) {
const message = resolveErrorMessage(
error,
tm("messages.pluginValidateFailed"),
);
installUrlValidation.status = "error";
installUrlValidation.message = message;
toast(message, "error");
loading_.value = false;
return;
} finally {
installUrlValidation.validating = false;
}
}
const res = await performInstallRequest({
source,
ignoreVersionCheck: shouldIgnoreVersionCheck,
});
loading_.value = false;
const canContinue = await handleInstallResponse(res.data);
if (!canContinue) return;
await finalizeSuccessfulInstall(res.data, source);
} catch (err) {
loading_.value = false;
const message = resolveErrorMessage(err, tm("messages.installFailed"));
toast(message, "error");
await refreshExtensionsAfterInstallFailure();
}
};
const normalizePlatformList = (platforms) => {
if (!Array.isArray(platforms)) return [];
return platforms.filter((item) => typeof item === "string");
};
const getPlatformDisplayList = (platforms) => {
return normalizePlatformList(platforms).map((platformId) =>
getPlatformDisplayName(platformId),
);
};
const resolveSelectedInstallPlugin = () => {
if (
selectedMarketInstallPlugin.value &&
selectedMarketInstallPlugin.value.repo === extension_url.value
) {
return selectedMarketInstallPlugin.value;
}
return (
pluginMarketData.value.find(
(plugin) => plugin.repo === extension_url.value,
) || null
);
};
const selectedInstallPlugin = computed(() => resolveSelectedInstallPlugin());
const selectedUpdateExtension = computed(() =>
getInstalledExtensionByName(updateConfirmDialog.extensionName),
);
const selectedUpdateMarketPlugin = computed(() =>
findMarketPluginForExtension(selectedUpdateExtension.value),
);
const selectedSourceBindingCandidate = computed(
() =>
sourceBindingDialog.candidates.find(
(item) => item.key === sourceBindingDialog.selectedKey,
) || null,
);
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 checkInstallVersionSupport = async () => {
installSupport.checked = false;
installSupport.supported = true;
installSupport.message = "";
const plugin = selectedInstallPlugin.value;
if (!plugin?.astrbot_version || uploadTab.value !== "url") {
return;
}
const currentVersion =
commonStore.astrbotVersion ||
(await commonStore.fetchAstrBotVersion().catch(() => ""));
const result = checkAstrBotVersionSupport(
plugin.astrbot_version,
currentVersion,
);
installSupport.checked = result.checked;
installSupport.supported = result.supported;
installSupport.message = result.message;
};
// 刷新插件市场数据
const refreshPluginMarket = async () => {
refreshingMarket.value = true;
loading_.value = true;
try {
// 强制刷新插件市场数据
const data = await commonStore.getPluginCollections(
true,
selectedSource.value,
);
pluginMarketData.value = data;
trimExtensionName();
checkAlreadyInstalled();
await annotateMarketVersionSupport();
await checkUpdate();
refreshRandomPlugins();
currentPage.value = 1; // 重置到第一页
toast(tm("messages.refreshSuccess"), "success");
} catch (err) {
toast(tm("messages.refreshFailed") + " " + err, "error");
} finally {
refreshingMarket.value = false;
loading_.value = false;
}
};
// 生命周期
onMounted(async () => {
if (!syncTabFromHash(getLocationHash())) {
await replaceTabRoute(router, route, activeTab.value);
}
loading_.value = true;
try {
await getExtensions({ withLoading: false });
// 加载自定义插件源
await loadCustomSources();
// 检查是否有 open_config 参数
const plugin_name = Array.isArray(route.query.open_config)
? route.query.open_config[0]
: route.query.open_config;
if (plugin_name) {
console.log(`Opening config for plugin: ${plugin_name}`);
openExtensionConfig(plugin_name);
}
const data = await commonStore.getPluginCollections(
false,
selectedSource.value,
);
pluginMarketData.value = data;
trimExtensionName();
checkAlreadyInstalled();
await annotateMarketVersionSupport();
await checkUpdate();
refreshRandomPlugins();
} catch (err) {
toast(tm("messages.getMarketDataFailed") + " " + err, "error");
} finally {
loading_.value = false;
}
});
// 处理语言切换事件,重新加载插件配置以获取插件的 i18n 数据
const handleLocaleChange = () => {
// 如果配置对话框是打开的,重新加载当前插件的配置
if (configDialog.value && currentConfigPlugin.value) {
openExtensionConfig(currentConfigPlugin.value);
}
};
// 监听语言切换事件
window.addEventListener("astrbot-locale-changed", handleLocaleChange);
// 清理事件监听器
onUnmounted(() => {
window.removeEventListener("astrbot-locale-changed", handleLocaleChange);
});
// 搜索防抖处理
let searchDebounceTimer = null;
watch(marketSearch, (newVal) => {
if (searchDebounceTimer) {
clearTimeout(searchDebounceTimer);
}
searchDebounceTimer = setTimeout(() => {
debouncedMarketSearch.value = newVal;
// 搜索时重置到第一页
currentPage.value = 1;
}, 300); // 300ms 防抖延迟
});
watch(
[() => dialog.value, () => extension_url.value, () => uploadTab.value],
async ([dialogOpen, _, currentUploadTab]) => {
resetInstallUrlValidation();
if (!dialogOpen || currentUploadTab !== "url") {
installSupport.checked = false;
installSupport.supported = true;
installSupport.message = "";
if (!dialogOpen) {
selectedMarketInstallPlugin.value = null;
}
return;
}
await checkInstallVersionSupport();
},
);
watch(
[() => sourceBindingDialog.show, () => sourceBindingDialog.selectedKey],
([dialogOpen]) => {
if (!dialogOpen || sourceBindingDialog.loading) {
return;
}
const candidate = sourceBindingDialog.candidates.find(
(item) => item.key === sourceBindingDialog.selectedKey,
);
if (
candidate?.install_method === "github" &&
candidate.validation_status !== "valid"
) {
void validateSourceBindingRepoCandidate(candidate);
}
},
);
watch(
() => route.hash,
(newHash) => {
const tab = extractTabFromHash(newHash);
if (tab && tab !== activeTab.value) {
activeTab.value = tab;
}
},
);
watch(activeTab, (newTab) => {
if (!isValidTab(newTab)) return;
if (route.hash === `#${newTab}`) return;
void replaceTabRoute(router, route, newTab);
});
watch(marketCategoryFilter, () => {
if (activeTab.value === "market") {
currentPage.value = 1;
}
});
watch(
marketCategoryItems,
(newItems) => {
const validValues = new Set(newItems.map((item) => item.value));
if (!validValues.has(marketCategoryFilter.value)) {
marketCategoryFilter.value = "all";
}
},
{ immediate: true },
);
return {
commonStore,
t,
tm,
router,
route,
getSelectedGitHubProxy,
conflictDialog,
checkAndPromptConflicts,
handleConflictConfirm,
fileInput,
activeTab,
validTabs,
isValidTab,
getLocationHash,
extractTabFromHash,
syncTabFromHash,
extension_data,
snack_message,
snack_show,
snack_success,
configDialog,
extension_config,
pluginMarketData,
loadingDialog,
curr_namespace,
updatingAll,
readmeDialog,
forceUpdateDialog,
updateConfirmDialog,
updateAllConfirmDialog,
changelogDialog,
pluginSearch,
loading_,
currentPage,
marketCategoryFilter,
marketCategoryItems,
marketCategoryCounts,
dangerConfirmDialog,
selectedDangerPlugin,
selectedMarketInstallPlugin,
installSupport,
installUrlValidation,
versionSupportDialog,
showUninstallDialog,
uninstallTarget,
showSourceDialog,
showSourceManagerDialog,
sourceName,
sourceUrl,
sourceResolving,
sourceResolveVisible,
sourceMarketMeta,
sourceResolveCurrent,
customSources,
selectedSource,
showRemoveSourceDialog,
sourceToRemove,
editingSource,
originalSourceUrl,
sourceBindingDialog,
selectedSourceBindingCandidate,
extension_url,
dialog,
upload_file,
uploadTab,
showPluginFullName,
marketSearch,
debouncedMarketSearch,
refreshingMarket,
sortBy,
sortOrder,
randomPluginNames,
normalizeStr,
toPinyinText,
toInitials,
filteredExtensions,
filteredPlugins,
filteredMarketPlugins,
sortedPlugins,
RANDOM_PLUGINS_COUNT,
randomPlugins,
shufflePlugins,
refreshRandomPlugins,
displayItemsPerPage,
totalPages,
paginatedPlugins,
updatableExtensions,
toast,
resetLoadingDialog,
onLoadingDialogResult,
failedPluginsDict,
failedPluginItems,
getExtensions,
handleReloadAllFailed,
reloadFailedPlugin,
checkUpdate,
uninstallExtension,
requestUninstallPlugin,
requestUninstallFailedPlugin,
handleUninstallConfirm,
updateExtension,
closeUpdateConfirmDialog,
confirmUpdatePlugin,
showUpdateAllConfirm,
confirmUpdateAll,
cancelUpdateAll,
confirmForceUpdate,
updateAllExtensions,
pluginOn,
pluginOff,
openExtensionConfig,
updateConfig,
showPluginInfo,
reloadPlugin,
viewReadme,
viewChangelog,
openInstallDialog,
closeInstallDialog,
handleInstallPlugin,
confirmDangerInstall,
cancelDangerInstall,
loadCustomSources,
saveCustomSources,
addCustomSource,
openSourceManagerDialog,
selectPluginSource,
editCustomSource,
removeCustomSource,
confirmRemoveSource,
resolveCustomSource,
saveCustomSource,
openPluginSourceBindingDialog,
closePluginSourceBindingDialog,
confirmPluginSourceBinding,
trimExtensionName,
checkAlreadyInstalled,
showVersionSupportWarning,
continueInstallIgnoringVersionWarning,
cancelInstallOnVersionWarning,
newExtension,
normalizePlatformList,
getPlatformDisplayList,
resolveSelectedInstallPlugin,
selectedInstallPlugin,
selectedInstallDownloadUrl,
selectedInstallSourceUrl,
installUsesGithubSource,
selectedUpdateExtension,
selectedUpdateMarketPlugin,
selectedUpdateDownloadUrl,
selectedUpdateSourceUrl,
updateUsesGithubSource,
checkInstallVersionSupport,
refreshPluginMarket,
handleLocaleChange,
searchDebounceTimer,
};
};