mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-15 17:30:13 +08:00
Merge pull request #3155 from AstrBotDevs/feat/plugin-display-name-and-logo
feat: add support for plugin display name and logo, and some extension card style fix
This commit is contained in:
@@ -23,7 +23,12 @@ class FileTokenService:
|
||||
for token in expired_tokens:
|
||||
self.staged_files.pop(token, None)
|
||||
|
||||
async def register_file(self, file_path: str, timeout: float = None) -> str:
|
||||
async def check_token_expired(self, file_token: str) -> bool:
|
||||
async with self.lock:
|
||||
await self._cleanup_expired_tokens()
|
||||
return file_token not in self.staged_files
|
||||
|
||||
async def register_file(self, file_path: str, timeout: float | None = None) -> str:
|
||||
"""向令牌服务注册一个文件。
|
||||
|
||||
Args:
|
||||
|
||||
@@ -56,6 +56,12 @@ class StarMetadata:
|
||||
star_handler_full_names: list[str] = field(default_factory=list)
|
||||
"""注册的 Handler 的全名列表"""
|
||||
|
||||
display_name: str | None = None
|
||||
"""用于展示的插件名称"""
|
||||
|
||||
logo_path: str | None = None
|
||||
"""插件 Logo 的路径"""
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"Plugin {self.name} ({self.version}) by {self.author}: {self.desc}"
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ class PluginManager:
|
||||
)
|
||||
"""保留插件的路径。在 packages 目录下"""
|
||||
self.conf_schema_fname = "_conf_schema.json"
|
||||
self.logo_fname = "logo.png"
|
||||
"""插件配置 Schema 文件名"""
|
||||
self._pm_lock = asyncio.Lock()
|
||||
"""StarManager操作互斥锁"""
|
||||
@@ -200,7 +201,7 @@ class PluginManager:
|
||||
|
||||
if os.path.exists(os.path.join(plugin_path, "metadata.yaml")):
|
||||
with open(
|
||||
os.path.join(plugin_path, "metadata.yaml"), "r", encoding="utf-8"
|
||||
os.path.join(plugin_path, "metadata.yaml"), encoding="utf-8"
|
||||
) as f:
|
||||
metadata = yaml.safe_load(f)
|
||||
elif plugin_obj and hasattr(plugin_obj, "info"):
|
||||
@@ -226,6 +227,7 @@ class PluginManager:
|
||||
desc=metadata["desc"],
|
||||
version=metadata["version"],
|
||||
repo=metadata["repo"] if "repo" in metadata else None,
|
||||
display_name=metadata.get("display_name", None),
|
||||
)
|
||||
|
||||
return metadata
|
||||
@@ -407,13 +409,14 @@ class PluginManager:
|
||||
)
|
||||
if os.path.exists(plugin_schema_path):
|
||||
# 加载插件配置
|
||||
with open(plugin_schema_path, "r", encoding="utf-8") as f:
|
||||
with open(plugin_schema_path, encoding="utf-8") as f:
|
||||
plugin_config = AstrBotConfig(
|
||||
config_path=os.path.join(
|
||||
self.plugin_config_path, f"{root_dir_name}_config.json"
|
||||
),
|
||||
schema=json.loads(f.read()),
|
||||
)
|
||||
logo_path = os.path.join(plugin_dir_path, self.logo_fname)
|
||||
|
||||
if path in star_map:
|
||||
# 通过 __init__subclass__ 注册插件
|
||||
@@ -430,6 +433,7 @@ class PluginManager:
|
||||
metadata.desc = metadata_yaml.desc
|
||||
metadata.version = metadata_yaml.version
|
||||
metadata.repo = metadata_yaml.repo
|
||||
metadata.display_name = metadata_yaml.display_name
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"插件 {root_dir_name} 元数据载入失败: {str(e)}。使用默认元数据。"
|
||||
@@ -540,9 +544,11 @@ class PluginManager:
|
||||
if metadata.module_path in inactivated_plugins:
|
||||
metadata.activated = False
|
||||
|
||||
assert metadata.module_path is not None, (
|
||||
f"插件 {metadata.name} 的模块路径为空。"
|
||||
)
|
||||
# Plugin logo path
|
||||
if os.path.exists(logo_path):
|
||||
metadata.logo_path = logo_path
|
||||
|
||||
assert metadata.module_path, f"插件 {metadata.name} 模块路径为空"
|
||||
|
||||
full_names = []
|
||||
for handler in star_handlers_registry.get_handlers_by_module_name(
|
||||
@@ -642,7 +648,7 @@ class PluginManager:
|
||||
|
||||
if os.path.exists(readme_path):
|
||||
try:
|
||||
with open(readme_path, "r", encoding="utf-8") as f:
|
||||
with open(readme_path, encoding="utf-8") as f:
|
||||
readme_content = f.read()
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
@@ -857,7 +863,7 @@ class PluginManager:
|
||||
|
||||
if os.path.exists(readme_path):
|
||||
try:
|
||||
with open(readme_path, "r", encoding="utf-8") as f:
|
||||
with open(readme_path, encoding="utf-8") as f:
|
||||
readme_content = f.read()
|
||||
except Exception as e:
|
||||
logger.warning(f"读取插件 {dir_name} 的 README.md 文件失败: {str(e)}")
|
||||
|
||||
@@ -8,7 +8,7 @@ import ssl
|
||||
import certifi
|
||||
|
||||
from .route import Route, Response, RouteContext
|
||||
from astrbot.core import logger
|
||||
from astrbot.core import logger, file_token_service
|
||||
from quart import request
|
||||
from astrbot.core.star.star_manager import PluginManager
|
||||
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
|
||||
@@ -54,6 +54,8 @@ class PluginRoute(Route):
|
||||
EventType.OnAfterMessageSentEvent: "发送消息后",
|
||||
}
|
||||
|
||||
self._logo_cache = {}
|
||||
|
||||
async def reload_plugins(self):
|
||||
if DEMO_MODE:
|
||||
return (
|
||||
@@ -147,7 +149,7 @@ class PluginRoute(Route):
|
||||
return False
|
||||
|
||||
# 加载缓存文件
|
||||
with open(cache_file, "r", encoding="utf-8") as f:
|
||||
with open(cache_file, encoding="utf-8") as f:
|
||||
cache_data = json.load(f)
|
||||
|
||||
cached_md5 = cache_data.get("md5")
|
||||
@@ -197,7 +199,7 @@ class PluginRoute(Route):
|
||||
"""加载本地缓存的插件市场数据"""
|
||||
try:
|
||||
if os.path.exists(cache_file):
|
||||
with open(cache_file, "r", encoding="utf-8") as f:
|
||||
with open(cache_file, encoding="utf-8") as f:
|
||||
cache_data = json.load(f)
|
||||
# 检查缓存是否有效
|
||||
if "data" in cache_data and "timestamp" in cache_data:
|
||||
@@ -209,7 +211,7 @@ class PluginRoute(Route):
|
||||
logger.warning(f"加载插件市场缓存失败: {e}")
|
||||
return None
|
||||
|
||||
def _save_plugin_cache(self, cache_file: str, data, md5: str = None):
|
||||
def _save_plugin_cache(self, cache_file: str, data, md5: str | None = None):
|
||||
"""保存插件市场数据到本地缓存"""
|
||||
try:
|
||||
# 确保目录存在
|
||||
@@ -227,12 +229,27 @@ class PluginRoute(Route):
|
||||
except Exception as e:
|
||||
logger.warning(f"保存插件市场缓存失败: {e}")
|
||||
|
||||
async def get_plugin_logo_token(self, logo_path: str):
|
||||
try:
|
||||
if token := self._logo_cache.get(logo_path):
|
||||
if not await file_token_service.check_token_expired(token):
|
||||
return self._logo_cache[logo_path]
|
||||
token = await file_token_service.register_file(logo_path, timeout=300)
|
||||
self._logo_cache[logo_path] = token
|
||||
return token
|
||||
except Exception as e:
|
||||
logger.warning(f"获取插件 Logo 失败: {e}")
|
||||
return None
|
||||
|
||||
async def get_plugins(self):
|
||||
_plugin_resp = []
|
||||
plugin_name = request.args.get("name")
|
||||
for plugin in self.plugin_manager.context.get_all_stars():
|
||||
if plugin_name and plugin.name != plugin_name:
|
||||
continue
|
||||
logo_url = None
|
||||
if plugin.logo_path:
|
||||
logo_url = await self.get_plugin_logo_token(plugin.logo_path)
|
||||
_t = {
|
||||
"name": plugin.name,
|
||||
"repo": "" if plugin.repo is None else plugin.repo,
|
||||
@@ -245,6 +262,8 @@ class PluginRoute(Route):
|
||||
"handlers": await self.get_plugin_handlers_info(
|
||||
plugin.star_handler_full_names
|
||||
),
|
||||
"display_name": plugin.display_name,
|
||||
"logo": f"/api/file/{logo_url}" if logo_url else None,
|
||||
}
|
||||
_plugin_resp.append(_t)
|
||||
return (
|
||||
@@ -469,7 +488,7 @@ class PluginRoute(Route):
|
||||
return Response().error(f"插件 {plugin_name} 没有README文件").__dict__
|
||||
|
||||
try:
|
||||
with open(readme_path, "r", encoding="utf-8") as f:
|
||||
with open(readme_path, encoding="utf-8") as f:
|
||||
readme_content = f.read()
|
||||
|
||||
return (
|
||||
|
||||
@@ -84,20 +84,17 @@ const viewReadme = () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-card class="mx-auto d-flex flex-column" elevation="2" :style="{
|
||||
<v-card class="mx-auto d-flex flex-column" elevation="0" :style="{
|
||||
position: 'relative',
|
||||
backgroundColor: useCustomizerStore().uiTheme === 'PurpleTheme' ? marketMode ? '#f8f0dd' : '#ffffff' : '#282833',
|
||||
color: useCustomizerStore().uiTheme === 'PurpleTheme' ? '#000000dd' : '#ffffff'
|
||||
}">
|
||||
<v-card-text style="padding: 16px; padding-bottom: 0px; display: flex; gap: 16px; width: 100%;">
|
||||
|
||||
<div v-if="extension?.icon">
|
||||
<v-avatar size="65">
|
||||
<v-img :src="extension.icon" :alt="extension.name" cover></v-img>
|
||||
</v-avatar>
|
||||
<div v-if="extension?.logo">
|
||||
<img :src="extension.logo" :alt="extension.name" cover width="100"/>
|
||||
</div>
|
||||
|
||||
<div style="width: 100%;">
|
||||
<div style="overflow-x: auto;">
|
||||
<!-- Top-right three-dot menu -->
|
||||
<div style="position: absolute; right: 8px; top: 8px; z-index: 5;">
|
||||
<v-menu offset-y>
|
||||
@@ -170,8 +167,8 @@ const viewReadme = () => {
|
||||
style="color: gray; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-right: 84px;">
|
||||
{{ extension.author }} / {{ extension.name }}
|
||||
</div>
|
||||
<p class="text-h3 font-weight-black" :class="{ 'text-h4': $vuetify.display.xs }">
|
||||
{{ extension.name }}
|
||||
<p class="text-h3 font-weight-black extension-title" :class="{ 'text-h4': $vuetify.display.xs }">
|
||||
<span class="extension-title__text">{{ extension.display_name?.length ? extension.display_name : extension.name }}</span>
|
||||
<v-tooltip location="top" v-if="extension?.has_update && !marketMode">
|
||||
<template v-slot:activator="{ props: tooltipProps }">
|
||||
<v-icon v-bind="tooltipProps" color="warning" class="ml-2" icon="mdi-update" size="small"></v-icon>
|
||||
@@ -195,7 +192,7 @@ const viewReadme = () => {
|
||||
<v-icon icon="mdi-arrow-up-bold" start></v-icon>
|
||||
{{ extension.online_version }}
|
||||
</v-chip>
|
||||
<v-chip color="primary" label size="small" class="ml-2" v-if="extension.handlers?.length">
|
||||
<v-chip color="primary" label size="small" class="ml-2" v-if="extension.handlers?.length" @click="viewHandlers" style="cursor: pointer;">
|
||||
<v-icon icon="mdi-cogs" start></v-icon>
|
||||
{{ extension.handlers?.length }}{{ tm("card.status.handlersCount") }}
|
||||
</v-chip>
|
||||
@@ -205,7 +202,7 @@ const viewReadme = () => {
|
||||
</v-chip>
|
||||
</div>
|
||||
|
||||
<div class="mt-2" :class="{ 'text-caption': $vuetify.display.xs }" style="overflow-y: auto; height: 60px;">
|
||||
<div class="mt-2" :class="{ 'text-caption': $vuetify.display.xs }" style="overflow-y: auto; height: 80px; font-size: 90%;">
|
||||
{{ extension.desc }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -223,6 +220,19 @@ const viewReadme = () => {
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
.extension-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.extension-title__text {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
padding-top: 6px
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.extension-image-container {
|
||||
margin-left: 8px;
|
||||
|
||||
@@ -159,10 +159,10 @@ export const useCommonStore = defineStore({
|
||||
if (!force && this.pluginMarketData.length > 0) {
|
||||
return Promise.resolve(this.pluginMarketData);
|
||||
}
|
||||
|
||||
|
||||
// 如果是强制刷新,添加 force_refresh 参数
|
||||
const url = force ? '/api/plugin/market_list?force_refresh=true' : '/api/plugin/market_list';
|
||||
|
||||
|
||||
return axios.get(url)
|
||||
.then((res) => {
|
||||
let data = []
|
||||
@@ -180,6 +180,7 @@ export const useCommonStore = defineStore({
|
||||
"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 : "",
|
||||
})
|
||||
}
|
||||
this.pluginMarketData = data;
|
||||
|
||||
@@ -42,7 +42,7 @@ const PurpleTheme: ThemeTypes = {
|
||||
preBg: 'rgb(249, 249, 249)',
|
||||
code: 'rgb(13, 13, 13)',
|
||||
chatMessageBubble: '#e7ebf4',
|
||||
mcpCardBg: '#f7f2f9',
|
||||
mcpCardBg: '#ecf2faff',
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -210,7 +210,6 @@ const checkUpdate = () => {
|
||||
} else {
|
||||
extension.has_update = false;
|
||||
}
|
||||
extension.logo = matchedPlugin?.logo;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -544,7 +543,7 @@ onMounted(async () => {
|
||||
<template>
|
||||
<v-row>
|
||||
<v-col cols="12" md="12">
|
||||
<v-card variant="flat">
|
||||
<v-card variant="flat" style="background-color: transparent;">
|
||||
<!-- 标签页 -->
|
||||
<v-card-text>
|
||||
<!-- 标签栏和搜索栏 - 响应式布局 -->
|
||||
@@ -647,7 +646,7 @@ onMounted(async () => {
|
||||
<div class="text-subtitle-1 font-weight-medium">{{ item.name }}</div>
|
||||
<div v-if="item.reserved" class="d-flex align-center mt-1">
|
||||
<v-chip color="primary" size="x-small" class="font-weight-medium">{{ tm('status.system')
|
||||
}}</v-chip>
|
||||
}}</v-chip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -751,7 +750,7 @@ onMounted(async () => {
|
||||
<v-row>
|
||||
<v-col cols="12" md="6" lg="6" v-for="extension in filteredPlugins" :key="extension.name"
|
||||
class="pb-4">
|
||||
<ExtensionCard :extension="extension" class="rounded-lg"
|
||||
<ExtensionCard :extension="extension" class="rounded-lg" style="background-color: rgb(var(--v-theme-mcpCardBg));"
|
||||
@configure="openExtensionConfig(extension.name)" @uninstall="uninstallExtension(extension.name)"
|
||||
@update="updateExtension(extension.name)" @reload="reloadPlugin(extension.name)"
|
||||
@toggle-activation="extension.activated ? pluginOff(extension) : pluginOn(extension)"
|
||||
@@ -798,7 +797,7 @@ onMounted(async () => {
|
||||
</div>
|
||||
|
||||
<v-col cols="12" md="12" style="padding: 0px;">
|
||||
<v-data-table :headers="pluginMarketHeaders" :items="pluginMarketData" item-key="name"
|
||||
<v-data-table :headers="pluginMarketHeaders" :items="pluginMarketData" item-key="name" style="border-radius: 10px;"
|
||||
:loading="loading_" v-model:search="marketSearch" :filter-keys="filterKeys"
|
||||
:custom-filter="marketCustomFilter">
|
||||
<template v-slot:item.name="{ item }">
|
||||
@@ -807,13 +806,16 @@ onMounted(async () => {
|
||||
<img v-if="item.logo" :src="item.logo"
|
||||
style="height: 80px; width: 80px; margin-right: 8px; border-radius: 8px; margin-top: 8px; margin-bottom: 8px;"
|
||||
alt="logo">
|
||||
<span v-if="item?.repo"><a :href="item?.repo"
|
||||
style="color: var(--v-theme-primaryText, #000); text-decoration:none">{{
|
||||
showPluginFullName ? item.name : item.trimmedName }}</a></span>
|
||||
<span v-else>{{ showPluginFullName ? item.name : item.trimmedName }}</span>
|
||||
<a :href="item?.repo" style="color: var(--v-theme-primaryText, #000);
|
||||
text-decoration:none">
|
||||
<div v-if="item.display_name">
|
||||
<span class="d-block">{{ item.display_name }}</span>
|
||||
<small style="color: grey; font-size: 60%;">({{ item.name }})</small>
|
||||
</div>
|
||||
<span v-else>{{ showPluginFullName ? item.name : item.trimmedName }}</span>
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-slot:item.desc="{ item }">
|
||||
<small>
|
||||
{{ item.desc }}
|
||||
@@ -863,7 +865,7 @@ onMounted(async () => {
|
||||
<v-col v-if="activeTab === 'market'" style="margin-bottom: 16px;" 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>
|
||||
}}</a></small>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
@@ -944,7 +946,7 @@ onMounted(async () => {
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn color="blue-darken-1" variant="text" @click="showPluginInfoDialog = false">{{ tm('buttons.close')
|
||||
}}</v-btn>
|
||||
}}</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
Reference in New Issue
Block a user