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:
Soulter
2025-10-26 20:54:24 +08:00
committed by GitHub
8 changed files with 88 additions and 39 deletions

View File

@@ -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:

View File

@@ -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}"

View File

@@ -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)}")

View File

@@ -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 (

View File

@@ -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;

View File

@@ -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;

View File

@@ -42,7 +42,7 @@ const PurpleTheme: ThemeTypes = {
preBg: 'rgb(249, 249, 249)',
code: 'rgb(13, 13, 13)',
chatMessageBubble: '#e7ebf4',
mcpCardBg: '#f7f2f9',
mcpCardBg: '#ecf2faff',
}
};

View File

@@ -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>