astrbbbb / dashboard /src /views /extension /useExtensionPage.js
qa1145's picture
Upload 1245 files
8ede856 verified
import axios from "axios";
import { useCommonStore } from "@/stores/common";
import { useI18n, useModuleI18n } from "@/i18n/composables";
import { getPlatformDisplayName } from "@/utils/platformUtils";
import { resolveErrorMessage } from "@/utils/errorUtils";
import {
buildSearchQuery,
matchesPluginSearch,
normalizeStr,
toInitials,
toPinyinText,
} from "@/utils/pluginSearch";
import {
getValidHashTab,
replaceTabRoute,
} from "@/utils/hashRouteTabs.mjs";
import { ref, computed, onMounted, onUnmounted, reactive, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useDisplay } from "vuetify";
const useRandomPluginsDisplay = ({ activeTab, marketSearch, currentPage }) => {
const showRandomPlugins = ref(true);
const toggleRandomPluginsVisibility = () => {
showRandomPlugins.value = !showRandomPlugins.value;
};
const collapseRandomPlugins = () => {
showRandomPlugins.value = false;
};
watch(marketSearch, () => {
if (activeTab.value === "market") {
collapseRandomPlugins();
}
});
watch(currentPage, (newPage, oldPage) => {
if (newPage === oldPage) return;
if (activeTab.value !== "market") return;
collapseRandomPlugins();
});
return {
showRandomPlugins,
toggleRandomPluginsVisibility,
collapseRandomPlugins,
};
};
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 { width } = useDisplay();
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 axios.get("/api/commands");
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: "",
});
// 从 localStorage 恢复显示系统插件的状态,默认为 false(隐藏)
const getInitialShowReserved = () => {
if (typeof window !== "undefined" && window.localStorage) {
const saved = localStorage.getItem("showReservedPlugins");
return saved === "true";
}
return false;
};
const showReserved = ref(getInitialShowReserved());
const snack_message = ref("");
const snack_show = ref(false);
const snack_success = ref("success");
const configDialog = ref(false);
const extension_config = reactive({
metadata: {},
config: {},
});
const pluginMarketData = ref([]);
const loadingDialog = reactive({
show: false,
title: "",
statusCode: 0, // 0: loading, 1: success, 2: error,
result: "",
});
const showPluginInfoDialog = ref(false);
const selectedPlugin = ref({});
const curr_namespace = ref("");
const updatingAll = ref(false);
const readmeDialog = reactive({
show: false,
pluginName: "",
repoUrl: null,
});
// 强制更新确认对话框
const forceUpdateDialog = reactive({
show: false,
extensionName: "",
});
// 更新全部插件确认对话框
const updateAllConfirmDialog = reactive({
show: false,
});
// 插件更新日志对话框(复用 ReadmeDialog)
const changelogDialog = reactive({
show: false,
pluginName: "",
repoUrl: null,
});
// 新增变量支持列表视图
// 从 localStorage 恢复显示模式,默认为 false(卡片视图)
const getInitialListViewMode = () => {
if (typeof window !== "undefined" && window.localStorage) {
return localStorage.getItem("pluginListViewMode") === "true";
}
return false;
};
const isListView = ref(getInitialListViewMode());
const pluginSearch = ref("");
const installedStatusFilter = ref("all");
const installedSortBy = ref("default");
const installedSortOrder = ref("desc");
const loading_ = ref(false);
// 分页相关
const currentPage = ref(1);
// 危险插件确认对话框
const dangerConfirmDialog = ref(false);
const selectedDangerPlugin = ref(null);
const selectedMarketInstallPlugin = ref(null);
const installCompat = reactive({
checked: false,
compatible: true,
message: "",
});
// AstrBot 版本范围不兼容警告对话框
const versionCompatibilityDialog = 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 customSources = ref([]);
const selectedSource = ref(null);
const showRemoveSourceDialog = ref(false);
const sourceToRemove = ref(null);
const editingSource = ref(false);
const originalSourceUrl = ref("");
// 插件市场相关
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 {
showRandomPlugins,
toggleRandomPluginsVisibility,
collapseRandomPlugins,
} = useRandomPluginsDisplay({
activeTab,
marketSearch,
currentPage,
});
// 插件市场拼音搜索
const plugin_handler_info_headers = computed(() => [
{ title: tm("table.headers.eventType"), key: "event_type_h" },
{ title: tm("table.headers.description"), key: "desc", maxWidth: "250px" },
{ title: tm("table.headers.specificType"), key: "type" },
{ title: tm("table.headers.trigger"), key: "cmd" },
]);
const installedSortItems = computed(() => [
{ title: tm("sort.default"), value: "default" },
{ title: tm("sort.installTime"), value: "install_time" },
{ title: tm("sort.name"), value: "name" },
{ title: tm("sort.author"), value: "author" },
{ title: tm("sort.updateStatus"), value: "update_status" },
]);
const installedSortUsesOrder = computed(
() => installedSortBy.value !== "default",
);
// 插件表格的表头定义
const showAuthorColumn = computed(() => width.value >= 1280);
const pluginHeaders = computed(() => {
const headers = [
{
title: tm("table.headers.name"),
key: "name",
sortable: false,
width: showAuthorColumn.value ? "24%" : "26%",
},
{
title: tm("table.headers.description"),
key: "desc",
sortable: false,
width: showAuthorColumn.value ? "32%" : "36%",
},
{
title: tm("table.headers.version"),
key: "version",
sortable: false,
width: showAuthorColumn.value ? "12%" : "14%",
},
];
if (showAuthorColumn.value) {
headers.push({
title: tm("table.headers.author"),
key: "author",
sortable: false,
width: "10%",
});
}
headers.push({
title: tm("table.headers.actions"),
key: "actions",
sortable: false,
width: showAuthorColumn.value ? "22%" : "24%",
});
return headers;
});
// 过滤要显示的插件
const filteredExtensions = computed(() => {
const data = Array.isArray(extension_data?.data) ? extension_data.data : [];
if (!showReserved.value) {
return data.filter((ext) => !ext.reserved);
}
return data;
});
const compareInstalledPluginNames = (left, right) =>
normalizeStr(left?.name ?? "").localeCompare(
normalizeStr(right?.name ?? ""),
undefined,
{
sensitivity: "base",
},
);
const compareInstalledPluginAuthors = (left, right) =>
normalizeStr(left?.author ?? "").localeCompare(
normalizeStr(right?.author ?? ""),
undefined,
{ sensitivity: "base" },
);
const getInstalledAtTimestamp = (plugin) => {
const parsed = Date.parse(plugin?.installed_at ?? "");
return Number.isFinite(parsed) ? parsed : null;
};
const sortInstalledPlugins = (plugins) => {
return plugins
.map((plugin, index) => ({
plugin,
index,
installedAtTimestamp: getInstalledAtTimestamp(plugin),
}))
.sort((left, right) => {
const fallbackNameCompare = compareInstalledPluginNames(
left.plugin,
right.plugin,
);
const fallbackResult =
fallbackNameCompare !== 0 ? fallbackNameCompare : left.index - right.index;
if (installedSortBy.value === "install_time") {
const leftTimestamp = left.installedAtTimestamp;
const rightTimestamp = right.installedAtTimestamp;
if (leftTimestamp == null && rightTimestamp == null) {
return fallbackResult;
}
if (leftTimestamp == null) {
return 1;
}
if (rightTimestamp == null) {
return -1;
}
const timeDiff =
installedSortOrder.value === "desc"
? rightTimestamp - leftTimestamp
: leftTimestamp - rightTimestamp;
return timeDiff !== 0 ? timeDiff : fallbackResult;
}
if (installedSortBy.value === "name") {
const nameCompare = compareInstalledPluginNames(left.plugin, right.plugin);
if (nameCompare !== 0) {
return installedSortOrder.value === "desc"
? -nameCompare
: nameCompare;
}
return left.index - right.index;
}
if (installedSortBy.value === "author") {
const authorCompare = compareInstalledPluginAuthors(
left.plugin,
right.plugin,
);
if (authorCompare !== 0) {
return installedSortOrder.value === "desc"
? -authorCompare
: authorCompare;
}
return fallbackResult;
}
if (installedSortBy.value === "update_status") {
const leftHasUpdate = left.plugin?.has_update ? 1 : 0;
const rightHasUpdate = right.plugin?.has_update ? 1 : 0;
const updateDiff =
installedSortOrder.value === "desc"
? rightHasUpdate - leftHasUpdate
: leftHasUpdate - rightHasUpdate;
return updateDiff !== 0 ? updateDiff : fallbackResult;
}
return fallbackResult;
})
.map((item) => item.plugin);
};
// 通过搜索过滤插件
const filteredPlugins = computed(() => {
const plugins = filteredExtensions.value.filter((plugin) => {
if (installedStatusFilter.value === "enabled") {
return !!plugin.activated;
}
if (installedStatusFilter.value === "disabled") {
return !plugin.activated;
}
return true;
});
const query = buildSearchQuery(pluginSearch.value);
const filtered = query
? plugins.filter((plugin) => matchesPluginSearch(plugin, query))
: plugins;
return sortInstalledPlugins(filtered);
});
// 过滤后的插件市场数据(带搜索)
const filteredMarketPlugins = computed(() => {
const query = buildSearchQuery(debouncedMarketSearch.value);
if (!query) {
return pluginMarketData.value;
}
return pluginMarketData.value.filter((plugin) =>
matchesPluginSearch(plugin, query),
);
});
// 所有插件列表,推荐插件排在前面
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 toggleShowReserved = () => {
showReserved.value = !showReserved.value;
// 保存到 localStorage
if (typeof window !== "undefined" && window.localStorage) {
localStorage.setItem("showReservedPlugins", showReserved.value.toString());
}
};
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 () => {
loading_.value = true;
try {
const res = await axios.get("/api/plugin/get");
Object.assign(extension_data, res.data);
const failRes = await axios.get("/api/plugin/source/get-failed-plugins");
failedPluginsDict.value = failRes.data.data || {};
checkUpdate();
} catch (err) {
toast(err, "error");
} finally {
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 =>
axios.post("/api/plugin/reload-failed", { dir_name: 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 axios.post("/api/plugin/reload-failed", { dir_name: 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 endpoint = isFailed
? "/api/plugin/uninstall-failed"
: "/api/plugin/uninstall";
const payload = isFailed
? { dir_name: target.id, delete_config: deleteConfig, delete_data: deleteData }
: { name: target.id, delete_config: deleteConfig, delete_data: deleteData };
toast(`${tm("messages.uninstalling")} ${target.id}`, "primary");
try {
const res = await axios.post(endpoint, payload);
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 checkUpdate = () => {
const onlinePluginsMap = new Map();
const onlinePluginsNameMap = new Map();
pluginMarketData.value.forEach((plugin) => {
if (plugin.repo) {
onlinePluginsMap.set(plugin.repo.toLowerCase(), plugin);
}
onlinePluginsNameMap.set(plugin.name, plugin);
});
const data = Array.isArray(extension_data?.data) ? extension_data.data : [];
data.forEach((extension) => {
const repoKey = extension.repo?.toLowerCase();
const onlinePlugin = repoKey ? onlinePluginsMap.get(repoKey) : null;
const onlinePluginByName = onlinePluginsNameMap.get(extension.name);
const matchedPlugin = onlinePlugin || onlinePluginByName;
if (matchedPlugin) {
extension.online_version = matchedPlugin.version;
extension.has_update =
extension.version !== matchedPlugin.version &&
matchedPlugin.version !== tm("status.unknown");
} else {
extension.has_update = false;
}
});
};
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 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);
// 如果没有检测到更新且不是强制更新,则弹窗确认
if (!ext?.has_update && !forceUpdate) {
forceUpdateDialog.extensionName = extension_name;
forceUpdateDialog.show = true;
return;
}
loadingDialog.title = tm("status.loading");
loadingDialog.show = true;
try {
const res = await axios.post("/api/plugin/update", {
name: extension_name,
proxy: 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: extension_name,
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) return;
updateAllConfirmDialog.show = true;
};
// 确认更新全部插件
const confirmUpdateAll = () => {
updateAllConfirmDialog.show = false;
updateAllExtensions();
};
// 取消更新全部插件
const cancelUpdateAll = () => {
updateAllConfirmDialog.show = false;
};
const confirmForceUpdate = () => {
const name = forceUpdateDialog.extensionName;
forceUpdateDialog.show = false;
forceUpdateDialog.extensionName = "";
updateExtension(name, true);
};
const updateAllExtensions = async () => {
if (updatingAll.value || updatableExtensions.value.length === 0) 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 axios.post("/api/plugin/update-all", {
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 axios.post("/api/plugin/on", { name: extension.name });
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 axios.post("/api/plugin/off", { name: extension.name });
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;
configDialog.value = true;
try {
const res = await axios.get(
"/api/config/get?plugin_name=" + extension_name,
);
extension_config.metadata = res.data.data.metadata;
extension_config.config = res.data.data.config;
} catch (err) {
toast(err, "error");
}
};
const updateConfig = async () => {
try {
const res = await axios.post(
"/api/config/plugin/update?plugin_name=" + 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;
extension_config.metadata = {};
extension_config.config = {};
getExtensions();
} catch (err) {
toast(err, "error");
}
};
const showPluginInfo = (plugin) => {
selectedPlugin.value = plugin;
showPluginInfoDialog.value = true;
};
const reloadPlugin = async (plugin_name) => {
try {
const res = await axios.post("/api/plugin/reload", { name: 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 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;
dialog.value = true;
uploadTab.value = "url";
}
};
// 确认安装危险插件
const confirmDangerInstall = () => {
if (selectedDangerPlugin.value) {
selectedMarketInstallPlugin.value = selectedDangerPlugin.value;
extension_url.value = selectedDangerPlugin.value.repo;
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 axios.get("/api/plugin/source/get");
if (res.data.status === "ok") {
customSources.value = 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 axios.post("/api/plugin/source/save", {
sources: 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 = "";
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 sourceSelectItems = computed(() => [
{ title: tm("market.defaultSource"), value: "__default__" },
...customSources.value.map((source) => ({
title: source.name,
value: source.url,
})),
]);
const editCustomSource = (source) => {
if (!source) return;
showSourceManagerDialog.value = false;
editingSource.value = true;
originalSourceUrl.value = source.url;
sourceName.value = source.name;
sourceUrl.value = source.url;
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 saveCustomSource = () => {
const normalizedUrl = sourceUrl.value.trim();
if (!sourceName.value.trim() || !normalizedUrl) {
toast(tm("messages.fillSourceNameAndUrl"), "error");
return;
}
// 检查URL格式
try {
new URL(normalizedUrl);
} catch (e) {
toast(tm("messages.invalidUrl"), "error");
return;
}
if (editingSource.value) {
// 编辑模式:更新现有源
const index = customSources.value.findIndex(
(s) => s.url === originalSourceUrl.value,
);
if (index !== -1) {
customSources.value[index] = {
name: sourceName.value.trim(),
url: normalizedUrl,
};
// 如果编辑的是当前选中的源,更新选中源
if (selectedSource.value === originalSourceUrl.value) {
selectedSource.value = normalizedUrl;
localStorage.setItem("selectedPluginSource", selectedSource.value);
// 重新加载插件市场数据
refreshPluginMarket();
}
}
} else {
// 添加模式:检查是否已存在
if (customSources.value.some((source) => source.url === normalizedUrl)) {
toast(tm("market.sourceExists"), "error");
return;
}
customSources.value.push({
name: sourceName.value.trim(),
url: normalizedUrl,
});
}
saveCustomSources();
toast(
editingSource.value ? tm("market.sourceUpdated") : tm("market.sourceAdded"),
"success",
);
// 重置表单
sourceName.value = "";
sourceUrl.value = "";
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 installedRepos = new Set(data.map((ext) => ext.repo?.toLowerCase()));
const installedNames = new Set(data.map((ext) => ext.name));
const installedByRepo = new Map(
data
.filter((ext) => ext.repo)
.map((ext) => [ext.repo.toLowerCase(), ext]),
);
const installedByName = new Map(data.map((ext) => [ext.name, ext]));
for (let i = 0; i < pluginMarketData.value.length; i++) {
const plugin = pluginMarketData.value[i];
const matchedInstalled =
(plugin.repo && installedByRepo.get(plugin.repo.toLowerCase())) ||
installedByName.get(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 =
installedRepos.has(plugin.repo?.toLowerCase()) ||
installedNames.has(plugin.name);
}
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 showVersionCompatibilityWarning = (message) => {
versionCompatibilityDialog.message = message;
versionCompatibilityDialog.show = true;
};
const refreshExtensionsAfterInstallFailure = async () => {
try {
await getExtensions();
} catch (error) {
console.debug("Failed to refresh extensions after install failure:", error);
}
};
const continueInstallIgnoringVersionWarning = async () => {
versionCompatibilityDialog.show = false;
await newExtension(true);
};
const cancelInstallOnVersionWarning = () => {
versionCompatibilityDialog.show = false;
};
const handleInstallResponse = async (resData, { toastStatus = false } = {}) => {
if (
resData.status === "warning" &&
resData.data?.warning_type === "astrbot_version_incompatible"
) {
onLoadingDialogResult(2, resData.message, -1);
showVersionCompatibilityWarning(resData.message);
await refreshExtensionsAfterInstallFailure();
return false;
}
if (toastStatus) {
toast(resData.message, resData.status === "ok" ? "success" : "error");
}
if (resData.status === "error") {
onLoadingDialogResult(2, resData.message, -1);
await refreshExtensionsAfterInstallFailure();
return false;
}
return true;
};
const performInstallRequest = async ({ source, ignoreVersionCheck }) => {
if (source === "file") {
const formData = new FormData();
formData.append("file", upload_file.value);
formData.append("ignore_version_check", String(ignoreVersionCheck));
return axios.post("/api/plugin/install-upload", formData, {
headers: {
"Content-Type": "multipart/form-data",
},
});
}
return axios.post("/api/plugin/install", {
url: extension_url.value,
proxy: getSelectedGitHubProxy(),
ignore_version_check: ignoreVersionCheck,
});
};
const finalizeSuccessfulInstall = async (resData, source) => {
if (source === "file") {
upload_file.value = null;
} else {
extension_url.value = "";
}
onLoadingDialogResult(1, resData.message);
dialog.value = false;
await getExtensions();
checkAlreadyInstalled();
viewReadme({
name: resData.data.name,
repo: resData.data.repo || null,
});
await checkAndPromptConflicts();
};
const newExtension = async (ignoreVersionCheck = false) => {
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;
}
loading_.value = true;
loadingDialog.title = tm("status.loading");
loadingDialog.show = true;
const source = upload_file.value !== null ? "file" : "url";
toast(
source === "file"
? tm("messages.installing")
: tm("messages.installingFromUrl") + " " + extension_url.value,
"primary",
);
try {
const res = await performInstallRequest({ source, ignoreVersionCheck });
loading_.value = false;
const canContinue = await handleInstallResponse(res.data, {
toastStatus: source === "url",
});
if (!canContinue) return;
await finalizeSuccessfulInstall(res.data, source);
} catch (err) {
loading_.value = false;
const message = resolveErrorMessage(err, tm("messages.installFailed"));
if (source === "url") {
toast(message, "error");
}
onLoadingDialogResult(2, message, -1);
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 checkInstallCompatibility = async () => {
installCompat.checked = false;
installCompat.compatible = true;
installCompat.message = "";
const plugin = selectedInstallPlugin.value;
if (!plugin?.astrbot_version || uploadTab.value !== "url") {
return;
}
try {
const res = await axios.post("/api/plugin/check-compat", {
astrbot_version: plugin.astrbot_version,
});
if (res.data.status === "ok") {
installCompat.checked = true;
installCompat.compatible = !!res.data.data?.compatible;
installCompat.message = res.data.data?.message || "";
}
} catch (err) {
console.debug("Failed to check plugin compatibility:", err);
}
};
// 刷新插件市场数据
const refreshPluginMarket = async () => {
refreshingMarket.value = true;
try {
// 强制刷新插件市场数据
const data = await commonStore.getPluginCollections(
true,
selectedSource.value,
);
pluginMarketData.value = data;
trimExtensionName();
checkAlreadyInstalled();
checkUpdate();
refreshRandomPlugins();
currentPage.value = 1; // 重置到第一页
toast(tm("messages.refreshSuccess"), "success");
} catch (err) {
toast(tm("messages.refreshFailed") + " " + err, "error");
} finally {
refreshingMarket.value = false;
}
};
// 生命周期
onMounted(async () => {
if (!syncTabFromHash(getLocationHash())) {
await replaceTabRoute(router, route, activeTab.value);
}
await getExtensions();
// 加载自定义插件源
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);
}
try {
const data = await commonStore.getPluginCollections(
false,
selectedSource.value,
);
pluginMarketData.value = data;
trimExtensionName();
checkAlreadyInstalled();
checkUpdate();
refreshRandomPlugins();
} catch (err) {
toast(tm("messages.getMarketDataFailed") + " " + err, "error");
}
});
// 处理语言切换事件,重新加载插件配置以获取插件的 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 防抖延迟
});
// 监听显示模式变化并保存到 localStorage
watch(isListView, (newVal) => {
if (typeof window !== "undefined" && window.localStorage) {
localStorage.setItem("pluginListViewMode", String(newVal));
}
});
watch(
[() => dialog.value, () => extension_url.value, () => uploadTab.value],
async ([dialogOpen, _, currentUploadTab]) => {
if (!dialogOpen || currentUploadTab !== "url") {
installCompat.checked = false;
installCompat.compatible = true;
installCompat.message = "";
return;
}
await checkInstallCompatibility();
},
);
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);
});
return {
commonStore,
t,
tm,
router,
route,
getSelectedGitHubProxy,
conflictDialog,
checkAndPromptConflicts,
handleConflictConfirm,
fileInput,
activeTab,
validTabs,
isValidTab,
getLocationHash,
extractTabFromHash,
syncTabFromHash,
extension_data,
getInitialShowReserved,
showReserved,
snack_message,
snack_show,
snack_success,
configDialog,
extension_config,
pluginMarketData,
loadingDialog,
showPluginInfoDialog,
selectedPlugin,
curr_namespace,
updatingAll,
readmeDialog,
forceUpdateDialog,
updateAllConfirmDialog,
changelogDialog,
getInitialListViewMode,
isListView,
pluginSearch,
installedStatusFilter,
installedSortBy,
installedSortOrder,
loading_,
currentPage,
dangerConfirmDialog,
selectedDangerPlugin,
selectedMarketInstallPlugin,
installCompat,
versionCompatibilityDialog,
showUninstallDialog,
uninstallTarget,
showSourceDialog,
showSourceManagerDialog,
sourceName,
sourceUrl,
customSources,
selectedSource,
showRemoveSourceDialog,
sourceToRemove,
editingSource,
originalSourceUrl,
extension_url,
dialog,
upload_file,
uploadTab,
showPluginFullName,
marketSearch,
debouncedMarketSearch,
refreshingMarket,
sortBy,
sortOrder,
randomPluginNames,
showRandomPlugins,
normalizeStr,
toPinyinText,
toInitials,
plugin_handler_info_headers,
installedSortItems,
installedSortUsesOrder,
pluginHeaders,
filteredExtensions,
filteredPlugins,
filteredMarketPlugins,
sortedPlugins,
RANDOM_PLUGINS_COUNT,
randomPlugins,
shufflePlugins,
refreshRandomPlugins,
toggleRandomPluginsVisibility,
collapseRandomPlugins,
displayItemsPerPage,
totalPages,
paginatedPlugins,
updatableExtensions,
toggleShowReserved,
toast,
resetLoadingDialog,
onLoadingDialogResult,
failedPluginsDict,
failedPluginItems,
getExtensions,
handleReloadAllFailed,
reloadFailedPlugin,
checkUpdate,
uninstallExtension,
requestUninstallPlugin,
requestUninstallFailedPlugin,
handleUninstallConfirm,
updateExtension,
showUpdateAllConfirm,
confirmUpdateAll,
cancelUpdateAll,
confirmForceUpdate,
updateAllExtensions,
pluginOn,
pluginOff,
openExtensionConfig,
updateConfig,
showPluginInfo,
reloadPlugin,
viewReadme,
viewChangelog,
handleInstallPlugin,
confirmDangerInstall,
cancelDangerInstall,
loadCustomSources,
saveCustomSources,
addCustomSource,
openSourceManagerDialog,
selectPluginSource,
sourceSelectItems,
editCustomSource,
removeCustomSource,
confirmRemoveSource,
saveCustomSource,
trimExtensionName,
checkAlreadyInstalled,
showVersionCompatibilityWarning,
continueInstallIgnoringVersionWarning,
cancelInstallOnVersionWarning,
newExtension,
normalizePlatformList,
getPlatformDisplayList,
resolveSelectedInstallPlugin,
selectedInstallPlugin,
checkInstallCompatibility,
refreshPluginMarket,
handleLocaleChange,
searchDebounceTimer,
};
};