feat: custom plugin api source manager (#3956)
* feat: custom plugin api source manager * fix: rename plugin source file in a safer way * chore: turned the way of saving plugin source to backend and refacted some components * style: clean up whitespace and improve logging message formatting --------- Co-authored-by: Soulter <905617992@qq.com>
This commit is contained in:
@@ -4,12 +4,16 @@ import os
|
||||
import ssl
|
||||
import traceback
|
||||
from datetime import datetime
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Optional
|
||||
import hashlib
|
||||
|
||||
import aiohttp
|
||||
import certifi
|
||||
from quart import request
|
||||
|
||||
from astrbot.core import DEMO_MODE, file_token_service, logger
|
||||
from astrbot.api import sp
|
||||
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
|
||||
from astrbot.core.star.filter.command import CommandFilter
|
||||
from astrbot.core.star.filter.command_group import CommandGroupFilter
|
||||
@@ -25,6 +29,13 @@ PLUGIN_UPDATE_CONCURRENCY = (
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RegistrySource:
|
||||
urls: List[str]
|
||||
cache_file: str
|
||||
md5_url: Optional[str] # None means "no remote MD5, always treat cache as stale"
|
||||
|
||||
|
||||
class PluginRoute(Route):
|
||||
def __init__(
|
||||
self,
|
||||
@@ -45,6 +56,8 @@ class PluginRoute(Route):
|
||||
"/plugin/on": ("POST", self.on_plugin),
|
||||
"/plugin/reload": ("POST", self.reload_plugins),
|
||||
"/plugin/readme": ("GET", self.get_plugin_readme),
|
||||
"/plugin/source/get": ("GET", self.get_custom_source),
|
||||
"/plugin/source/save": ("POST", self.save_custom_source),
|
||||
}
|
||||
self.core_lifecycle = core_lifecycle
|
||||
self.plugin_manager = plugin_manager
|
||||
@@ -84,22 +97,15 @@ class PluginRoute(Route):
|
||||
custom = request.args.get("custom_registry")
|
||||
force_refresh = request.args.get("force_refresh", "false").lower() == "true"
|
||||
|
||||
cache_file = "data/plugins.json"
|
||||
|
||||
if custom:
|
||||
urls = [custom]
|
||||
else:
|
||||
urls = [
|
||||
"https://api.soulter.top/astrbot/plugins",
|
||||
"https://github.com/AstrBotDevs/AstrBot_Plugins_Collection/raw/refs/heads/main/plugin_cache_original.json",
|
||||
]
|
||||
# 构建注册表源信息
|
||||
source = self._build_registry_source(custom)
|
||||
|
||||
# 如果不是强制刷新,先检查缓存是否有效
|
||||
cached_data = None
|
||||
if not force_refresh:
|
||||
# 先检查MD5是否匹配,如果匹配则使用缓存
|
||||
if await self._is_cache_valid(cache_file):
|
||||
cached_data = self._load_plugin_cache(cache_file)
|
||||
if await self._is_cache_valid(source):
|
||||
cached_data = self._load_plugin_cache(source.cache_file)
|
||||
if cached_data:
|
||||
logger.debug("缓存MD5匹配,使用缓存的插件市场数据")
|
||||
return Response().ok(cached_data).__dict__
|
||||
@@ -109,7 +115,7 @@ class PluginRoute(Route):
|
||||
ssl_context = ssl.create_default_context(cafile=certifi.where())
|
||||
connector = aiohttp.TCPConnector(ssl=ssl_context)
|
||||
|
||||
for url in urls:
|
||||
for url in source.urls:
|
||||
try:
|
||||
async with (
|
||||
aiohttp.ClientSession(
|
||||
@@ -128,11 +134,13 @@ class PluginRoute(Route):
|
||||
logger.warning(f"远程插件市场数据为空: {url}")
|
||||
continue # 继续尝试其他URL或使用缓存
|
||||
|
||||
logger.info("成功获取远程插件市场数据")
|
||||
logger.info(
|
||||
f"成功获取远程插件市场数据,包含 {len(remote_data)} 个插件"
|
||||
)
|
||||
# 获取最新的MD5并保存到缓存
|
||||
current_md5 = await self._get_remote_md5()
|
||||
current_md5 = await self._fetch_remote_md5(source.md5_url)
|
||||
self._save_plugin_cache(
|
||||
cache_file,
|
||||
source.cache_file,
|
||||
remote_data,
|
||||
current_md5,
|
||||
)
|
||||
@@ -143,7 +151,7 @@ class PluginRoute(Route):
|
||||
|
||||
# 如果远程获取失败,尝试使用缓存数据
|
||||
if not cached_data:
|
||||
cached_data = self._load_plugin_cache(cache_file)
|
||||
cached_data = self._load_plugin_cache(source.cache_file)
|
||||
|
||||
if cached_data:
|
||||
logger.warning("远程插件市场数据获取失败,使用缓存数据")
|
||||
@@ -151,24 +159,75 @@ class PluginRoute(Route):
|
||||
|
||||
return Response().error("获取插件列表失败,且没有可用的缓存数据").__dict__
|
||||
|
||||
async def _is_cache_valid(self, cache_file: str) -> bool:
|
||||
"""检查缓存是否有效(基于MD5)"""
|
||||
try:
|
||||
if not os.path.exists(cache_file):
|
||||
return False
|
||||
def _build_registry_source(self, custom_url: str | None) -> RegistrySource:
|
||||
"""构建注册表源信息"""
|
||||
if custom_url:
|
||||
# 对自定义URL生成一个安全的文件名
|
||||
url_hash = hashlib.md5(custom_url.encode()).hexdigest()[:8]
|
||||
cache_file = f"data/plugins_custom_{url_hash}.json"
|
||||
|
||||
# 加载缓存文件
|
||||
# 更安全的后缀处理方式
|
||||
if custom_url.endswith(".json"):
|
||||
md5_url = custom_url[:-5] + "-md5.json"
|
||||
else:
|
||||
md5_url = custom_url + "-md5.json"
|
||||
|
||||
urls = [custom_url]
|
||||
else:
|
||||
cache_file = "data/plugins.json"
|
||||
md5_url = "https://api.soulter.top/astrbot/plugins-md5"
|
||||
urls = [
|
||||
"https://api.soulter.top/astrbot/plugins",
|
||||
"https://github.com/AstrBotDevs/AstrBot_Plugins_Collection/raw/refs/heads/main/plugin_cache_original.json",
|
||||
]
|
||||
return RegistrySource(urls=urls, cache_file=cache_file, md5_url=md5_url)
|
||||
|
||||
def _load_cached_md5(self, cache_file: str) -> str | None:
|
||||
"""从缓存文件中加载MD5"""
|
||||
if not os.path.exists(cache_file):
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(cache_file, encoding="utf-8") as f:
|
||||
cache_data = json.load(f)
|
||||
return cache_data.get("md5")
|
||||
except Exception as e:
|
||||
logger.warning(f"加载缓存MD5失败: {e}")
|
||||
return None
|
||||
|
||||
cached_md5 = cache_data.get("md5")
|
||||
async def _fetch_remote_md5(self, md5_url: str | None) -> str | None:
|
||||
"""获取远程MD5"""
|
||||
if not md5_url:
|
||||
return None
|
||||
|
||||
try:
|
||||
ssl_context = ssl.create_default_context(cafile=certifi.where())
|
||||
connector = aiohttp.TCPConnector(ssl=ssl_context)
|
||||
|
||||
async with (
|
||||
aiohttp.ClientSession(
|
||||
trust_env=True,
|
||||
connector=connector,
|
||||
) as session,
|
||||
session.get(md5_url) as response,
|
||||
):
|
||||
if response.status == 200:
|
||||
data = await response.json()
|
||||
return data.get("md5", "")
|
||||
except Exception as e:
|
||||
logger.debug(f"获取远程MD5失败: {e}")
|
||||
return None
|
||||
|
||||
async def _is_cache_valid(self, source: RegistrySource) -> bool:
|
||||
"""检查缓存是否有效(基于MD5)"""
|
||||
try:
|
||||
cached_md5 = self._load_cached_md5(source.cache_file)
|
||||
if not cached_md5:
|
||||
logger.debug("缓存文件中没有MD5信息")
|
||||
return False
|
||||
|
||||
# 获取远程MD5
|
||||
remote_md5 = await self._get_remote_md5()
|
||||
if not remote_md5:
|
||||
remote_md5 = await self._fetch_remote_md5(source.md5_url)
|
||||
if remote_md5 is None:
|
||||
logger.warning("无法获取远程MD5,将使用缓存")
|
||||
return True # 如果无法获取远程MD5,认为缓存有效
|
||||
|
||||
@@ -182,30 +241,6 @@ class PluginRoute(Route):
|
||||
logger.warning(f"检查缓存有效性失败: {e}")
|
||||
return False
|
||||
|
||||
async def _get_remote_md5(self) -> str:
|
||||
"""获取远程插件数据的MD5"""
|
||||
try:
|
||||
ssl_context = ssl.create_default_context(cafile=certifi.where())
|
||||
connector = aiohttp.TCPConnector(ssl=ssl_context)
|
||||
|
||||
async with (
|
||||
aiohttp.ClientSession(
|
||||
trust_env=True,
|
||||
connector=connector,
|
||||
) as session,
|
||||
session.get(
|
||||
"https://api.soulter.top/astrbot/plugins-md5",
|
||||
) as response,
|
||||
):
|
||||
if response.status == 200:
|
||||
data = await response.json()
|
||||
return data.get("md5", "")
|
||||
logger.error(f"获取MD5失败,状态码:{response.status}")
|
||||
return ""
|
||||
except Exception as e:
|
||||
logger.error(f"获取远程MD5失败: {e}")
|
||||
return ""
|
||||
|
||||
def _load_plugin_cache(self, cache_file: str):
|
||||
"""加载本地缓存的插件市场数据"""
|
||||
try:
|
||||
@@ -547,7 +582,7 @@ class PluginRoute(Route):
|
||||
|
||||
plugin_dir = os.path.join(
|
||||
self.plugin_manager.plugin_store_path,
|
||||
plugin_obj.root_dir_name,
|
||||
plugin_obj.root_dir_name or "",
|
||||
)
|
||||
|
||||
if not os.path.isdir(plugin_dir):
|
||||
@@ -572,3 +607,22 @@ class PluginRoute(Route):
|
||||
except Exception as e:
|
||||
logger.error(f"/api/plugin/readme: {traceback.format_exc()}")
|
||||
return Response().error(f"读取README文件失败: {e!s}").__dict__
|
||||
|
||||
async def get_custom_source(self):
|
||||
"""获取自定义插件源"""
|
||||
sources = await sp.global_get("custom_plugin_sources", [])
|
||||
return Response().ok(sources).__dict__
|
||||
|
||||
async def save_custom_source(self):
|
||||
"""保存自定义插件源"""
|
||||
try:
|
||||
data = await request.get_json()
|
||||
sources = data.get("sources", [])
|
||||
if not isinstance(sources, list):
|
||||
return Response().error("sources fields must be a list").__dict__
|
||||
|
||||
await sp.global_put("custom_plugin_sources", sources)
|
||||
return Response().ok(None, "保存成功").__dict__
|
||||
except Exception as e:
|
||||
logger.error(f"/api/plugin/source/save: {traceback.format_exc()}")
|
||||
return Response().error(str(e)).__dict__
|
||||
|
||||
@@ -12,38 +12,40 @@
|
||||
</template>
|
||||
</v-radio>
|
||||
</v-radio-group>
|
||||
<div v-if="radioValue === '1'" style="margin-left: 16px;">
|
||||
<v-radio-group v-model="githubProxyRadioControl" class="mt-2" hide-details="true">
|
||||
<v-radio color="success" v-for="(proxy, idx) in githubProxies" :key="proxy" :value="idx">
|
||||
<template v-slot:label>
|
||||
<div class="d-flex align-center">
|
||||
<span class="mr-2">{{ proxy }}</span>
|
||||
<div v-if="proxyStatus[idx]">
|
||||
<v-chip
|
||||
:color="proxyStatus[idx].available ? 'success' : 'error'"
|
||||
size="x-small"
|
||||
class="mr-1">
|
||||
{{ proxyStatus[idx].available ? '可用' : '不可用' }}
|
||||
</v-chip>
|
||||
<v-chip
|
||||
v-if="proxyStatus[idx].available"
|
||||
color="info"
|
||||
size="x-small">
|
||||
{{ proxyStatus[idx].latency }}ms
|
||||
</v-chip>
|
||||
<v-expand-transition>
|
||||
<div v-if="radioValue === '1'" style="margin-left: 16px;">
|
||||
<v-radio-group v-model="githubProxyRadioControl" class="mt-2" hide-details="true">
|
||||
<v-radio color="success" v-for="(proxy, idx) in githubProxies" :key="proxy" :value="idx">
|
||||
<template v-slot:label>
|
||||
<div class="d-flex align-center">
|
||||
<span class="mr-2">{{ proxy }}</span>
|
||||
<div v-if="proxyStatus[idx]">
|
||||
<v-chip
|
||||
:color="proxyStatus[idx].available ? 'success' : 'error'"
|
||||
size="x-small"
|
||||
class="mr-1">
|
||||
{{ proxyStatus[idx].available ? '可用' : '不可用' }}
|
||||
</v-chip>
|
||||
<v-chip
|
||||
v-if="proxyStatus[idx].available"
|
||||
color="info"
|
||||
size="x-small">
|
||||
{{ proxyStatus[idx].latency }}ms
|
||||
</v-chip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</v-radio>
|
||||
<v-radio color="primary" value="-1" label="自定义">
|
||||
<template v-slot:label v-if="githubProxyRadioControl === '-1'">
|
||||
<v-text-field density="compact" v-model="selectedGitHubProxy" variant="outlined"
|
||||
style="width: 100vw;" placeholder="自定义" hide-details="true">
|
||||
</v-text-field>
|
||||
</template>
|
||||
</v-radio>
|
||||
</v-radio-group>
|
||||
</div>
|
||||
</template>
|
||||
</v-radio>
|
||||
<v-radio color="primary" value="-1" label="自定义">
|
||||
<template v-slot:label v-if="githubProxyRadioControl === '-1'">
|
||||
<v-text-field density="compact" v-model="selectedGitHubProxy" variant="outlined"
|
||||
style="width: 100vw;" placeholder="自定义" hide-details="true">
|
||||
</v-text-field>
|
||||
</template>
|
||||
</v-radio>
|
||||
</v-radio-group>
|
||||
</div>
|
||||
</v-expand-transition>
|
||||
</template>
|
||||
|
||||
|
||||
|
||||
@@ -33,7 +33,8 @@
|
||||
"back": "Back",
|
||||
"selectFile": "Select File",
|
||||
"refresh": "Refresh",
|
||||
"updateAll": "Update All"
|
||||
"updateAll": "Update All",
|
||||
"deleteSource": "Delete Source"
|
||||
},
|
||||
"status": {
|
||||
"enabled": "Enabled",
|
||||
@@ -78,7 +79,27 @@
|
||||
"allPlugins": "📦 All Extensions",
|
||||
"showFullName": "Full Name",
|
||||
"devDocs": "Extension Development Docs",
|
||||
"submitRepo": "Submit Extension Repository"
|
||||
"submitRepo": "Submit Extension Repository",
|
||||
"customSource": "Custom Extension Source",
|
||||
"source": "Source",
|
||||
"availableSources": "Available Sources",
|
||||
"sourceManagement": "Source Management",
|
||||
"addSource": "Add Source",
|
||||
"sourceName": "Source Name",
|
||||
"sourceUrl": "Source URL",
|
||||
"defaultSource": "Official Source",
|
||||
"removeSource": "Remove Source",
|
||||
"confirmRemoveSource": "Are you sure you want to remove this source?",
|
||||
"sourceAdded": "Source added successfully",
|
||||
"sourceRemoved": "Source removed successfully",
|
||||
"sourceError": "Operation failed",
|
||||
"selectSource": "Select Source",
|
||||
"currentSource": "Current Source",
|
||||
"editSource": "Edit Source",
|
||||
"sourceUpdated": "Source updated successfully",
|
||||
"defaultOfficialSource": "Default Official Source",
|
||||
"sourceExists": "This source already exists",
|
||||
"installPlugin": "Install Plugin"
|
||||
},
|
||||
"sort": {
|
||||
"default": "Default",
|
||||
@@ -144,7 +165,10 @@
|
||||
"dontFillBoth": "Please don't fill in both extension URL and upload file",
|
||||
"supportedFormats": "Supports .zip extension files",
|
||||
"updateAllSuccess": "All upgradable extensions have been updated!",
|
||||
"updateAllFailed": "{failed} of {total} extensions failed to update:"
|
||||
"updateAllFailed": "{failed} of {total} extensions failed to update:",
|
||||
"fillSourceNameAndUrl": "Please fill in the complete source name and URL",
|
||||
"invalidUrl": "Please enter a valid URL",
|
||||
"enterJsonUrl": "Please enter a URL that returns plugin list JSON data"
|
||||
},
|
||||
"upload": {
|
||||
"fromFile": "Install from File",
|
||||
|
||||
@@ -33,7 +33,8 @@
|
||||
"back": "返回",
|
||||
"selectFile": "选择文件",
|
||||
"refresh": "刷新",
|
||||
"updateAll": "更新全部插件"
|
||||
"updateAll": "更新全部插件",
|
||||
"deleteSource": "删除源"
|
||||
},
|
||||
"status": {
|
||||
"enabled": "启用",
|
||||
@@ -78,7 +79,27 @@
|
||||
"allPlugins": "📦 全部插件",
|
||||
"showFullName": "完整名称",
|
||||
"devDocs": "插件开发文档",
|
||||
"submitRepo": "提交插件仓库"
|
||||
"submitRepo": "提交插件仓库",
|
||||
"customSource": "自定义插件源",
|
||||
"source": "插件源",
|
||||
"availableSources": "可用源",
|
||||
"sourceManagement": "插件源管理",
|
||||
"addSource": "添加插件源",
|
||||
"sourceName": "源名称",
|
||||
"sourceUrl": "源地址",
|
||||
"defaultSource": "官方插件源",
|
||||
"removeSource": "删除插件源",
|
||||
"confirmRemoveSource": "确定要删除此插件源吗?",
|
||||
"sourceAdded": "插件源添加成功",
|
||||
"sourceRemoved": "插件源删除成功",
|
||||
"sourceError": "操作失败",
|
||||
"selectSource": "选择插件源",
|
||||
"currentSource": "当前插件源",
|
||||
"editSource": "编辑插件源",
|
||||
"sourceUpdated": "插件源更新成功",
|
||||
"defaultOfficialSource": "默认官方源",
|
||||
"sourceExists": "该插件源已存在",
|
||||
"installPlugin": "安装插件"
|
||||
},
|
||||
"sort": {
|
||||
"default": "默认排序",
|
||||
@@ -144,7 +165,10 @@
|
||||
"dontFillBoth": "请不要同时填写插件链接和上传文件",
|
||||
"supportedFormats": "支持 .zip 格式的插件文件",
|
||||
"updateAllSuccess": "所有可更新的插件都已更新!",
|
||||
"updateAllFailed": "有 {failed}/{total} 个插件更新失败:"
|
||||
"updateAllFailed": "有 {failed}/{total} 个插件更新失败:",
|
||||
"fillSourceNameAndUrl": "请填写完整的插件源名称和地址",
|
||||
"invalidUrl": "请输入有效的URL地址",
|
||||
"enterJsonUrl": "请输入返回插件列表JSON数据的URL地址"
|
||||
},
|
||||
"upload": {
|
||||
"fromFile": "从文件安装",
|
||||
|
||||
@@ -154,35 +154,43 @@ export const useCommonStore = defineStore({
|
||||
this.startTime = res.data.data.start_time
|
||||
})
|
||||
},
|
||||
async getPluginCollections(force = false) {
|
||||
async getPluginCollections(force = false, customSource = null) {
|
||||
// 获取插件市场数据
|
||||
if (!force && this.pluginMarketData.length > 0) {
|
||||
if (!force && this.pluginMarketData.length > 0 && !customSource) {
|
||||
return Promise.resolve(this.pluginMarketData);
|
||||
}
|
||||
|
||||
// 如果是强制刷新,添加 force_refresh 参数
|
||||
const url = force ? '/api/plugin/market_list?force_refresh=true' : '/api/plugin/market_list';
|
||||
// 构建URL
|
||||
let url = force ? '/api/plugin/market_list?force_refresh=true' : '/api/plugin/market_list';
|
||||
if (customSource) {
|
||||
url += (url.includes('?') ? '&' : '?') + `custom_registry=${encodeURIComponent(customSource)}`;
|
||||
}
|
||||
|
||||
return axios.get(url)
|
||||
.then((res) => {
|
||||
let data = []
|
||||
for (let key in res.data.data) {
|
||||
data.push({
|
||||
"name": key,
|
||||
"desc": res.data.data[key].desc,
|
||||
"author": res.data.data[key].author,
|
||||
"repo": res.data.data[key].repo,
|
||||
"installed": false,
|
||||
"version": res.data.data[key]?.version ? res.data.data[key].version : "未知",
|
||||
"social_link": res.data.data[key]?.social_link,
|
||||
"tags": res.data.data[key]?.tags ? res.data.data[key].tags : [],
|
||||
"logo": res.data.data[key]?.logo ? res.data.data[key].logo : "",
|
||||
"pinned": res.data.data[key]?.pinned ? res.data.data[key].pinned : false,
|
||||
"stars": res.data.data[key]?.stars ? res.data.data[key].stars : 0,
|
||||
"updated_at": res.data.data[key]?.updated_at ? res.data.data[key].updated_at : "",
|
||||
"display_name": res.data.data[key]?.display_name ? res.data.data[key].display_name : "",
|
||||
})
|
||||
if (res.data.data && typeof res.data.data === 'object') {
|
||||
for (let key in res.data.data) {
|
||||
const pluginData = res.data.data[key];
|
||||
|
||||
data.push({
|
||||
"name": pluginData.name || key, // 优先使用插件数据中的name字段,否则使用键名
|
||||
"desc": pluginData.desc,
|
||||
"author": pluginData.author,
|
||||
"repo": pluginData.repo,
|
||||
"installed": false,
|
||||
"version": pluginData?.version ? pluginData.version : "未知",
|
||||
"social_link": pluginData?.social_link,
|
||||
"tags": pluginData?.tags ? pluginData.tags : [],
|
||||
"logo": pluginData?.logo ? pluginData.logo : "",
|
||||
"pinned": pluginData?.pinned ? pluginData.pinned : false,
|
||||
"stars": pluginData?.stars ? pluginData.stars : 0,
|
||||
"updated_at": pluginData?.updated_at ? pluginData.updated_at : "",
|
||||
"display_name": pluginData?.display_name ? pluginData.display_name : "",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
this.pluginMarketData = data;
|
||||
return data;
|
||||
})
|
||||
|
||||
@@ -67,6 +67,17 @@ const selectedDangerPlugin = ref(null);
|
||||
const showUninstallDialog = ref(false);
|
||||
const pluginToUninstall = ref(null);
|
||||
|
||||
// 自定义插件源相关
|
||||
const showSourceDialog = 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);
|
||||
@@ -549,6 +560,156 @@ const cancelDangerInstall = () => {
|
||||
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 = () => {
|
||||
editingSource.value = false;
|
||||
originalSourceUrl.value = '';
|
||||
sourceName.value = '';
|
||||
sourceUrl.value = '';
|
||||
showSourceDialog.value = true;
|
||||
};
|
||||
|
||||
const selectPluginSource = (sourceUrl) => {
|
||||
selectedSource.value = sourceUrl;
|
||||
if (sourceUrl) {
|
||||
localStorage.setItem('selectedPluginSource', sourceUrl);
|
||||
} else {
|
||||
localStorage.removeItem('selectedPluginSource');
|
||||
}
|
||||
// 重新加载插件市场数据
|
||||
refreshPluginMarket();
|
||||
};
|
||||
|
||||
// 获取当前选中的源对象
|
||||
const selectedSourceObj = computed(() => {
|
||||
if (!selectedSource.value) return null;
|
||||
return customSources.value.find(s => s.url === selectedSource.value) || null;
|
||||
});
|
||||
|
||||
const editCustomSource = (source) => {
|
||||
if (!source) return;
|
||||
editingSource.value = true;
|
||||
originalSourceUrl.value = source.url;
|
||||
sourceName.value = source.name;
|
||||
sourceUrl.value = source.url;
|
||||
showSourceDialog.value = true;
|
||||
};
|
||||
|
||||
const removeCustomSource = (source) => {
|
||||
if (!source) return;
|
||||
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 => {
|
||||
@@ -660,7 +821,7 @@ const refreshPluginMarket = async () => {
|
||||
refreshingMarket.value = true;
|
||||
try {
|
||||
// 强制刷新插件市场数据
|
||||
const data = await commonStore.getPluginCollections(true);
|
||||
const data = await commonStore.getPluginCollections(true, selectedSource.value);
|
||||
pluginMarketData.value = data;
|
||||
trimExtensionName();
|
||||
checkAlreadyInstalled();
|
||||
@@ -678,6 +839,9 @@ const refreshPluginMarket = async () => {
|
||||
// 生命周期
|
||||
onMounted(async () => {
|
||||
await getExtensions();
|
||||
|
||||
// 加载自定义插件源
|
||||
loadCustomSources();
|
||||
|
||||
// 检查是否有 open_config 参数
|
||||
let urlParams;
|
||||
@@ -697,7 +861,7 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await commonStore.getPluginCollections();
|
||||
const data = await commonStore.getPluginCollections(false, selectedSource.value);
|
||||
pluginMarketData.value = data;
|
||||
trimExtensionName();
|
||||
checkAlreadyInstalled();
|
||||
@@ -957,11 +1121,146 @@ watch(marketSearch, (newVal) => {
|
||||
<!-- 插件市场标签页内容 -->
|
||||
<v-tab-item v-show="activeTab === 'market'">
|
||||
|
||||
<!-- 插件源管理区域 -->
|
||||
<div class="mb-6">
|
||||
<div class="d-flex align-center pa-1 pl-2 pr-2" style="background: rgb(var(--v-theme-surface-variant), 0.1); border-radius: 12px; border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));">
|
||||
|
||||
<!-- 左侧:源选择 -->
|
||||
<div class="d-flex align-center flex-grow-1" style="min-width: 0;">
|
||||
<v-icon color="primary" class="ml-2 mr-2" size="small">mdi-source-branch</v-icon>
|
||||
<span class="text-caption text-medium-emphasis text-uppercase font-weight-bold mr-2" style="letter-spacing: 0.05em; white-space: nowrap;">
|
||||
{{ tm('market.source') }}
|
||||
</span>
|
||||
|
||||
<v-menu offset-y>
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-btn
|
||||
v-bind="props"
|
||||
variant="text"
|
||||
class="text-capitalize px-1"
|
||||
:ripple="false"
|
||||
height="32"
|
||||
>
|
||||
<span class="text-body-2 font-weight-medium text-high-emphasis text-truncate" style="max-width: 200px;">
|
||||
{{ selectedSource ? customSources.find(s => s.url === selectedSource)?.name : tm('market.defaultSource') }}
|
||||
</span>
|
||||
<v-icon right size="small" class="ml-1 text-medium-emphasis">mdi-chevron-down</v-icon>
|
||||
<v-tooltip activator="parent" location="top">{{ selectedSource || tm('market.defaultOfficialSource') }}</v-tooltip>
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-list density="compact" nav class="pa-2">
|
||||
<v-list-subheader class="font-weight-bold text-caption text-uppercase mb-1">
|
||||
{{ tm('market.availableSources') }}
|
||||
</v-list-subheader>
|
||||
<v-list-item
|
||||
:value="null"
|
||||
@click="selectPluginSource(null)"
|
||||
rounded="md"
|
||||
color="primary"
|
||||
:active="selectedSource === null"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<v-icon icon="mdi-shield-check" size="small" class="mr-2"></v-icon>
|
||||
</template>
|
||||
<v-list-item-title>{{ tm('market.defaultSource') }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
|
||||
<v-divider class="my-1" v-if="customSources.length > 0"></v-divider>
|
||||
|
||||
<v-list-item
|
||||
v-for="source in customSources"
|
||||
:key="source.url"
|
||||
:value="source.url"
|
||||
@click="selectPluginSource(source.url)"
|
||||
rounded="md"
|
||||
color="primary"
|
||||
:active="selectedSource === source.url"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<v-icon icon="mdi-link-variant" size="small" class="mr-2"></v-icon>
|
||||
</template>
|
||||
<v-list-item-title>{{ source.name }}</v-list-item-title>
|
||||
<v-list-item-subtitle class="text-caption">{{ source.url }}</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</div>
|
||||
|
||||
<!-- 垂直分隔线 -->
|
||||
<div style="height: 20px; width: 1px; background-color: rgba(var(--v-border-color), 0.15); margin: 0 8px;"></div>
|
||||
|
||||
<!--右侧:操作按钮组-->
|
||||
<div class="d-flex align-center">
|
||||
<v-tooltip location="top" :text="tm('market.addSource')">
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-btn
|
||||
v-bind="props"
|
||||
icon="mdi-plus"
|
||||
size="small"
|
||||
variant="text"
|
||||
density="comfortable"
|
||||
color="primary"
|
||||
@click="addCustomSource"
|
||||
></v-btn>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
|
||||
<template v-if="selectedSourceObj">
|
||||
<v-tooltip location="top" :text="tm('market.editSource')">
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-btn
|
||||
v-bind="props"
|
||||
icon="mdi-pencil-outline"
|
||||
size="small"
|
||||
variant="text"
|
||||
density="comfortable"
|
||||
color="medium-emphasis"
|
||||
class="ml-1"
|
||||
@click="editCustomSource(selectedSourceObj)"
|
||||
></v-btn>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
|
||||
<v-tooltip location="top" :text="tm('market.removeSource')">
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-btn
|
||||
v-bind="props"
|
||||
icon="mdi-trash-can-outline"
|
||||
size="small"
|
||||
variant="text"
|
||||
density="comfortable"
|
||||
color="error"
|
||||
class="ml-1"
|
||||
@click="removeCustomSource(selectedSourceObj)"
|
||||
></v-btn>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- <small style="color: var(--v-theme-secondaryText);">每个插件都是作者无偿提供的的劳动成果。如果您喜欢某个插件,请 Star!</small> -->
|
||||
|
||||
<v-btn icon="mdi-plus" size="x-large" style="position: fixed; right: 52px; bottom: 52px; z-index: 10000"
|
||||
@click="dialog = true" color="darkprimary">
|
||||
</v-btn>
|
||||
<!-- FAB Button -->
|
||||
<v-tooltip :text="tm('market.installPlugin')" location="left">
|
||||
<template v-slot:activator="{ props }">
|
||||
<button
|
||||
v-bind="props"
|
||||
type="button"
|
||||
class="v-btn v-btn--elevated v-btn--icon v-theme--PurpleThemeDark bg-darkprimary v-btn--density-default v-btn--size-x-large v-btn--variant-elevated fab-button"
|
||||
style="position: fixed; right: 52px; bottom: 52px; z-index: 10000; border-radius: 16px;"
|
||||
@click="dialog = true"
|
||||
>
|
||||
<span class="v-btn__overlay"></span>
|
||||
<span class="v-btn__underlay"></span>
|
||||
<span class="v-btn__content" data-no-activator="">
|
||||
<i class="mdi-plus mdi v-icon notranslate v-theme--PurpleThemeDark v-icon--size-default" aria-hidden="true" style="font-size: 32px;"></i>
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
|
||||
<div class="mt-4">
|
||||
<div class="d-flex align-center mb-2" style="justify-content: space-between; flex-wrap: wrap; gap: 8px;">
|
||||
@@ -1127,9 +1426,29 @@ watch(marketSearch, (newVal) => {
|
||||
</v-col>
|
||||
|
||||
<v-col v-if="activeTab === 'market'" cols="12" md="12">
|
||||
<small><a href="https://astrbot.app/dev/plugin.html">{{ tm('market.devDocs') }}</a></small> |
|
||||
<small> <a href="https://github.com/AstrBotDevs/AstrBot_Plugins_Collection">{{ tm('market.submitRepo')
|
||||
}}</a></small>
|
||||
<div class="d-flex align-center justify-center mt-4 mb-4 gap-4">
|
||||
<v-btn
|
||||
variant="text"
|
||||
prepend-icon="mdi-book-open-variant"
|
||||
href="https://astrbot.app/dev/plugin.html"
|
||||
target="_blank"
|
||||
color="primary"
|
||||
class="text-none"
|
||||
>
|
||||
{{ tm('market.devDocs') }}
|
||||
</v-btn>
|
||||
<div style="height: 24px; width: 1px; background-color: rgba(var(--v-theme-on-surface), 0.12);"></div>
|
||||
<v-btn
|
||||
variant="text"
|
||||
prepend-icon="mdi-github"
|
||||
href="https://github.com/AstrBotDevs/AstrBot_Plugins_Collection"
|
||||
target="_blank"
|
||||
color="primary"
|
||||
class="text-none"
|
||||
>
|
||||
{{ tm('market.submitRepo') }}
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
@@ -1249,10 +1568,15 @@ watch(marketSearch, (newVal) => {
|
||||
|
||||
<!-- 上传插件对话框 -->
|
||||
<v-dialog v-model="dialog" width="500">
|
||||
<v-card>
|
||||
<v-card-title class="text-h5">{{ tm('dialogs.install.title') }}</v-card-title>
|
||||
<v-card-text>
|
||||
<v-tabs v-model="uploadTab">
|
||||
<div class="v-card v-theme--PurpleThemeDark v-card--density-default rounded-lg v-card--variant-elevated">
|
||||
<div class="v-card__loader">
|
||||
<v-progress-linear :indeterminate="loading_" color="primary" height="2" :active="loading_"></v-progress-linear>
|
||||
</div>
|
||||
|
||||
<div class="v-card-title text-h5">{{ tm('dialogs.install.title') }}</div>
|
||||
|
||||
<div class="v-card-text">
|
||||
<v-tabs v-model="uploadTab" color="primary">
|
||||
<v-tab value="file">{{ tm('dialogs.install.fromFile') }}</v-tab>
|
||||
<v-tab value="url">{{ tm('dialogs.install.fromUrl') }}</v-tab>
|
||||
</v-tabs>
|
||||
@@ -1263,7 +1587,7 @@ watch(marketSearch, (newVal) => {
|
||||
<v-file-input ref="fileInput" v-model="upload_file" :label="tm('upload.selectFile')" accept=".zip"
|
||||
hide-details hide-input class="d-none"></v-file-input>
|
||||
|
||||
<v-btn color="primary" size="large" prepend-icon="mdi-upload" @click="$refs.fileInput.click()">
|
||||
<v-btn color="primary" size="large" prepend-icon="mdi-upload" @click="$refs.fileInput.click()" elevation="2">
|
||||
{{ tm('buttons.selectFile') }}
|
||||
</v-btn>
|
||||
|
||||
@@ -1285,19 +1609,79 @@ watch(marketSearch, (newVal) => {
|
||||
<v-window-item value="url">
|
||||
<div class="pa-4">
|
||||
<v-text-field v-model="extension_url" :label="tm('upload.enterUrl')" variant="outlined"
|
||||
prepend-inner-icon="mdi-link" hide-details
|
||||
prepend-inner-icon="mdi-link" hide-details class="rounded-lg mb-4"
|
||||
placeholder="https://github.com/username/repo"></v-text-field>
|
||||
<div class="mt-4">
|
||||
<ProxySelector></ProxySelector>
|
||||
</div>
|
||||
|
||||
<ProxySelector></ProxySelector>
|
||||
</div>
|
||||
</v-window-item>
|
||||
</v-window>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
</div>
|
||||
|
||||
<div class="v-card-actions">
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn color="grey" variant="text" @click="dialog = false">{{ tm('buttons.cancel') }}</v-btn>
|
||||
<v-btn color="primary" variant="text" @click="newExtension">{{ tm('buttons.install') }}</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</v-dialog>
|
||||
|
||||
<!-- 添加/编辑自定义插件源对话框 -->
|
||||
<v-dialog v-model="showSourceDialog" width="500">
|
||||
<v-card>
|
||||
<v-card-title class="text-h5">{{ editingSource ? tm('market.editSource') : tm('market.addSource') }}</v-card-title>
|
||||
<v-card-text>
|
||||
<div class="pa-2">
|
||||
<v-text-field
|
||||
v-model="sourceName"
|
||||
:label="tm('market.sourceName')"
|
||||
variant="outlined"
|
||||
prepend-inner-icon="mdi-rename-box"
|
||||
hide-details
|
||||
class="mb-4"
|
||||
placeholder="我的插件源"
|
||||
></v-text-field>
|
||||
|
||||
<v-text-field
|
||||
v-model="sourceUrl"
|
||||
:label="tm('market.sourceUrl')"
|
||||
variant="outlined"
|
||||
prepend-inner-icon="mdi-link"
|
||||
hide-details
|
||||
placeholder="https://example.com/plugins.json"
|
||||
></v-text-field>
|
||||
|
||||
<div class="text-caption text-medium-emphasis mt-2">
|
||||
{{ tm('messages.enterJsonUrl') }}
|
||||
</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn color="grey" variant="text" @click="showSourceDialog = false">{{ tm('buttons.cancel') }}</v-btn>
|
||||
<v-btn color="primary" variant="text" @click="saveCustomSource">{{ tm('buttons.save') }}</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- 删除插件源确认对话框 -->
|
||||
<v-dialog v-model="showRemoveSourceDialog" width="400">
|
||||
<v-card>
|
||||
<v-card-title class="text-h5 d-flex align-center">
|
||||
<v-icon color="warning" class="mr-2">mdi-alert-circle</v-icon>
|
||||
{{ tm('dialogs.uninstall.title') }}
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<div>{{ tm('market.confirmRemoveSource') }}</div>
|
||||
<div v-if="sourceToRemove" class="mt-2">
|
||||
<strong>{{ sourceToRemove.name }}</strong>
|
||||
<div class="text-caption">{{ sourceToRemove.url }}</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn color="grey" variant="text" @click="showRemoveSourceDialog = false">{{ tm('buttons.cancel') }}</v-btn>
|
||||
<v-btn color="error" variant="text" @click="confirmRemoveSource">{{ tm('buttons.deleteSource') }}</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
@@ -1342,4 +1726,14 @@ watch(marketSearch, (newVal) => {
|
||||
.plugin-description::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgba(var(--v-theme-primary-rgb), 0.6);
|
||||
}
|
||||
|
||||
.fab-button {
|
||||
transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.fab-button:hover {
|
||||
transform: translateY(-4px) scale(1.05);
|
||||
box-shadow: 0 12px 20px rgba(var(--v-theme-primary), 0.4);
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user